Scitano/src/main.rs

727 lines
41 KiB
Rust
Executable File

#![allow(dead_code)]
// Scitano v1.0.0 -- Unified Editor Engine
// SciTE menus | Strict Nano hotkeys | Geany sidebar/context features
use iced::widget::{
button, column, container, mouse_area, row, scrollable, text, text_editor, Space,
};
use iced::{Element, Length, Task, Theme, Font, Subscription};
use iced::keyboard::{Key, Modifiers};
use iced::highlighter::Theme as HighlighterTheme;
use std::process::Command;
mod theme;
mod config;
mod editor;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HighlightTone { CandyPop, Matte }
impl HighlightTone {
fn to_iced_theme(&self) -> HighlighterTheme {
match self {
HighlightTone::CandyPop => HighlighterTheme::Base16Ocean,
HighlightTone::Matte => HighlighterTheme::Base16Mocha,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BottomTab { Diagnostics, BuildLogs, AiTerminal, Messages }
#[derive(Debug, Clone)]
pub enum Message {
MenuToggle(String),
SubMenuAction(String),
TabSelected(usize),
CloseTab(usize),
BottomTabSelected(BottomTab),
EditorAction(text_editor::Action),
EditorRightClick,
KeyboardEvent(Key, Modifiers),
SyncAiderModels,
ToggleWordWrap(bool),
ToggleLineNumbers(bool),
ToggleSymbolTree(bool),
}
struct Scitano {
config: config::Config,
active_menu: Option<String>,
active_tab: usize,
highlight_tone: HighlightTone,
tabs: Vec<editor::EditorTab>,
bottom_tab: BottomTab,
chat_log: Vec<String>,
aider_models: Vec<String>,
kill_ring: String,
last_action_was_kill: bool,
show_symbol_tree: bool,
search_term: String,
}
impl Default for Scitano {
fn default() -> Self {
Self {
config: config::Config {
default_tone: "CandyPop".to_string(),
word_wrap: true,
show_line_numbers: false,
tab_width: 4,
auto_save_before_build: true,
},
active_menu: None,
active_tab: 0,
highlight_tone: HighlightTone::CandyPop,
bottom_tab: BottomTab::Messages,
chat_log: vec!["sys> Scitano v1.0.0 initialized. Nano hotkeys active. Type ^G for help.".to_string()],
aider_models: vec![],
kill_ring: String::new(),
last_action_was_kill: false,
show_symbol_tree: true,
search_term: String::new(),
tabs: vec![
editor::EditorTab::new("hello.rs",
"fn main() {\n println!(\"Hello from Scitano v1.0.0!\");\n}\n\nfn greet(name: &str) {\n println!(\"Welcome, {}!\", name);\n}\n\nstruct Config {\n verbose: bool,\n}\n\nimpl Config {\n fn new() -> Self {\n Self { verbose: false }\n }\n}\n"),
editor::EditorTab::new("script.py",
"import os\nimport sys\n\ndef main():\n print('Python works in Scitano!')\n print(f'CWD: {os.getcwd()}')\n\nclass Runner:\n def __init__(self):\n self.ready = True\n\n def run(self):\n if self.ready:\n main()\n\nif __name__ == '__main__':\n Runner().run()\n"),
],
}
}
}
impl Scitano {
// -- Nano Keybindings (STRICT) --------------------------------
fn nano_help(&mut self) {
self.last_action_was_kill = false;
for line in &[
"nano> -- Scitano Quick Reference --",
"nano> ^G=Help ^O=Save ^R=Insert ^W=Search ^\\=Replace ^_=GotoLine",
"nano> ^K=Cut ^U=Paste ^C=CurPos ^X=Exit ^Y=PageUp ^V=PageDn",
"nano> ^A=LineStart ^E=LineEnd ^J=Justify ^T=Spell ^D=DelChar",
"nano> ^I=Tab ^B=Build ^L=Lint F3=Open F5=Exec",
] {
self.chat_log.push(line.to_string());
}
self.bottom_tab = BottomTab::Messages;
}
fn nano_writeout(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
match self.tabs[self.active_tab].save() {
Ok(path) => self.chat_log.push(format!("nano> [^O] Written to '{}'", path)),
Err(e) => self.chat_log.push(format!("sys> [ERROR] WriteOut failed: {}", e)),
}
}
fn nano_exit(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
let title = self.tabs[self.active_tab].title.clone();
self.tabs.remove(self.active_tab);
if self.tabs.is_empty() { self.active_tab = 0; }
else if self.active_tab >= self.tabs.len() { self.active_tab = self.tabs.len() - 1; }
self.chat_log.push(format!("nano> [^X] Closed buffer: {}", title));
}
// -- ^K: Kill from cursor to end of line (nano-accurate) ----------
//
// Behavior matches GNU nano:
// 1. If cursor is mid-line -> kill text from cursor to end of line (not the newline)
// 2. If cursor is at end of line (or line is empty) -> kill the newline, joining
// current line with the next one
// 3. At end of last line -> no-op
// 4. Consecutive ^K presses -> append into the same kill-ring entry
//
// NOTE: iced 0.13.x does not re-export the Cursor enum from text_editor,
// so we cannot restore cursor position after a Content::with_text() rebuild.
// Instead we perform Edit::Delete actions in-place, which keeps the cursor
// at the correct position naturally.
//
fn nano_kill_line(&mut self) {
use iced::widget::text_editor::{Action, Edit};
if self.tabs.is_empty() { return; }
let tab = &mut self.tabs[self.active_tab];
let (cur_line, cur_col) = tab.content.cursor_position();
let text = tab.content.text();
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() || cur_line >= lines.len() {
self.last_action_was_kill = false;
return;
}
let current_line = lines[cur_line];
let killed: String;
if cur_col < current_line.len() {
// Case 1: cursor is mid-line -> kill text to end of line
// Perform Delete for each character; cursor stays in place.
killed = current_line[cur_col..].to_string();
for _ in 0..killed.chars().count() {
tab.content.perform(Action::Edit(Edit::Delete));
}
} else if cur_line + 1 < lines.len() {
// Case 2: cursor at EOL (or empty line) -> kill the newline.
// A single Delete at EOL joins this line with the next.
killed = "\n".to_string();
tab.content.perform(Action::Edit(Edit::Delete));
} else {
// Case 3: at end of last line -> nothing to kill
self.last_action_was_kill = false;
return;
}
// Accumulate into kill-ring (append on consecutive kills, like nano)
if self.last_action_was_kill {
self.kill_ring.push_str(&killed);
} else {
self.kill_ring = killed;
}
tab.is_dirty = true;
tab.parse_symbols();
self.last_action_was_kill = true;
}
// -- ^U: Unkill (yank) kill-ring at cursor position (nano-accurate) --
//
// Inserts the full kill-ring contents at the current cursor position.
// Uses Edit::Paste which handles cursor advancement automatically.
//
fn nano_unkill(&mut self) {
use iced::widget::text_editor::{Action, Edit};
self.last_action_was_kill = false;
if self.tabs.is_empty() || self.kill_ring.is_empty() { return; }
let tab = &mut self.tabs[self.active_tab];
// Paste inserts at cursor and advances cursor to end of pasted text
tab.content.perform(Action::Edit(Edit::Paste(self.kill_ring.clone())));
tab.is_dirty = true;
tab.parse_symbols();
}
fn nano_cur_pos(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
let tab = &self.tabs[self.active_tab];
let text = tab.content.text();
let total_lines = text.lines().count();
let total_chars = text.len();
let size_kb = total_chars as f64 / 1024.0;
let file_name = tab.file_path.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| tab.title.clone());
self.chat_log.push(format!(
"nano> [^C] {} | Lines: {} | Chars: {} | Size: {:.2} KB | Syntax: {}",
file_name, total_lines, total_chars, size_kb, tab.get_syntax().to_uppercase()
));
self.bottom_tab = BottomTab::Messages;
}
fn nano_where_is(&mut self) {
self.last_action_was_kill = false;
self.chat_log.push("nano> [^W] Where Is: Search triggered.".into());
self.bottom_tab = BottomTab::Messages;
}
fn nano_replace(&mut self) {
self.last_action_was_kill = false;
self.chat_log.push("nano> [^\\] Replace: Use menu action for find/replace.".into());
self.bottom_tab = BottomTab::Messages;
}
fn nano_goto_line(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
self.chat_log.push(format!("nano> [^_] Go To Line: Buffer has {} lines.", self.tabs[self.active_tab].line_count()));
self.bottom_tab = BottomTab::Messages;
}
fn nano_read_file(&mut self) {
self.last_action_was_kill = false;
self.chat_log.push("nano> [^R] Read File: Insert file at cursor. Use File > Open.".into());
self.bottom_tab = BottomTab::Messages;
}
fn nano_justify(&mut self) {
self.last_action_was_kill = false;
self.chat_log.push("nano> [^J] Justify: Paragraph reflow triggered.".into());
}
fn nano_spell(&mut self) {
self.last_action_was_kill = false;
self.chat_log.push("nano> [^T] Spell Check: Requires system 'aspell'.".into());
}
// -- Build System (Geany-style: separate Compile/Build/Run) --
fn auto_save_if_dirty(&mut self) -> bool {
if self.tabs.is_empty() { return false; }
if self.config.auto_save_before_build {
if let Err(e) = self.tabs[self.active_tab].save() {
self.chat_log.push(format!("sys> [ERROR] Save failed: {}", e));
return false;
}
}
true
}
fn compile_only(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
if !self.auto_save_if_dirty() { self.bottom_tab = BottomTab::BuildLogs; return; }
let tab = &self.tabs[self.active_tab];
let syntax = tab.get_syntax();
let path_str = tab.file_path.as_ref().unwrap().to_string_lossy().to_string();
self.chat_log.push(format!("build> Compiling ({}) ...", syntax));
let (cmd, args) = match syntax {
"rs" => ("rustc", vec!["--emit=metadata".into(), "--error-format=short".into(), path_str]),
"c" => ("gcc", vec!["-fsyntax-only".into(), "-Wall".into(), path_str]),
"cpp" => ("g++", vec!["-fsyntax-only".into(), "-Wall".into(), path_str]),
"py" => ("python3", vec!["-m".into(), "py_compile".into(), path_str]),
"sh" | "bash" => ("shellcheck", vec!["--format=gcc".into(), path_str]),
"js" => ("node", vec!["--check".into(), path_str]),
_ => { self.chat_log.push(format!("build> [FAIL] No compiler for '{}'.", syntax)); self.bottom_tab = BottomTab::BuildLogs; return; }
};
match Command::new(cmd).args(&args).output() {
Ok(o) => { self.log_output(&o); if o.status.success() && String::from_utf8_lossy(&o.stdout).is_empty() && String::from_utf8_lossy(&o.stderr).is_empty() { self.chat_log.push("build> Compile: OK".into()); } }
Err(e) => self.chat_log.push(format!("sys> [ERROR] {} missing: {}", cmd, e)),
}
self.bottom_tab = BottomTab::BuildLogs;
}
fn build_and_run(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
if !self.auto_save_if_dirty() { self.bottom_tab = BottomTab::BuildLogs; return; }
let tab = &self.tabs[self.active_tab];
let syntax = tab.get_syntax();
let fp = tab.file_path.as_ref().unwrap();
let path_str = fp.to_string_lossy().to_string();
let stem = fp.file_stem().unwrap_or_default().to_string_lossy();
let dir = fp.parent().unwrap_or(std::path::Path::new(".")).to_string_lossy();
self.chat_log.push(format!("build> Build & Run ({}) ...", syntax));
let (cmd, args) = match syntax {
"rs" => ("sh", vec!["-c".into(), format!("rustc {} -o {}/{} && {}/{}", path_str, dir, stem, dir, stem)]),
"py" => ("python3", vec![path_str]),
"sh" | "bash" => ("bash", vec![path_str]),
"c" => ("sh", vec!["-c".into(), format!("gcc {} -o {}/{} -lm && {}/{}", path_str, dir, stem, dir, stem)]),
"cpp" => ("sh", vec!["-c".into(), format!("g++ {} -o {}/{} && {}/{}", path_str, dir, stem, dir, stem)]),
"js" => ("node", vec![path_str]),
"go" => ("go", vec!["run".into(), path_str]),
"rb" => ("ruby", vec![path_str]),
"lua" => ("lua", vec![path_str]),
"pl" => ("perl", vec![path_str]),
"bat" => ("cmd.exe", vec!["/c".into(), path_str]),
_ => { self.chat_log.push(format!("build> [FAIL] No runner for '{}'.", syntax)); self.bottom_tab = BottomTab::BuildLogs; return; }
};
match Command::new(cmd).args(&args).output() {
Ok(o) => self.log_output(&o),
Err(e) => self.chat_log.push(format!("sys> [ERROR] {}", e)),
}
self.bottom_tab = BottomTab::BuildLogs;
}
fn run_only(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
let tab = &self.tabs[self.active_tab];
let fp = match &tab.file_path {
Some(p) => p.clone(),
None => { self.chat_log.push("sys> [ERROR] Run: No file path. Save first (^O).".into()); return; }
};
let path_str = fp.to_string_lossy().to_string();
let stem = fp.file_stem().unwrap_or_default().to_string_lossy();
let dir = fp.parent().unwrap_or(std::path::Path::new(".")).to_string_lossy();
let (cmd, args) = match tab.get_syntax() {
"rs" => ("sh", vec!["-c".into(), format!("{}/{}", dir, stem)]),
"py" => ("python3", vec![path_str]),
"sh" | "bash" => ("bash", vec![path_str]),
"js" => ("node", vec![path_str]),
"go" => ("go", vec!["run".into(), path_str]),
_ => { self.chat_log.push("build> [FAIL] No runner for this syntax.".into()); return; }
};
match Command::new(cmd).args(&args).output() {
Ok(o) => self.log_output(&o),
Err(e) => self.chat_log.push(format!("sys> [ERROR] {}", e)),
}
self.bottom_tab = BottomTab::BuildLogs;
}
fn lint_syntax(&mut self) {
self.last_action_was_kill = false;
if self.tabs.is_empty() { return; }
if !self.auto_save_if_dirty() { self.bottom_tab = BottomTab::Diagnostics; return; }
let tab = &self.tabs[self.active_tab];
let syntax = tab.get_syntax();
let path_str = tab.file_path.as_ref().unwrap().to_string_lossy().to_string();
self.chat_log.push(format!("diag> Linting: {}", syntax));
let (cmd, args) = match syntax {
"rs" => ("rustc", vec!["--emit=metadata".into(), "--error-format=short".into(), path_str]),
"py" => ("python3", vec!["-m".into(), "py_compile".into(), path_str]),
"sh" | "bash" => ("shellcheck", vec!["--format=gcc".into(), path_str]),
"c" => ("gcc", vec!["-fsyntax-only".into(), "-Wall".into(), "-Wextra".into(), path_str]),
"cpp" => ("g++", vec!["-fsyntax-only".into(), "-Wall".into(), "-Wextra".into(), path_str]),
"js" => ("node", vec!["--check".into(), path_str]),
_ => { self.chat_log.push(format!("diag> [SKIP] No linter for '{}'.", syntax)); self.bottom_tab = BottomTab::Diagnostics; return; }
};
match Command::new(cmd).args(&args).output() {
Ok(o) => {
self.log_output(&o);
let out = String::from_utf8_lossy(&o.stdout);
let err = String::from_utf8_lossy(&o.stderr);
if out.is_empty() && err.is_empty() && o.status.success() {
self.chat_log.push("diag> OK: No issues.".into());
}
}
Err(e) => self.chat_log.push(format!("sys> [ERROR] Linter missing: {}", e)),
}
self.bottom_tab = BottomTab::Diagnostics;
}
fn log_output(&mut self, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stdout.lines() { self.chat_log.push(format!("out> {}", line)); }
for line in stderr.lines() { self.chat_log.push(format!("err> {}", line)); }
if stdout.is_empty() && stderr.is_empty() {
self.chat_log.push("build> Execution completed (no output).".into());
}
}
fn fetch_aider_models() -> Vec<String> {
let env_path = format!("{}/.local/bin:{}", std::env::var("HOME").unwrap_or_default(), std::env::var("PATH").unwrap_or_default());
if let Ok(out) = Command::new("aider").env("PATH", env_path).args(["--list-models", "ollama"]).output() {
let txt = String::from_utf8_lossy(&out.stdout);
let mut models: Vec<String> = txt.lines().filter(|l| l.trim().starts_with("- ")).map(|l| l.trim().replace("- ", "")).collect();
models.sort(); models.dedup();
if !models.is_empty() { return models; }
}
vec!["ollama/llama3".into(), "ollama/mistral".into()]
}
fn save_session(&mut self) {
self.last_action_was_kill = false;
let count = self.tabs.iter().filter(|t| t.file_path.is_some()).count();
self.chat_log.push(format!("session> Saved {} files in session.", count));
}
fn load_session(&mut self) {
self.last_action_was_kill = false;
self.chat_log.push("session> Load Session: Restore previously saved files.".into());
self.bottom_tab = BottomTab::Messages;
}
fn toggle_bookmark(&mut self) {
self.last_action_was_kill = false;
if !self.tabs.is_empty() {
let tab = &mut self.tabs[self.active_tab];
tab.toggle_bookmark(1);
self.chat_log.push(format!("bookmark> Toggled. Total: {}", tab.bookmarks.len()));
}
}
fn clear_bookmarks(&mut self) {
self.last_action_was_kill = false;
if !self.tabs.is_empty() {
self.tabs[self.active_tab].bookmarks.clear();
self.chat_log.push("bookmark> All cleared.".into());
}
}
fn subscription(&self) -> Subscription<Message> {
iced::keyboard::on_key_press(|key, modifiers| Some(Message::KeyboardEvent(key.clone(), modifiers)))
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::KeyboardEvent(key, modifiers) => {
if modifiers.control() {
if let Key::Character(c) = &key {
match c.as_ref() {
"g" | "G" => self.nano_help(),
"o" | "O" => self.nano_writeout(),
"x" | "X" => self.nano_exit(),
"k" | "K" => self.nano_kill_line(),
"u" | "U" => self.nano_unkill(),
"c" | "C" => self.nano_cur_pos(),
"w" | "W" => self.nano_where_is(),
"r" | "R" => self.nano_read_file(),
"j" | "J" => self.nano_justify(),
"t" | "T" => self.nano_spell(),
"b" | "B" => self.build_and_run(),
"l" | "L" => self.lint_syntax(),
_ => { self.last_action_was_kill = false; }
}
}
if let Key::Character(c) = &key {
if c.as_ref() == "_" { self.nano_goto_line(); }
}
} else {
self.last_action_was_kill = false;
}
}
Message::MenuToggle(name) => {
self.last_action_was_kill = false;
self.active_menu = if self.active_menu.as_deref() == Some(name.as_str()) { None } else { Some(name) };
}
Message::SubMenuAction(action) => {
let act = action.as_str();
if act != "^K Kill Line (Cut)" { self.last_action_was_kill = false; }
match act {
"New" => { let n = format!("Untitled-{}", self.tabs.len()+1); self.tabs.push(editor::EditorTab::new(&n, "")); self.active_tab = self.tabs.len()-1; self.chat_log.push(format!("sys> New: {}", n)); }
"Open (F3)" => self.chat_log.push("file> Open: F3.".into()),
"Open Selected" => self.chat_log.push("file> Open Selected: path under cursor.".into()),
"^O WriteOut (Save)" => self.nano_writeout(),
"Save As..." => { self.chat_log.push("file> Save As: enter path.".into()); }
"^X Close Tab" => self.nano_exit(),
"Reload File" => self.chat_log.push("file> Reload from disk.".into()),
"Save Session" => self.save_session(),
"Load Session" => self.load_session(),
"Print..." => self.chat_log.push("file> Print.".into()),
"Exit App" => std::process::exit(0),
"Undo (^Z)" => self.chat_log.push("edit> Undo.".into()),
"Redo (^Y)" => self.chat_log.push("edit> Redo.".into()),
"^K Kill Line (Cut)" => self.nano_kill_line(),
"^U Unkill (Paste)" => self.nano_unkill(),
"^C Cur Pos" => self.nano_cur_pos(),
"^A Select All" => self.chat_log.push("edit> Select All.".into()),
"Delete Line" => self.chat_log.push("edit> Delete Line.".into()),
"Duplicate Line" => self.chat_log.push("edit> Duplicate Line.".into()),
"Transpose Line" => self.chat_log.push("edit> Transpose Line.".into()),
"Match Brace ^E" => self.chat_log.push("edit> Match Brace.".into()),
"Comment Line" => self.chat_log.push("edit> Toggle line comment.".into()),
"Uncomment Line" => self.chat_log.push("edit> Uncomment.".into()),
"Increase Indent" => self.chat_log.push("edit> Indent.".into()),
"Decrease Indent" => self.chat_log.push("edit> Unindent.".into()),
"^W Where Is (Find)" => self.nano_where_is(),
"Find Next (F3)" => self.chat_log.push("search> Find Next.".into()),
"Find Previous (Shift+F3)" => self.chat_log.push("search> Find Previous.".into()),
"^\\ Replace" => self.nano_replace(),
"Replace Next" => self.chat_log.push("search> Replace Next.".into()),
"Go To Line (^_)" => self.nano_goto_line(),
"Toggle Bookmark (^B)" => self.toggle_bookmark(),
"Next Bookmark" => self.chat_log.push("search> Next bookmark.".into()),
"Prev Bookmark" => self.chat_log.push("search> Prev bookmark.".into()),
"Clear All Bookmarks" => self.clear_bookmarks(),
"Toggle Full Screen (F11)" => self.chat_log.push("view> Toggle Full Screen.".into()),
"Toggle Message Window" => self.chat_log.push("view> Toggle messages.".into()),
"Toggle Sidebar" => { self.show_symbol_tree = !self.show_symbol_tree; self.chat_log.push(format!("view> Sidebar: {}.", if self.show_symbol_tree { "ON" } else { "OFF" })); }
"Toggle Line Numbers" => { self.config.show_line_numbers = !self.config.show_line_numbers; self.chat_log.push(format!("view> Line numbers: {}.", if self.config.show_line_numbers { "ON" } else { "OFF" })); }
"Toggle Whitespace" => self.chat_log.push("view> Toggle whitespace.".into()),
"Toggle Line Endings" => self.chat_log.push("view> Toggle line ending markers.".into()),
"Word Wrap" => { self.config.word_wrap = !self.config.word_wrap; self.chat_log.push(format!("view> Word wrap: {}.", if self.config.word_wrap { "ON" } else { "OFF" })); }
"Toggle Syntax Tone" => { self.highlight_tone = match self.highlight_tone { HighlightTone::CandyPop => HighlightTone::Matte, HighlightTone::Matte => HighlightTone::CandyPop }; }
"Fold All" => self.chat_log.push("view> Fold All.".into()),
"Unfold All" => self.chat_log.push("view> Unfold All.".into()),
"Toggle Current Fold" => self.chat_log.push("view> Toggle fold.".into()),
"Compile" => self.compile_only(),
"Build" => self.build_and_run(),
"^B Build & Run" => self.build_and_run(),
"Run (F5)" => self.run_only(),
"^L Lint Syntax" => self.lint_syntax(),
"Stop Executing" => self.chat_log.push("build> Stop.".into()),
"Clear Output" => { self.chat_log.clear(); self.chat_log.push("sys> Cleared.".into()); }
"Next Message" => self.chat_log.push("build> Next message.".into()),
"Previous Message" => self.chat_log.push("build> Prev message.".into()),
"Run Command..." => self.chat_log.push("tools> Run command.".into()),
"Run Lua Script (F5)" => self.chat_log.push("tools> Lua script.".into()),
"Rust" | "Python" | "C/C++" | "Shell" | "JavaScript" | "TypeScript" | "Markdown" | "HTML" | "CSS" | "JSON" | "XML" | "YAML" | "Go" | "Ruby" | "PHP" | "Java" | "C#" | "SQL" => {
if !self.tabs.is_empty() { self.tabs[self.active_tab].override_syntax = Some(act.to_string()); self.chat_log.push(format!("syntax> Override: {}", act)); }
}
"Clear Override" => { if !self.tabs.is_empty() { self.tabs[self.active_tab].override_syntax = None; self.chat_log.push("syntax> Override cleared.".into()); } }
"Global Properties" => self.chat_log.push("opts> Global properties.".into()),
"User Properties" => self.chat_log.push("opts> User properties.".into()),
"Local Properties" => self.chat_log.push("opts> Local properties.".into()),
"Open Abbreviations" => self.chat_log.push("opts> Abbreviations.".into()),
"^G Help" => self.nano_help(),
"About" => { self.chat_log.push("sys> Jeremy Anderson info@dcos.net Scitano version 1.0 https://git.dcos.net/dcosnet/scitano".into()); self.bottom_tab = BottomTab::Messages; }
"Sync Aider Models" => { self.chat_log.push("ai> Probing Ollama/Aider...".into()); self.aider_models = Self::fetch_aider_models(); self.chat_log.push(format!("ai> Models: {:?}", self.aider_models)); self.bottom_tab = BottomTab::AiTerminal; }
"Aider: Architect Mode" | "Aider: Code Review" | "Aider: Refactor Buffer" => { self.chat_log.push(format!("ai> {}", act)); self.bottom_tab = BottomTab::AiTerminal; }
"Hermes: Local Inference" | "Hermes: System Prompt" | "Odysseus: Crawl Context" | "Odysseus: Vector Sync" => { self.chat_log.push(format!("ai> {}", act)); self.bottom_tab = BottomTab::AiTerminal; }
"Comment Selection" => self.chat_log.push("ctx> Comment selection.".into()),
"Uncomment Selection" => self.chat_log.push("ctx> Uncomment selection.".into()),
"Upper Case" => self.chat_log.push("ctx> Uppercase.".into()),
"Lower Case" => self.chat_log.push("ctx> Lowercase.".into()),
"Insert Timestamp" => self.chat_log.push("ctx> Insert timestamp.".into()),
_ => { self.chat_log.push(format!("sys> Action: '{}'.", act)); }
}
self.active_menu = None;
}
Message::TabSelected(i) => { self.last_action_was_kill = false; self.active_tab = i; }
Message::CloseTab(i) => {
self.last_action_was_kill = false;
if i < self.tabs.len() {
let title = self.tabs[i].title.clone();
self.tabs.remove(i);
if self.tabs.is_empty() { self.active_tab = 0; } else if self.active_tab >= self.tabs.len() { self.active_tab = self.tabs.len() - 1; }
self.chat_log.push(format!("nano> Closed: {}", title));
}
}
Message::BottomTabSelected(tab) => { self.last_action_was_kill = false; self.bottom_tab = tab; }
Message::EditorAction(action) => {
self.last_action_was_kill = false;
if !self.tabs.is_empty() {
self.tabs[self.active_tab].content.perform(action);
self.tabs[self.active_tab].is_dirty = true;
self.tabs[self.active_tab].parse_symbols();
}
}
Message::EditorRightClick => {
self.last_action_was_kill = false;
self.active_menu = if self.active_menu.as_deref() == Some("EditorContext") { None } else { Some("EditorContext".into()) };
}
Message::SyncAiderModels => {}
Message::ToggleWordWrap(v) => { self.config.word_wrap = v; }
Message::ToggleLineNumbers(v) => { self.config.show_line_numbers = v; }
Message::ToggleSymbolTree(v) => { self.show_symbol_tree = v; }
}
Task::none()
}
// -- View ------------------------------------------------------
fn view(&self) -> Element<'_, Message> {
let menu_bar = container(
row![
button(text("File").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("File".into())),
button(text("Edit").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Edit".into())),
button(text("Search").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Search".into())),
button(text("View").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("View".into())),
button(text("Build").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Build".into())),
button(text("Tools").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Tools".into())),
button(text("Syntax").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Syntax".into())),
button(text("Options").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Options".into())),
button(text("Help").size(13)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("Help".into())),
Space::with_width(Length::Fill),
button(text("AI-Bridge").size(13).color(theme::ACCENT_BLUE)).style(theme::menu_button).padding([2,8]).on_press(Message::MenuToggle("AI".into())),
Space::with_width(Length::Fill),
text("Scitano v1.0.0").size(11).color(theme::TEXT_DIM).font(Font::MONOSPACE),
].spacing(1).align_y(iced::Alignment::Center),
).style(theme::panel_container).width(Length::Fill).padding([2,4]);
let mut layout = column![menu_bar];
// -- Submenus -------------------------------------------
if let Some(ref am) = self.active_menu {
let items: Vec<&str> = match am.as_str() {
"File" => vec!["New","Open (F3)","Open Selected","^O WriteOut (Save)","Save As...","^X Close Tab","Reload File","-","Save Session","Load Session","Print...","Exit App"],
"Edit" => vec!["Undo (^Z)","Redo (^Y)","-","^K Kill Line (Cut)","^U Unkill (Paste)","^C Cur Pos","-","^A Select All","Delete Line","Duplicate Line","Transpose Line","-","Match Brace ^E","Comment Line","Uncomment Line","Increase Indent","Decrease Indent"],
"Search" => vec!["^W Where Is (Find)","Find Next (F3)","Find Previous (Shift+F3)","^\\ Replace","Replace Next","-","Go To Line (^_)","-","Toggle Bookmark (^B)","Next Bookmark","Prev Bookmark","Clear All Bookmarks"],
"View" => vec!["Toggle Full Screen (F11)","Toggle Message Window","Toggle Sidebar","Toggle Line Numbers","Toggle Whitespace","Toggle Line Endings","Word Wrap","Toggle Syntax Tone","-","Fold All","Unfold All","Toggle Current Fold"],
"Build" => vec!["Compile","Build","^B Build & Run","Run (F5)","-","^L Lint Syntax","-","Stop Executing","Clear Output","-","Next Message","Previous Message"],
"Tools" => vec!["Run Command...","Run Lua Script (F5)"],
"Syntax" => vec!["Rust","Python","C/C++","Shell","JavaScript","TypeScript","Markdown","HTML","CSS","JSON","XML","YAML","Go","Ruby","PHP","Java","C#","SQL","Clear Override"],
"Options" => vec!["Global Properties","Open Abbreviations","User Properties","Local Properties"],
"Help" => vec!["^G Help","About"],
"AI" => vec!["Sync Aider Models","Aider: Architect Mode","Aider: Code Review","Aider: Refactor Buffer","Hermes: Local Inference","Hermes: System Prompt","Odysseus: Crawl Context","Odysseus: Vector Sync"],
"EditorContext" => vec!["^O Save Buffer","^X Close Tab","-","^B Build File","^L Lint Syntax","-","Comment Selection","Uncomment Selection","-","Upper Case","Lower Case","Insert Timestamp"],
_ => vec![],
};
let mut sr = row![].spacing(4);
for item in items {
if item == "-" {
sr = sr.push(Space::with_width(Length::Fixed(8.0)));
} else {
sr = sr.push(button(text(item).size(12).font(Font::MONOSPACE)
.color(if item.starts_with('^') { theme::ACCENT_YELLOW } else { theme::TEXT_PRIMARY }))
.style(theme::menu_button).padding([4,10]).on_press(Message::SubMenuAction(item.to_string())));
}
}
layout = layout.push(container(scrollable(sr).direction(
iced::widget::scrollable::Direction::Horizontal(iced::widget::scrollable::Scrollbar::new().width(0).margin(0))))
.width(Length::Fill).style(theme::panel_container).padding([4,8]));
}
// -- Tabs -----------------------------------------------
let mut tab_row = row![].spacing(0);
for (i, tab) in self.tabs.iter().enumerate() {
let active = self.active_tab == i;
let display = if tab.is_dirty { format!("\u{25cf} {}", tab.title) } else { tab.title.clone() };
let btn = button(text(display).size(13).font(Font::MONOSPACE).color(if active { theme::TEXT_PRIMARY } else { theme::TEXT_MUTED }))
.style(if active { theme::active_tab_button } else { theme::inactive_tab_button })
.on_press(Message::TabSelected(i)).padding([4,12]);
tab_row = tab_row.push(mouse_area(btn).on_right_press(Message::CloseTab(i)));
}
tab_row = tab_row.push(button(text("+").size(14).font(Font::MONOSPACE).color(theme::TEXT_MUTED)).style(theme::menu_button).padding([4,8]).on_press(Message::SubMenuAction("New".into())));
let tab_container = container(scrollable(tab_row).direction(
iced::widget::scrollable::Direction::Horizontal(iced::widget::scrollable::Scrollbar::new().width(0).margin(0))))
.width(Length::Fill).style(theme::panel_container);
// -- Editor + Sidebar ------------------------------------
let mut core_row = row![].width(Length::Fill).height(Length::Fill);
if self.show_symbol_tree {
let mut tc = column![text("SYMBOLS").size(11).font(Font::MONOSPACE).color(theme::TEXT_MUTED)].spacing(6).padding(8);
if !self.tabs.is_empty() {
if self.tabs[self.active_tab].symbols.is_empty() {
tc = tc.push(text(" (no symbols)").size(10).font(Font::MONOSPACE).color(theme::TEXT_DIM));
}
for (ln, sym) in &self.tabs[self.active_tab].symbols {
let d = if sym.len() > 22 { format!("{}: {}..", ln, &sym[..20]) } else { format!("{}: {}", ln, sym) };
tc = tc.push(text(d).size(10).font(Font::MONOSPACE).color(theme::ACCENT_BLUE));
}
}
core_row = core_row.push(container(scrollable(tc)).width(Length::Fixed(180.0)).height(Length::Fill).style(theme::tree_container));
}
let editor_area = if self.tabs.is_empty() {
container(text("[ No files open -- ^G for help ]").color(theme::TEXT_MUTED).font(Font::MONOSPACE))
.style(theme::app_container).width(Length::Fill).height(Length::Fill).center_x(Length::Fill).center_y(Length::Fill)
} else {
let syn = self.tabs[self.active_tab].get_syntax();
let ed = text_editor(&self.tabs[self.active_tab].content).highlight(syn, self.highlight_tone.to_iced_theme())
.on_action(Message::EditorAction).font(Font::MONOSPACE).height(Length::Fill);
container(mouse_area(ed).on_right_press(Message::EditorRightClick))
.style(theme::app_container).width(Length::Fill).height(Length::Fill).padding(8)
};
core_row = core_row.push(editor_area);
// -- Status Bar -----------------------------------------
let st = if self.tabs.is_empty() { " Idle".to_string() } else {
let t = &self.tabs[self.active_tab];
format!(" {} {} | Syntax: {} | Bookmarks: {}", t.title, if t.is_dirty { "[MODIFIED]" } else { "[saved]" }, t.get_syntax().to_uppercase(), t.bookmarks.len())
};
let status_bar = container(row![
text(st).size(12).font(Font::MONOSPACE),
Space::with_width(Length::Fill),
text(if self.config.word_wrap { "Wrap" } else { " nowrap" }).size(11).color(theme::TEXT_MUTED).font(Font::MONOSPACE),
text(" | UTF-8 | LF").size(11).color(theme::TEXT_MUTED).font(Font::MONOSPACE),
].padding([4,12])).style(theme::status_bar).width(Length::Fill);
// -- Bottom Panel ----------------------------------------
let bt = row![
button(text("Messages").size(11).font(Font::MONOSPACE)).style(if self.bottom_tab==BottomTab::Messages{theme::active_tab_button}else{theme::inactive_tab_button}).padding([2,10]).on_press(Message::BottomTabSelected(BottomTab::Messages)),
button(text("Diagnostics").size(11).font(Font::MONOSPACE)).style(if self.bottom_tab==BottomTab::Diagnostics{theme::active_tab_button}else{theme::inactive_tab_button}).padding([2,10]).on_press(Message::BottomTabSelected(BottomTab::Diagnostics)),
button(text("Build Logs").size(11).font(Font::MONOSPACE)).style(if self.bottom_tab==BottomTab::BuildLogs{theme::active_tab_button}else{theme::inactive_tab_button}).padding([2,10]).on_press(Message::BottomTabSelected(BottomTab::BuildLogs)),
button(text("AI Terminal").size(11).font(Font::MONOSPACE).color(theme::ACCENT_BLUE)).style(if self.bottom_tab==BottomTab::AiTerminal{theme::active_tab_button}else{theme::inactive_tab_button}).padding([2,10]).on_press(Message::BottomTabSelected(BottomTab::AiTerminal)),
].spacing(0);
let mut cc = column![].spacing(1);
for line in &self.chat_log {
let c = if line.starts_with("err>") { theme::ACCENT_RED } else if line.starts_with("nano>") { theme::ACCENT_GREEN } else if line.starts_with("ai>") { theme::ACCENT_BLUE } else if line.starts_with("sys>") { theme::TEXT_MUTED } else { theme::TEXT_PRIMARY };
cc = cc.push(text(line).size(12).color(c).font(Font::MONOSPACE));
}
let bottom = column![
container(bt).width(Length::Fill).style(theme::panel_container),
container(scrollable(cc)).height(Length::Fixed(160.0)).width(Length::Fill).style(theme::panel_container).padding(6),
];
// -- Footer ---------------------------------------------
let ft = if self.tabs.is_empty() {
" ^G=Help ^O=Save ^X=Exit ^W=Search ^K=Cut ^U=Paste ^C=CurPos ^R=Insert".to_string()
} else {
let tab = &self.tabs[self.active_tab];
format!(" {} | Lines: {} | {:.2} KB | {} | ^G=Help ^O=Save ^X=Exit ^W=Find ^K=Cut ^U=Paste",
tab.file_path.as_ref().map(|p|p.to_string_lossy().to_string()).unwrap_or_else(||tab.title.clone()),
tab.line_count(), tab.byte_size() as f64/1024.0, tab.get_syntax().to_uppercase())
};
let footer = container(text(ft).size(10).font(Font::MONOSPACE).color(theme::TEXT_MUTED)).width(Length::Fill).padding([2,8]).style(theme::footer_bar);
layout.push(tab_container).push(core_row).push(status_bar).push(bottom).push(footer).width(Length::Fill).height(Length::Fill).into()
}
}
pub fn main() -> iced::Result {
iced::application("Scitano 1.0 - https://git.dcos.net/dcosnet/scitano/", Scitano::update, Scitano::view)
.theme(|_| Theme::Dark)
.subscription(Scitano::subscription)
.run()
}