727 lines
41 KiB
Rust
Executable File
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()
|
|
} |