A hotkey-first programmer's editor built in Rust on the iced GUI framework. Scitano combines the menu structure of SciTE, strict GNU nano hotkeys, and Geany-inspired sidebar and context features into a single unified editing environment.

This commit is contained in:
Jeremy Anderson 2026-07-13 11:54:10 -04:00
commit 796b53be01
9 changed files with 5693 additions and 0 deletions

4381
Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

8
Cargo.toml Executable file
View File

@ -0,0 +1,8 @@
[package]
name = "scitano"
version = "1.0.0"
edition = "2021"
description = "Scitano Unified Editor - SciTE menus, strict Nano hotkeys, Geany features in Rust/iced"
[dependencies]
iced = { version = "0.13", features = ["highlighter", "advanced"] }

220
README.md Normal file
View File

@ -0,0 +1,220 @@
# Scitano 1.0
**Jeremy Anderson** — info@dcos.net
https://git.dcos.net/dcosnet/scitano/
A hotkey-first programmer's editor built in Rust on the iced GUI framework. Scitano combines the menu structure of SciTE, strict GNU nano hotkeys, and Geany-inspired sidebar and context features into a single unified editing environment.
![Scitano 1.0 screenshot](screenshot.png)
---
## Features
- **Strict Nano Hotkeys**`^K`/`^U` kill-ring, `^O` writeout, `^X` exit, `^W` search, and more — all faithful to GNU nano behavior
- **SciTE-Style Menus** — File, Edit, Search, View, Build, Tools, Syntax, Options, Help, and AI-Bridge menus across the top
- **Geany-Inspired Sidebar** — Symbol tree auto-parses `fn`, `struct`, `impl`, `enum`, `trait`, `class`, `def`, `mod`, and `macro_rules!` definitions
- **Multi-Tab Editing** — Multiple buffers with dirty indicators (`●`), right-click close, and `+` to create new tabs
- **Syntax Highlighting** — 20+ languages: Rust, Python, C/C++, Shell, JavaScript, TypeScript, Markdown, HTML, CSS, JSON, XML, YAML, Go, Ruby, PHP, Java, C#, SQL, and more
- **Dual Highlight Tones** — CandyPop (Base16 Ocean) and Matte (Base16 Mocha) switchable from the View menu
- **Bottom Panel** — Messages, Diagnostics, Build Logs, and AI Terminal panes with color-coded output
- **Right-Click Context Menu** — Quick access to Save, Close, Build, Lint, Comment/Uncomment, and case transforms
- **Build Integration** — Compile, build-and-run, run-only, and lint with output routed to the Build Logs pane
- **AI Bridge** — Aider and Hermes/Odysseus integration points for AI-assisted editing
---
## Quickstart
### Prerequisites
- Rust 1.70+ (edition 2021)
- A system display server (X11 or Wayland with XWayland)
### Build
```bash
git clone https://git.dcos.net/dcosnet/scitano.git
cd scitano
cargo build --release
```
Or use the included build script:
```bash
chmod +x build.sh
./build.sh
```
The release binary will be at `target/release/scitano`.
### Run
```bash
./target/release/scitano
```
Scitano launches with two sample buffers (`hello.rs` and `script.py`) so you can start editing immediately.
### Install (system-wide)
```bash
cargo install --path .
```
---
## Hotkey Reference
All hotkeys follow GNU nano conventions. `^` denotes `Ctrl`. Hotkeys are active when no text is selected and the editor has focus.
### Core Nano Hotkeys
| Key | Action | Description |
|-----|--------|-------------|
| `^G` | Help | Display the quick-reference hotkey list in the Messages pane |
| `^O` | WriteOut | Save the current buffer to disk |
| `^X` | Exit | Close the current tab |
| `^K` | Kill Line (Cut) | Kill text from cursor to end of line. Consecutive presses accumulate into the kill-ring: first press kills line content, second press (at EOL) kills the newline and joins with the next line, further presses continue accumulating. Any non-`^K` action resets the accumulation. |
| `^U` | Unkill (Paste/Yank) | Insert the entire kill-ring at the cursor position. Cursor advances to the end of the pasted text. Resets the consecutive-kill flag. |
| `^C` | Cursor Position | Display current buffer info in the Messages pane (line count, byte size, syntax, file path) |
| `^W` | Where Is (Search) | Trigger search (opens Search menu) |
| `^\` | Replace | Trigger find-and-replace |
| `^_` | Go To Line | Display line count for the current buffer |
| `^R` | Read File | Insert file at cursor (opens File menu) |
| `^J` | Justify | Paragraph reflow |
| `^T` | Spell Check | Requires system `aspell` |
### Cursor Movement
| Key | Action |
|-----|--------|
| `^A` | Move to start of current line |
| `^E` | Move to end of current line |
| `^Y` | Page Up |
| `^V` | Page Down |
| `^D` | Delete character at cursor |
| `^I` | Insert tab |
### Build & Lint
| Key | Action |
|-----|--------|
| `^B` | Build and run the current file |
| `^L` | Lint syntax of the current file |
### Function Keys
| Key | Action |
|-----|--------|
| `F3` | Open file |
| `F5` | Execute / run |
| `F11` | Toggle full screen |
### Mouse
| Action | Effect |
|--------|--------|
| Right-click on tab | Close that tab |
| Right-click in editor | Open context menu (Save, Build, Lint, Comment, etc.) |
### Kill-Ring Behavior (^K / ^U)
The kill-ring faithfully replicates GNU nano's cut/paste model:
1. **Mid-line `^K`** — Kills all text from the cursor to the end of the current line (text only, not the line break). Cursor stays in place.
2. **End-of-line `^K`** — Kills the newline character, joining the current line with the next. Cursor sits at the junction point.
3. **Empty-line `^K`** — Same as end-of-line: removes the line by joining with the next.
4. **Last-line `^K`** — No-op when the cursor is at the end of the final line; nothing to kill.
5. **Consecutive `^K`** — Each subsequent press appends to the same kill-ring entry. For example, four presses on a 3-line block kill: line content, newline, next line content, newline — building `"line one\nline two\n"`.
6. **`^U` (yank)** — Inserts the full kill-ring at the current cursor position and advances the cursor to the end of the inserted text. Resets the consecutive-kill flag.
7. **Chain breaking** — Any action other than `^K` (typing, cursor movement, mouse click, etc.) resets the kill chain so the next `^K` starts a fresh kill-ring entry.
---
## Menu Structure
### File
`New` · `Open (F3)` · `Open Selected` · `^O WriteOut (Save)` · `Save As...` · `^X Close Tab` · `Reload File` · `Save Session` · `Load Session` · `Print...` · `Exit App`
### Edit
`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
`^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
`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
`Compile` · `Build` · `^B Build & Run` · `Run (F5)` · `^L Lint Syntax` · `Stop Executing` · `Clear Output` · `Next Message` · `Previous Message`
### Tools
`Run Command...` · `Run Lua Script (F5)`
### Syntax
`Rust` · `Python` · `C/C++` · `Shell` · `JavaScript` · `TypeScript` · `Markdown` · `HTML` · `CSS` · `JSON` · `XML` · `YAML` · `Go` · `Ruby` · `PHP` · `Java` · `C#` · `SQL` · `Clear Override`
### Options
`Global Properties` · `Open Abbreviations` · `User Properties` · `Local Properties`
### Help
`^G Help` · `About`
### AI
`Sync Aider Models` · `Aider: Architect Mode` · `Aider: Code Review` · `Aider: Refactor Buffer` · `Hermes: Local Inference` · `Hermes: System Prompt` · `Odysseus: Crawl Context` · `Odysseus: Vector Sync`
### Editor Context (right-click)
`^O Save Buffer` · `^X Close Tab` · `^B Build File` · `^L Lint Syntax` · `Comment Selection` · `Uncomment Selection` · `Upper Case` · `Lower Case` · `Insert Timestamp`
---
## Supported Languages
Syntax highlighting is auto-detected by file extension. Manual override is available via the Syntax menu.
| Extension | Language | Extension | Language |
|-----------|----------|-----------|----------|
| `.rs` | Rust | `.rb` | Ruby |
| `.py` `.pyw` | Python | `.php` `.phtml` | PHP |
| `.c` `.h` | C | `.java` | Java |
| `.cpp` `.hpp` `.cc` `.cxx` | C++ | `.cs` | C# |
| `.sh` `.bash` `.zsh` | Shell | `.sql` | SQL |
| `.js` `.mjs` `.cjs` | JavaScript | `.r` | R |
| `.ts` `.tsx` | TypeScript | `.lua` | Lua |
| `.md` | Markdown | `.pl` `.pm` | Perl |
| `.html` `.htm` | HTML | `.ex` `.exs` | Elixir |
| `.css` `.scss` `.sass` `.less` | CSS | `.hs` | Haskell |
| `.json` | JSON | `.scala` | Scala |
| `.xml` | XML | `.swift` | Swift |
| `.yaml` `.yml` | YAML | `.kt` `.kts` | Kotlin |
| `.toml` | TOML | `.dart` | Dart |
| `.ps1` `.psm1` | PowerShell | `.bat` `.cmd` | Batch |
| `.conf` `.cfg` `.ini` | Config/INI | `.vbs` | VBScript |
| Dockerfile / Containerfile | Dockerfile | `.go` | Go |
Special filenames: `Makefile`, `*.rc` (Shell), `nginx*` (Nginx), `apache*`/`httpd*` (Apache)
---
## Project Structure
```
scitano/
├── Cargo.toml # Package manifest (iced 0.13, edition 2021)
├── Cargo.lock # Dependency lockfile
├── build.sh # Release build script
├── screenshot.png # Application screenshot
├── README.md # This file
└── src/
├── main.rs # Application entry point, UI layout, hotkey routing, state machine
├── editor.rs # EditorTab: buffer model, file I/O, syntax detection, symbol parsing
├── config.rs # User configuration (tab width, wrap, auto-save)
└── theme.rs # Dark theme: color constants, panel/button/container styles
```
---
## License
Jeremy Anderson — info@dcos.net — https://git.dcos.net/dcosnet/scitano/

2
build.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
CARGO_INCREMENTAL=0 cargo build --release

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

8
src/config.rs Executable file
View File

@ -0,0 +1,8 @@
#[derive(Debug, Default, Clone)]
pub struct Config {
pub default_tone: String,
pub word_wrap: bool,
pub show_line_numbers: bool,
pub tab_width: usize,
pub auto_save_before_build: bool,
}

193
src/editor.rs Executable file
View File

@ -0,0 +1,193 @@
use iced::widget::text_editor;
use std::fs;
use std::path::PathBuf;
pub struct EditorTab {
pub title: String,
pub content: text_editor::Content,
pub is_dirty: bool,
pub file_path: Option<PathBuf>,
pub override_syntax: Option<String>,
pub symbols: Vec<(usize, String)>,
pub bookmarks: Vec<usize>,
}
impl EditorTab {
pub fn new(title: &str, initial_text: &str) -> Self {
let mut tab = Self {
title: title.to_string(),
content: text_editor::Content::with_text(initial_text),
is_dirty: false,
file_path: None,
override_syntax: None,
symbols: vec![],
bookmarks: vec![],
};
tab.parse_symbols();
tab
}
pub fn parse_symbols(&mut self) {
self.symbols.clear();
for (i, line) in self.content.text().lines().enumerate() {
let t = line.trim_start();
if t.starts_with("fn ")
|| t.starts_with("def ")
|| t.starts_with("class ")
|| t.starts_with("struct ")
|| t.starts_with("impl ")
|| t.starts_with("enum ")
|| t.starts_with("trait ")
|| t.starts_with("mod ")
|| t.starts_with("pub fn ")
|| t.starts_with("pub struct ")
|| t.starts_with("pub enum ")
|| t.starts_with("pub trait ")
|| t.starts_with("pub mod ")
|| t.starts_with("macro_rules!")
{
let name = t
.split('{')
.next()
.unwrap_or(t)
.split('(')
.next()
.unwrap_or(t)
.split('!')
.next()
.unwrap_or(t)
.trim()
.to_string();
self.symbols.push((i + 1, name));
}
}
}
pub fn toggle_bookmark(&mut self, line: usize) {
if self.bookmarks.contains(&line) {
self.bookmarks.retain(|&b| b != line);
} else {
self.bookmarks.push(line);
self.bookmarks.sort();
}
}
pub fn save(&mut self) -> Result<String, std::io::Error> {
let path = self
.file_path
.clone()
.unwrap_or_else(|| PathBuf::from(&self.title));
let text = self.content.text();
fs::write(&path, text)?;
self.is_dirty = false;
self.file_path = Some(path.clone());
Ok(path.to_string_lossy().to_string())
}
pub fn save_as(&mut self, new_path: &str) -> Result<String, std::io::Error> {
let path = PathBuf::from(new_path);
let text = self.content.text();
fs::write(&path, text)?;
self.is_dirty = false;
self.file_path = Some(path.clone());
self.title = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
Ok(path.to_string_lossy().to_string())
}
pub fn line_count(&self) -> usize {
self.content.text().lines().count().max(1)
}
pub fn byte_size(&self) -> usize {
self.content.text().len()
}
pub fn get_syntax(&self) -> &'static str {
if let Some(ref syn) = self.override_syntax {
return match syn.as_str() {
"Rust" => "rs",
"Python" => "py",
"C/C++" | "C" | "C++" => "cpp",
"Shell" | "Bash" => "sh",
"JavaScript" => "js",
"TypeScript" => "ts",
"Markdown" => "md",
"HTML" => "html",
"CSS" => "css",
"JSON" => "json",
"XML" => "xml",
"YAML" => "yaml",
"Go" => "go",
"Ruby" => "rb",
"PHP" => "php",
"Java" => "java",
"C#" => "cs",
"SQL" => "sql",
_ => "txt",
};
}
let name = self.title.to_lowercase();
if name.contains("dockerfile") || name.contains("containerfile") {
return "dockerfile";
}
if name.contains("nginx") {
return "nginx";
}
if name.contains("apache") || name.contains("httpd") {
return "apache";
}
if name.ends_with("rc") {
return "sh";
}
if name.ends_with("makefile") || name == "makefile" {
return "makefile";
}
let parts: Vec<&str> = name.split('.').collect();
if let Some(ext) = parts.last() {
match *ext {
"rs" => "rs",
"sh" | "bash" | "zsh" | "command" => "sh",
"ps1" | "psm1" => "ps1",
"bat" | "cmd" | "dosbat" => "bat",
"vbs" | "vbe" | "wscript" | "vbasic" => "vbs",
"conf" | "cfg" | "ini" | "service" | "target" => "ini",
"yaml" | "yml" => "yaml",
"toml" => "toml",
"json" => "json",
"xml" => "xml",
"html" | "htm" | "xhtml" => "html",
"css" | "scss" | "sass" | "less" => "css",
"js" | "mjs" | "cjs" => "js",
"ts" | "tsx" => "ts",
"py" | "pyw" => "py",
"c" | "h" => "c",
"cpp" | "hpp" | "cc" | "cxx" | "hxx" => "cpp",
"go" => "go",
"rb" => "rb",
"php" | "phtml" => "php",
"java" => "java",
"cs" => "cs",
"sql" => "sql",
"md" | "markdown" => "md",
"r" => "r",
"lua" => "lua",
"pl" | "pm" => "pl",
"ex" | "exs" => "ex",
"hs" => "hs",
"scala" => "scala",
"swift" => "swift",
"kt" | "kts" => "kt",
"dart" => "dart",
_ => "txt",
}
} else {
"txt"
}
}
}

782
src/main.rs Executable file
View File

@ -0,0 +1,782 @@
#![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
//
fn nano_kill_line(&mut self) {
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;
let new_cursor_col: usize;
let mut rebuilt: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
if cur_col < current_line.len() {
// Case 1: cursor is mid-line -> kill text to end of line
killed = current_line[cur_col..].to_string();
rebuilt[cur_line] = current_line[..cur_col].to_string();
new_cursor_col = cur_col; // cursor stays put
} else if cur_line + 1 < lines.len() {
// Case 2: cursor at EOL (or empty line) -> kill the newline
killed = "\n".to_string();
rebuilt[cur_line] = format!("{}{}", lines[cur_line], lines[cur_line + 1]);
rebuilt.remove(cur_line + 1);
new_cursor_col = lines[cur_line].len(); // cursor at the junction
} 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;
}
let new_text = rebuilt.join("\n");
tab.content = text_editor::Content::with_text(&new_text);
tab.is_dirty = true;
tab.parse_symbols();
// Restore cursor to where the kill happened
Self::restore_cursor(&mut tab.content, cur_line, new_cursor_col);
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,
// then moves the cursor to the end of the inserted text.
//
fn nano_unkill(&mut self) {
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];
let (cur_line, cur_col) = tab.content.cursor_position();
let text = tab.content.text();
let paste = self.kill_ring.clone();
// Insert paste text at cursor position
let mut rebuilt: Vec<String> = text.lines().map(|l| l.to_string()).collect();
if rebuilt.is_empty() {
rebuilt.push(String::new());
}
if cur_line < rebuilt.len() {
let line = &rebuilt[cur_line];
let col = cur_col.min(line.len());
let before = &line[..col];
let after = &line[col..];
rebuilt[cur_line] = format!("{}{}{}", before, paste, after);
} else {
rebuilt.push(paste.clone());
}
let new_text = rebuilt.join("\n");
tab.content = text_editor::Content::with_text(&new_text);
tab.is_dirty = true;
tab.parse_symbols();
// Move cursor to end of pasted text
let newline_count = paste.matches('\n').count();
let paste_segments: Vec<&str> = paste.split('\n').collect();
let last_seg_len = paste_segments.last().map(|s| s.len()).unwrap_or(0);
let target_line = cur_line + newline_count;
let target_col = if newline_count > 0 {
last_seg_len
} else {
cur_col + last_seg_len
};
Self::restore_cursor(&mut tab.content, target_line, target_col);
}
// -- Restore cursor after Content rebuild ---------------------------
//
// Content::with_text() resets cursor to (0,0). We walk it back to
// the desired position using the perform() API so the user never
// sees a cursor jump.
//
fn restore_cursor(
content: &mut text_editor::Content,
target_line: usize,
target_col: usize,
) {
use iced::widget::text_editor::{Action, Cursor};
content.perform(Action::Move(Cursor::DocumentStart));
for _ in 0..target_line {
content.perform(Action::Move(Cursor::Down));
}
for _ in 0..target_col {
content.perform(Action::Move(Cursor::Right));
}
}
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()
}

99
src/theme.rs Executable file
View File

@ -0,0 +1,99 @@
use iced::widget::{button, container};
use iced::{Background, Color, Theme, Border, Shadow};
pub const TEXT_PRIMARY: Color = Color::from_rgb(0.9, 0.9, 0.9);
pub const TEXT_MUTED: Color = Color::from_rgb(0.5, 0.5, 0.5);
pub const TEXT_DIM: Color = Color::from_rgb(0.35, 0.35, 0.35);
pub const ACCENT_BLUE: Color = Color::from_rgb(0.4, 0.7, 1.0);
pub const ACCENT_GREEN: Color = Color::from_rgb(0.5, 0.85, 0.5);
pub const ACCENT_YELLOW: Color = Color::from_rgb(0.95, 0.85, 0.4);
pub const ACCENT_RED: Color = Color::from_rgb(0.9, 0.4, 0.4);
pub const BG_PANEL: Color = Color::from_rgb(0.12, 0.12, 0.12);
pub const BG_ACTIVE: Color = Color::from_rgb(0.18, 0.18, 0.18);
pub const BG_STATUS: Color = Color::from_rgb(0.15, 0.25, 0.45);
pub const BG_TREE: Color = Color::from_rgb(0.08, 0.08, 0.08);
pub const BG_FOOTER: Color = Color::from_rgb(0.10, 0.10, 0.10);
pub fn panel_container(_theme: &Theme) -> container::Style {
container::Style {
text_color: Some(TEXT_PRIMARY),
background: Some(Background::Color(BG_PANEL)),
border: Border::default(),
shadow: Shadow::default(),
}
}
pub fn tree_container(_theme: &Theme) -> container::Style {
container::Style {
text_color: Some(TEXT_PRIMARY),
background: Some(Background::Color(BG_TREE)),
border: Border {
color: Color::from_rgb(0.2, 0.2, 0.2),
width: 1.0,
radius: 0.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn app_container(_theme: &Theme) -> container::Style {
container::Style {
text_color: Some(TEXT_PRIMARY),
background: Some(Background::Color(Color::BLACK)),
border: Border::default(),
shadow: Shadow::default(),
}
}
pub fn status_bar(_theme: &Theme) -> container::Style {
container::Style {
text_color: Some(Color::WHITE),
background: Some(Background::Color(BG_STATUS)),
border: Border::default(),
shadow: Shadow::default(),
}
}
pub fn footer_bar(_theme: &Theme) -> container::Style {
container::Style {
text_color: Some(TEXT_MUTED),
background: Some(Background::Color(BG_FOOTER)),
border: Border {
color: Color::from_rgb(0.2, 0.2, 0.2),
width: 1.0,
radius: 0.0.into(),
},
shadow: Shadow::default(),
}
}
pub fn menu_button(_theme: &Theme, status: button::Status) -> button::Style {
match status {
button::Status::Hovered => button::Style {
background: Some(Background::Color(BG_ACTIVE)),
text_color: TEXT_PRIMARY,
..button::Style::default()
},
_ => button::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
text_color: TEXT_PRIMARY,
..button::Style::default()
},
}
}
pub fn active_tab_button(_theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(BG_ACTIVE)),
text_color: TEXT_PRIMARY,
..button::Style::default()
}
}
pub fn inactive_tab_button(_theme: &Theme, _status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(BG_PANEL)),
text_color: TEXT_MUTED,
..button::Style::default()
}
}