#![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, active_tab: usize, highlight_tone: HighlightTone, tabs: Vec, bottom_tab: BottomTab, chat_log: Vec, aider_models: Vec, 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 { 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 = 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 { iced::keyboard::on_key_press(|key, modifiers| Some(Message::KeyboardEvent(key.clone(), modifiers))) } fn update(&mut self, message: Message) -> Task { 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() }