diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..ca37fe6ac31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `src/`: core Rust crates for the editor (`bin/edit` for the terminal app, `buffer`, `tui`, `framebuffer`, etc.). Start from `src/bin/edit/main.rs` for entry flow, and `src/bin/edit/state.rs` for global state/preferences. +- `i18n/`: localization sources (`edit.toml`) compiled into `src/bin/edit/localization.rs`. +- `assets/`: icons, branding, and misc resources bundled with releases. +- `scripts/`: helper executables; prefer `scripts/*.sh` over raw cargo commands to match CI settings. + +## Build, Test, and Development Commands +- `scripts/build-debug.sh` – debug build (`cargo build`). +- `scripts/build-release.sh` – release build with nightly config (`cargo build --config .cargo/release-nightly.toml --release`). +- `scripts/test.sh [-- ]` – run `cargo test`. +- `scripts/check.sh` – `cargo check` (fast validation). +- `scripts/install.sh` – `cargo install --path . --locked`; installs `edit` into `~/.cargo/bin`. + +## Coding Style & Naming Conventions +- Use `cargo fmt` (already configured via `rustfmt.toml`); check formatting before committing. +- Modules follow snake_case; types use UpperCamelCase; constants use SCREAMING_SNAKE. +- Prefer `arena_format!` for UI strings needing formatting; keep UI `classname`s stable for the TUI diffing algorithm. + +## Testing Guidelines +- Tests leverage Rust’s standard `cargo test` runner; integration tests live alongside modules. +- Add `#[test]` functions near the functionality they cover; name as `test__`. +- For rendering or UI changes, add snapshot/debug logs where possible and describe manual test steps in PRs. + +## Commit & Pull Request Guidelines +- Commit messages mirror existing history: concise imperative summary (e.g., “Add preferences dialog with colorschemes”). +- PRs should include: purpose/summary, key changes, testing performed (`cargo check`, `scripts/test.sh`, manual steps), and screenshots for UI shifts. +- Reference related issues (`Fixes #123`) when applicable, and keep PRs focused; split large features into iterative commits when possible. diff --git a/Cargo.toml b/Cargo.toml index 792aa41d4fa..6d2b6dc1b2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ codegen-units = 16 # Make compiling criterion faster (16 is the default lto = "thin" # Similarly, speed up linking by a ton [dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -63,6 +65,4 @@ features = [ [dev-dependencies] criterion = { version = "0.7", features = ["html_reports"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0" } zstd = { version = "0.13", default-features = false } diff --git a/README.md b/README.md index 623c6dfd59c..bfcb4208b3c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ winget install Microsoft.Edit * Rust 1.90 or earlier: `cargo build --config .cargo/release.toml --release` * otherwise: `cargo build --config .cargo/release-nightly.toml --release` +### Helper Scripts + +Instead of memorizing full cargo invocations, you can run the helper scripts in `scripts/`: + +* `scripts/build-release.sh` – runs `cargo build --config .cargo/release-nightly.toml --release` +* `scripts/build-debug.sh` – runs `cargo build` +* `scripts/test.sh` – runs `cargo test` +* `scripts/check.sh` – runs `cargo check` +* `scripts/install.sh` – runs `cargo install --path . --locked` + +All scripts forward additional CLI arguments to cargo (e.g., `scripts/test.sh -- --ignored`). + ### Build Configuration During compilation you can set various environment variables to configure the build. The following table lists the available configuration options: diff --git a/i18n/edit.toml b/i18n/edit.toml index 4492200d173..04bb61d3c39 100644 --- a/i18n/edit.toml +++ b/i18n/edit.toml @@ -8,6 +8,8 @@ __default__ = [ "ja", "ko", "pt_br", + "ro", + "pl", "ru", "zh_hans", "zh_hant", @@ -20,10 +22,12 @@ zh = "zh_hans" [Ctrl] en = "Ctrl" de = "Strg" +ro = "Ctrl" # The keyboard key [Alt] en = "Alt" +ro = "Alt" # The keyboard key [Shift] @@ -34,6 +38,7 @@ es = "Mayús" fi = "Vaihto" fr = "Maj" it = "Maiusc" +ro = "Shift" # Used as a common dialog button [Ok] @@ -53,6 +58,7 @@ it = "OK" ja = "OK" ko = "확인" nl = "OK" +pl = "OK" pt_br = "OK" pt_pt = "OK" ro = "OK" @@ -286,6 +292,11 @@ vi = "Mở tệp…" zh_hans = "打开文件…" zh_hant = "開啟檔案…" +[FileOpenRecent] +en = "Open Recent…" +pl = "Otwórz ostatnie…" +ro = "Deschide recent…" + [FileSave] en = "Save" bn = "সংরক্ষণ" @@ -710,6 +721,10 @@ vi = "Chọn tất cả" zh_hans = "全选" zh_hant = "全選" +[EditPreferences] +en = "Preferences" +ro = "Preferințe" + # A menu bar item [View] en = "View" @@ -1767,3 +1782,132 @@ uk = "Файл вже існує. Перезаписати?" vi = "Tệp đã tồn tại. Bạn có muốn ghi đè không?" zh_hans = "文件已存在。要覆盖它吗?" zh_hant = "檔案已存在。要覆蓋它嗎?" +[PreferencesDialogTitle] +en = "Preferences" +pl = "Preferencje" +ro = "Preferințe" + +[PreferencesAutoClose] +en = "Auto close brackets" +pl = "Automatycznie domykaj nawiasy" +ro = "Închide automat parantezele" + +[PreferencesGeneral] +en = "General" +pl = "Ogólne" +ro = "General" + +[PreferencesLineHighlight] +en = "Highlight current line" +pl = "Podświetlaj bieżący wiersz" +ro = "Evidențiază linia curentă" + +[PreferencesColorscheme] +en = "Color scheme" +pl = "Motyw kolorów" +ro = "Schemat de culori" + +[PreferencesShowLineNumbers] +en = "Show line numbers" +pl = "Pokaż numery wierszy" +ro = "Afișează numerele liniilor" + +[PreferencesWordWrap] +en = "Wrap long lines" +pl = "Zawijaj długie wiersze" +ro = "Încadrează liniile lungi" + +[PreferencesIndentWithTabs] +en = "Use tabs for indentation" +pl = "Używaj tabulatorów do wcięć" +ro = "Folosește tabulatori pentru indentare" + +[PreferencesTabWidth] +en = "Tab width" +pl = "Szerokość tabulatora" +ro = "Lățimea tabulatorului" + +[PreferencesLanguage] +en = "Language" +pl = "Język" +ro = "Limba" + +[PreferencesLanguageSystem] +en = "Match system language" +pl = "Język systemu" +ro = "Potrivește limba sistemului" + +[PreferencesSchemeSystem] +en = "System" +pl = "Systemowy" +ro = "Sistem" + +[PreferencesSchemeMidnight] +en = "Midnight" +pl = "Północ" +ro = "Miez de noapte" + +[PreferencesSchemeDaylight] +en = "Daylight" +pl = "Światło dzienne" +ro = "Lumina zilei" + +[PreferencesSchemeNord] +en = "Nord" +pl = "Nord" +ro = "Nord" + +[PreferencesSchemeHighContrast] +en = "High Contrast" +pl = "Wysoki kontrast" +ro = "Contrast ridicat" + +[PreferencesSchemeGruvboxDark] +en = "Gruvbox (Dark)" +pl = "Gruvbox (ciemny)" +ro = "Gruvbox (întunecat)" + +[PreferencesSchemeGruvboxLight] +en = "Gruvbox (Light)" +pl = "Gruvbox (jasny)" +ro = "Gruvbox (luminos)" + +[PreferencesSchemeDracula] +en = "Dracula" +pl = "Dracula" +ro = "Dracula" + +[PreferencesSchemeKanagawa] +en = "Kanagawa" +pl = "Kanagawa" +ro = "Kanagawa" + +[PreferencesSchemeTokyonight] +en = "Tokyo Night" +pl = "Tokyo Night" +ro = "Tokyo Night" + +[PreferencesSchemeMonokai] +en = "Monokai" +pl = "Monokai" +ro = "Monokai" + +[PreferencesSchemeAtom] +en = "Atom One Dark" +pl = "Atom One Dark" +ro = "Atom One Dark" + +[RecentFilesDialogTitle] +en = "Recent Files" +pl = "Ostatnie pliki" +ro = "Fișiere recente" + +[CommandPaletteTitle] +en = "Command Palette" +pl = "Paleta poleceń" +ro = "Paleta de comenzi" + +[CommandPaletteNoResults] +en = "No commands found" +pl = "Brak wyników" +ro = "Nicio comandă găsită" diff --git a/scripts/build-debug.sh b/scripts/build-debug.sh new file mode 100755 index 00000000000..baf85caac63 --- /dev/null +++ b/scripts/build-debug.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +cargo build "$@" diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 00000000000..000809ac905 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +cargo build --config .cargo/release-nightly.toml --release "$@" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 00000000000..d4d084d3915 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +cargo check "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000000..2497276a2e4 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +# Installs the edit binary into Cargo's bin dir. +# Additional arguments are forwarded to `cargo install`. +cargo install --path . --locked "$@" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 00000000000..d1695d40df0 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +cargo test "$@" diff --git a/src/base64.rs b/src/base64.rs index bce13c62751..4b63864a9ae 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -7,6 +7,19 @@ use crate::arena::ArenaString; const CHARSET: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +fn decode_value(byte: u8) -> Option { + match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + b'=' => Some(64), + b'\r' | b'\n' => None, + _ => Some(0xff), + } +} + /// One aspect of base64 is that the encoded length can be /// calculated accurately in advance, which is what this returns. #[inline] @@ -77,6 +90,63 @@ pub fn encode(dst: &mut ArenaString, src: &[u8]) { } } +/// Decodes a base64 string into raw bytes. +pub fn decode(src: &str) -> Option> { + let mut chunk = [0u8; 4]; + let mut chunk_len = 0; + let mut out = Vec::with_capacity(src.len().saturating_sub(3) / 4 * 3); + + for &byte in src.as_bytes() { + let Some(val) = decode_value(byte) else { + continue; + }; + if val == 0xff { + return None; + } + chunk[chunk_len] = val; + chunk_len += 1; + + if chunk_len == 4 { + if chunk[0] == 64 || chunk[1] == 64 { + return None; + } + + out.push((chunk[0] << 2) | (chunk[1] >> 4)); + + match chunk[2] { + 64 => { + if chunk[3] != 64 { + return None; + } + } + val => { + out.push((chunk[1] << 4) | (val >> 2)); + } + } + + if let (Some(c), Some(d)) = + ((chunk[2] != 64).then_some(chunk[2]), (chunk[3] != 64).then_some(chunk[3])) + { + out.push((c << 6) | d); + } else if chunk[3] != 64 { + return None; + } + + if chunk[2] == 64 && chunk[3] != 64 { + return None; + } + + chunk_len = 0; + } + } + + if chunk_len != 0 { + return None; + } + + Some(out) +} + #[cfg(test)] mod tests { use super::encode; @@ -118,4 +188,13 @@ mod tests { assert_eq!(enc(b"abcdefghijklmNOPQRSTUVWXY"), "YWJjZGVmZ2hpamtsbU5PUFFSU1RVVldYWQ=="); assert_eq!(enc(b"abcdefghijklmNOPQRSTUVWXYZ"), "YWJjZGVmZ2hpamtsbU5PUFFSU1RVVldYWVo="); } + + #[test] + fn roundtrip_decode() { + let arena = Arena::new(4 * 1024).unwrap(); + let mut dst = ArenaString::new_in(&arena); + encode(&mut dst, b"hello clipboard"); + let decoded = super::decode(&dst).unwrap(); + assert_eq!(decoded, b"hello clipboard"); + } } diff --git a/src/bin/edit/documents.rs b/src/bin/edit/documents.rs index 33fc8cf5a76..46f32a6dad4 100644 --- a/src/bin/edit/documents.rs +++ b/src/bin/edit/documents.rs @@ -8,11 +8,13 @@ use std::path::{Path, PathBuf}; use edit::buffer::{RcTextBuffer, TextBuffer}; use edit::helpers::{CoordType, Point}; +use edit::highlight::SyntaxKind; use edit::{apperr, path, sys}; use crate::state::DisplayablePathBuf; pub struct Document { + pub id: u64, pub buffer: RcTextBuffer, pub path: Option, pub dir: Option, @@ -59,12 +61,14 @@ impl Document { } fn set_path(&mut self, path: PathBuf) { + let syntax = SyntaxKind::from_path(Some(&path)); let filename = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); let dir = path.parent().map(ToOwned::to_owned).unwrap_or_default(); self.filename = filename; self.dir = Some(DisplayablePathBuf::from_path(dir)); self.path = Some(path); self.update_file_mode(); + self.buffer.borrow_mut().set_syntax(syntax); } fn update_file_mode(&mut self) { @@ -76,6 +80,7 @@ impl Document { #[derive(Default)] pub struct DocumentManager { list: LinkedList, + next_id: u64, } impl DocumentManager { @@ -112,9 +117,28 @@ impl DocumentManager { self.list.pop_front(); } + pub fn iter(&self) -> impl Iterator { + self.list.iter() + } + + pub fn activate(&mut self, id: u64) -> bool { + self.update_active(|doc| doc.id == id) + } + + fn next_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + id + } + + pub fn iter_mut(&mut self) -> std::collections::linked_list::IterMut<'_, Document> { + self.list.iter_mut() + } + pub fn add_untitled(&mut self) -> apperr::Result<&mut Document> { let buffer = Self::create_buffer()?; let mut doc = Document { + id: self.next_id(), buffer, path: None, dir: Default::default(), @@ -125,6 +149,11 @@ impl DocumentManager { self.gen_untitled_name(&mut doc); self.list.push_front(doc); + let syntax = { + let doc_ref = self.list.front().unwrap(); + SyntaxKind::from_path(Some(Path::new(&doc_ref.filename))) + }; + self.list.front_mut().unwrap().buffer.borrow_mut().set_syntax(syntax); Ok(self.list.front_mut().unwrap()) } @@ -175,6 +204,7 @@ impl DocumentManager { } let mut doc = Document { + id: self.next_id(), buffer, path: None, dir: None, diff --git a/src/bin/edit/draw_command_palette.rs b/src/bin/edit/draw_command_palette.rs new file mode 100644 index 00000000000..b0015ae6cfc --- /dev/null +++ b/src/bin/edit/draw_command_palette.rs @@ -0,0 +1,448 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use edit::arena::scratch_arena; +use edit::arena_format; +use edit::framebuffer::IndexedColor; +use edit::fuzzy::score_fuzzy; +use edit::helpers::*; +use edit::input::vk; +use edit::tui::*; + +use crate::localization::*; +use crate::state::*; + +const MAX_COMMAND_RESULTS: usize = 12; + +#[derive(Clone, Copy)] +enum StaticCommand { + NewFile, + OpenFile, + OpenRecentList, + SaveFile, + SaveFileAs, + CloseFile, + ExitApp, + Find, + Replace, + GoToFile, + GoToLine, + Preferences, + About, + ToggleWordWrap, +} + +#[derive(Clone, Copy)] +enum CommandAction { + Static(StaticCommand), + RecentFile(usize), +} + +struct StaticCommandDefinition { + label: LocId, + shortcut: Option<&'static str>, + keywords: &'static [&'static str], + action: StaticCommand, + requires_document: bool, + requires_search: bool, + requires_recent: bool, +} + +struct CommandEntry<'a> { + label: &'a str, + shortcut: Option<&'static str>, + keywords: &'static [&'static str], + action: CommandAction, + enabled: bool, +} + +const STATIC_COMMANDS: &[StaticCommandDefinition] = &[ + StaticCommandDefinition { + label: LocId::FileNew, + shortcut: Some("Ctrl+N"), + keywords: &["new"], + action: StaticCommand::NewFile, + requires_document: false, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::FileOpen, + shortcut: Some("Ctrl+O"), + keywords: &["open", "file"], + action: StaticCommand::OpenFile, + requires_document: false, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::FileOpenRecent, + shortcut: None, + keywords: &["recent"], + action: StaticCommand::OpenRecentList, + requires_document: false, + requires_search: false, + requires_recent: true, + }, + StaticCommandDefinition { + label: LocId::FileSave, + shortcut: Some("Ctrl+S"), + keywords: &["save"], + action: StaticCommand::SaveFile, + requires_document: true, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::FileSaveAs, + shortcut: Some("Ctrl+Shift+S"), + keywords: &["save as"], + action: StaticCommand::SaveFileAs, + requires_document: true, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::FileClose, + shortcut: Some("Ctrl+W"), + keywords: &["close"], + action: StaticCommand::CloseFile, + requires_document: true, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::FileExit, + shortcut: Some("Ctrl+Q"), + keywords: &["quit", "exit"], + action: StaticCommand::ExitApp, + requires_document: false, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::EditFind, + shortcut: Some("Ctrl+F"), + keywords: &["find", "search"], + action: StaticCommand::Find, + requires_document: true, + requires_search: true, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::EditReplace, + shortcut: Some("Ctrl+R"), + keywords: &["replace"], + action: StaticCommand::Replace, + requires_document: true, + requires_search: true, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::ViewGoToFile, + shortcut: Some("Ctrl+P"), + keywords: &["switch file"], + action: StaticCommand::GoToFile, + requires_document: true, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::FileGoto, + shortcut: Some("Ctrl+G"), + keywords: &["goto line"], + action: StaticCommand::GoToLine, + requires_document: true, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::EditPreferences, + shortcut: None, + keywords: &["prefs", "settings"], + action: StaticCommand::Preferences, + requires_document: false, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::HelpAbout, + shortcut: None, + keywords: &["about"], + action: StaticCommand::About, + requires_document: false, + requires_search: false, + requires_recent: false, + }, + StaticCommandDefinition { + label: LocId::ViewWordWrap, + shortcut: Some("Alt+Z"), + keywords: &["wrap"], + action: StaticCommand::ToggleWordWrap, + requires_document: true, + requires_search: false, + requires_recent: false, + }, +]; + +pub fn draw_command_palette(ctx: &mut Context, state: &mut State) { + if state.command_palette_reset_selection { + state.command_palette_selection = 0; + state.command_palette_reset_selection = false; + } + + ctx.modal_begin("command-palette", loc(LocId::CommandPaletteTitle)); + ctx.attr_focus_well(); + ctx.attr_padding(Rect::three(1, 2, 1)); + + let mut close = false; + let mut activate: Option = None; + + if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) { + close = true; + } + + let filter_focused; + ctx.block_begin("filter"); + ctx.attr_padding(Rect::three(0, 0, 1)); + ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 }); + if ctx.editline("command-filter", &mut state.command_palette_filter) { + state.command_palette_selection = 0; + } + filter_focused = ctx.is_focused(); + if state.command_palette_focus_filter { + state.command_palette_focus_filter = false; + ctx.steal_focus(); + } + ctx.block_end(); + + let entries = build_command_entries(state); + let filtered = filter_commands(entries, state.command_palette_filter.trim()); + let mut selection = state.command_palette_selection; + + let visible_len = filtered.len().min(MAX_COMMAND_RESULTS); + if visible_len == 0 { + selection = 0; + } else { + selection = selection.min(visible_len.saturating_sub(1)); + } + + if filter_focused && visible_len > 0 { + if ctx.consume_shortcut(vk::DOWN) { + selection = (selection + 1).min(visible_len - 1); + ctx.needs_rerender(); + } else if ctx.consume_shortcut(vk::UP) && selection > 0 { + selection -= 1; + ctx.needs_rerender(); + } + } + + ctx.block_begin("results"); + ctx.attr_padding(Rect::three(0, 0, 1)); + + if visible_len == 0 { + ctx.attr_foreground_rgba(ctx.indexed_alpha(IndexedColor::BrightBlack, 3, 4)); + ctx.label("no-results", loc(LocId::CommandPaletteNoResults)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Foreground)); + } else { + ctx.list_begin("command-list"); + ctx.inherit_focus(); + { + for (idx, entry) in filtered.iter().take(visible_len).enumerate() { + let mut label_owned = None; + let label_text: &str = if let Some(shortcut) = entry.shortcut { + label_owned = + Some(arena_format!(ctx.arena(), "{} {}", entry.label, shortcut)); + label_owned.as_deref().unwrap() + } else { + entry.label + }; + + match ctx.list_item(selection == idx, label_text) { + ListSelection::Activated => { + selection = idx; + if entry.enabled { + activate = Some(entry.action); + } + } + ListSelection::Selected => selection = idx, + ListSelection::Unchanged => {} + } + } + } + ctx.list_end(); + } + ctx.block_end(); + + if activate.is_none() + && filter_focused + && visible_len > 0 + && ctx.consume_shortcut(vk::RETURN) + && filtered.get(selection).is_some_and(|entry| entry.enabled) + { + activate = Some(filtered[selection].action); + } + + state.command_palette_selection = selection; + + if ctx.modal_end() { + close = true; + } + + if let Some(action) = activate { + execute_command(ctx, state, action); + close = true; + } + + if close { + state.wants_command_palette = false; + state.command_palette_filter.clear(); + state.command_palette_selection = 0; + } +} + +fn build_command_entries<'a>(state: &'a State) -> Vec> { + let mut entries = Vec::new(); + + for def in STATIC_COMMANDS { + let has_doc = state.documents.active().is_some(); + let search_available = !matches!(state.wants_search.kind, StateSearchKind::Disabled); + let recent_available = !state.recent_files.is_empty(); + let enabled = (!def.requires_document || has_doc) + && (!def.requires_search || search_available) + && (!def.requires_recent || recent_available); + + entries.push(CommandEntry { + label: loc(def.label), + shortcut: def.shortcut, + keywords: def.keywords, + action: CommandAction::Static(def.action), + enabled, + }); + } + + for (idx, entry) in state.recent_files.iter().enumerate() { + entries.push(CommandEntry { + label: entry.as_str(), + shortcut: None, + keywords: &["recent"], + action: CommandAction::RecentFile(idx), + enabled: true, + }); + } + + entries +} + +fn filter_commands<'a>(entries: Vec>, needle: &str) -> Vec> { + if needle.is_empty() { + return entries; + } + + let scratch = scratch_arena(None); + let mut matches = Vec::new(); + for entry in entries { + let mut best = score_fuzzy(&scratch, entry.label, needle, true).0; + if best == 0 { + for keyword in entry.keywords { + let (score, _) = score_fuzzy(&scratch, keyword, needle, true); + best = best.max(score); + } + } + if best > 0 { + matches.push((best, entry)); + } + } + + matches.sort_by(|a, b| b.0.cmp(&a.0)); + matches.into_iter().map(|(_, entry)| entry).collect() +} + +fn execute_command(ctx: &mut Context, state: &mut State, action: CommandAction) { + match action { + CommandAction::Static(cmd) => match cmd { + StaticCommand::NewFile => { + draw_add_untitled_document(ctx, state); + ctx.needs_rerender(); + } + StaticCommand::OpenFile => { + state.wants_file_picker = StateFilePicker::Open; + ctx.needs_rerender(); + } + StaticCommand::OpenRecentList => { + state.wants_recent_files = true; + ctx.needs_rerender(); + } + StaticCommand::SaveFile => { + state.wants_save = true; + ctx.needs_rerender(); + } + StaticCommand::SaveFileAs => { + state.wants_file_picker = StateFilePicker::SaveAs; + ctx.needs_rerender(); + } + StaticCommand::CloseFile => { + state.wants_close = true; + ctx.needs_rerender(); + } + StaticCommand::ExitApp => { + state.wants_exit = true; + ctx.needs_rerender(); + } + StaticCommand::Find => { + if state.wants_search.kind != StateSearchKind::Disabled { + state.wants_search.kind = StateSearchKind::Search; + state.wants_search.focus = true; + ctx.needs_rerender(); + } + } + StaticCommand::Replace => { + if state.wants_search.kind != StateSearchKind::Disabled { + state.wants_search.kind = StateSearchKind::Replace; + state.wants_search.focus = true; + ctx.needs_rerender(); + } + } + StaticCommand::GoToFile => { + state.wants_go_to_file = true; + ctx.needs_rerender(); + } + StaticCommand::GoToLine => { + state.wants_goto = true; + ctx.needs_rerender(); + } + StaticCommand::Preferences => { + state.wants_preferences = true; + state.preferences_focus_reset = true; + ctx.needs_rerender(); + } + StaticCommand::About => { + state.wants_about = true; + ctx.needs_rerender(); + } + StaticCommand::ToggleWordWrap => { + if let Some(doc) = state.documents.active_mut() { + let mut tb = doc.buffer.borrow_mut(); + let wrap_enabled = tb.is_word_wrap_enabled(); + tb.set_word_wrap(!wrap_enabled); + tb.make_cursor_visible(); + ctx.needs_rerender(); + } + } + }, + CommandAction::RecentFile(idx) => { + if let Some(entry) = state.recent_files.get(idx) { + let path = entry.as_path().to_path_buf(); + let prefs = state.preferences.clone(); + match state.documents.add_file_path(&path) { + Ok(doc) => { + prefs.apply_to_document(doc); + state.mark_file_recent_path(&path); + ctx.needs_rerender(); + } + Err(err) => error_log_add(ctx, state, err), + } + } + } + } +} diff --git a/src/bin/edit/draw_editor.rs b/src/bin/edit/draw_editor.rs index 94f7dbfc50f..86c73b8bf22 100644 --- a/src/bin/edit/draw_editor.rs +++ b/src/bin/edit/draw_editor.rs @@ -195,10 +195,13 @@ pub fn search_execute(ctx: &mut Context, state: &mut State, action: SearchAction } pub fn draw_handle_save(ctx: &mut Context, state: &mut State) { + let mut recent_path = None; if let Some(doc) = state.documents.active_mut() { if doc.path.is_some() { if let Err(err) = doc.save(None) { error_log_add(ctx, state, err); + } else { + recent_path = doc.path.clone(); } } else { // No path? Show the file picker. @@ -208,6 +211,10 @@ pub fn draw_handle_save(ctx: &mut Context, state: &mut State) { } } + if let Some(path) = recent_path { + state.mark_file_recent_path(path); + } + state.wants_save = false; } diff --git a/src/bin/edit/draw_filepicker.rs b/src/bin/edit/draw_filepicker.rs index 3fae635104c..18d02c0b2f0 100644 --- a/src/bin/edit/draw_filepicker.rs +++ b/src/bin/edit/draw_filepicker.rs @@ -240,15 +240,20 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { } if let Some(path) = doit { + let prefs = state.preferences.clone(); + let res = if state.wants_file_picker == StateFilePicker::Open { - state.documents.add_file_path(&path).map(|_| ()) + state.documents.add_file_path(&path).map(|doc| { + prefs.apply_to_document(doc); + }) } else if let Some(doc) = state.documents.active_mut() { - doc.save(Some(path)) + doc.save(Some(path.clone())) } else { Ok(()) }; match res { Ok(..) => { + state.mark_file_recent_path(&path); ctx.needs_rerender(); done = true; } diff --git a/src/bin/edit/draw_menubar.rs b/src/bin/edit/draw_menubar.rs index 9fe8b7cefb6..0f2dc8419aa 100644 --- a/src/bin/edit/draw_menubar.rs +++ b/src/bin/edit/draw_menubar.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use edit::arena_format; +use edit::framebuffer::IndexedColor; use edit::helpers::*; use edit::input::{kbmod, vk}; use edit::tui::*; @@ -37,6 +38,52 @@ pub fn draw_menubar(ctx: &mut Context, state: &mut State) { ctx.menubar_end(); } +pub fn draw_tabstrip(ctx: &mut Context, state: &mut State) { + if state.documents.len() <= 1 { + return; + } + + let mut tabs = Vec::new(); + for doc in state.documents.iter() { + let dirty = doc.buffer.borrow().is_dirty(); + tabs.push((doc.id, doc.filename.clone(), dirty)); + } + + let active_id = state.documents.active().map(|doc| doc.id); + + ctx.block_begin("tabstrip"); + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Background, 5, 6)); + ctx.attr_padding(Rect::three(0, 1, 0)); + let columns = vec![0; tabs.len().max(1)]; + ctx.table_begin("tabstrip-row"); + ctx.table_set_columns(&columns); + ctx.table_next_row(); + ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + for (id, title, dirty) in tabs { + ctx.next_block_id_mixin(id); + if Some(id) == active_id { + ctx.attr_background_rgba(state.menubar_color_bg); + ctx.attr_foreground_rgba(state.menubar_color_fg); + } else { + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Background)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Foreground)); + } + + let mut label = title.clone(); + if dirty { + label.push('*'); + } + + if ctx.button("tab", &label, ButtonStyle::default()) { + if state.documents.activate(id) { + ctx.needs_rerender(); + } + } + } + ctx.table_end(); + ctx.block_end(); +} + fn draw_menu_file(ctx: &mut Context, state: &mut State) { if ctx.menubar_menu_button(loc(LocId::FileNew), 'N', kbmod::CTRL | vk::N) { draw_add_untitled_document(ctx, state); @@ -44,6 +91,11 @@ fn draw_menu_file(ctx: &mut Context, state: &mut State) { if ctx.menubar_menu_button(loc(LocId::FileOpen), 'O', kbmod::CTRL | vk::O) { state.wants_file_picker = StateFilePicker::Open; } + if !state.recent_files.is_empty() + && ctx.menubar_menu_button(loc(LocId::FileOpenRecent), 'R', vk::NULL) + { + state.wants_recent_files = true; + } if state.documents.active().is_some() { if ctx.menubar_menu_button(loc(LocId::FileSave), 'S', kbmod::CTRL | vk::S) { state.wants_save = true; @@ -69,7 +121,7 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) { tb.undo(); ctx.needs_rerender(); } - if ctx.menubar_menu_button(loc(LocId::EditRedo), 'R', kbmod::CTRL | vk::Y) { + if ctx.menubar_menu_button(loc(LocId::EditRedo), 'D', kbmod::CTRL | vk::Y) { tb.redo(); ctx.needs_rerender(); } @@ -99,6 +151,10 @@ fn draw_menu_edit(ctx: &mut Context, state: &mut State) { tb.select_all(); ctx.needs_rerender(); } + if ctx.menubar_menu_button(loc(LocId::EditPreferences), 'R', vk::NULL) { + state.wants_preferences = true; + state.preferences_focus_reset = true; + } ctx.menubar_menu_end(); } diff --git a/src/bin/edit/localization.rs b/src/bin/edit/localization.rs index aeecec7aa56..898dc383800 100644 --- a/src/bin/edit/localization.rs +++ b/src/bin/edit/localization.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use std::collections::HashSet; + use edit::arena::scratch_arena; use edit::helpers::AsciiStringHelpers; use edit::sys; @@ -8,6 +10,7 @@ use edit::sys; include!(concat!(env!("OUT_DIR"), "/i18n_edit.rs")); static mut S_LANG: LangId = LangId::en; +static mut DEFAULT_LANG: LangId = LangId::en; pub fn init() { let scratch = scratch_arena(None); @@ -25,9 +28,68 @@ pub fn init() { unsafe { S_LANG = lang; + DEFAULT_LANG = lang; } } pub fn loc(id: LocId) -> &'static str { TRANSLATIONS[unsafe { S_LANG as usize }][id as usize] } + +pub fn unique_languages() -> Vec<(&'static str, LangId)> { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for &(tag, id) in LANGUAGES { + if seen.insert(id as u32) { + result.push((tag, id)); + } + } + result +} + +pub fn reset_language() { + unsafe { + S_LANG = DEFAULT_LANG; + } +} + +pub fn set_language_tag(tag: &str) -> Option { + let normalized = normalize_lang_tag(tag); + for &(lang_tag, id) in LANGUAGES { + if normalize_lang_tag(lang_tag) == normalized { + unsafe { S_LANG = id }; + return Some(id); + } + } + None +} + +pub fn language_display_name(tag: &str) -> &'static str { + let normalized = normalize_lang_tag(tag); + match normalized.as_str() { + "en" => "English", + "de" => "Deutsch", + "es" => "Español", + "fr" => "Français", + "it" => "Italiano", + "ja" => "日本語", + "ko" => "한국어", + "pl" => "Polski", + "pt_br" => "Português (Brasil)", + "ro" => "Română", + "ru" => "Русский", + "zh_hans" => "中文(简体)", + "zh_hant" => "中文(繁體)", + _ => "Unknown", + } +} + +fn normalize_lang_tag(tag: &str) -> String { + tag.chars() + .map(|c| match c { + 'A'..='Z' => c.to_ascii_lowercase(), + '-' => '_', + _ => c, + }) + .collect() +} diff --git a/src/bin/edit/main.rs b/src/bin/edit/main.rs index 326c88a02ee..6ec143be65f 100644 --- a/src/bin/edit/main.rs +++ b/src/bin/edit/main.rs @@ -4,11 +4,13 @@ #![feature(allocator_api, linked_list_cursors, string_from_utf8_lossy_owned)] mod documents; +mod draw_command_palette; mod draw_editor; mod draw_filepicker; mod draw_menubar; mod draw_statusbar; -mod localization; +pub(crate) mod localization; +mod session; mod state; use std::borrow::Cow; @@ -18,6 +20,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use std::{env, process}; +use draw_command_palette::*; use draw_editor::*; use draw_filepicker::*; use draw_menubar::*; @@ -70,6 +73,8 @@ fn run() -> apperr::Result<()> { return Ok(()); } + state.initialize_session(); + // This will reopen stdin if it's redirected (which may fail) and switch // the terminal to raw mode which prevents the user from pressing Ctrl+C. // `handle_args` may want to print a help message (must not fail), @@ -83,25 +88,12 @@ fn run() -> apperr::Result<()> { let _restore = setup_terminal(&mut tui, &mut state, &mut vt_parser); - state.menubar_color_bg = tui.indexed(IndexedColor::Background).oklab_blend(tui.indexed_alpha( - IndexedColor::BrightBlue, - 1, - 2, - )); - state.menubar_color_fg = tui.contrasted(state.menubar_color_bg); - let floater_bg = tui - .indexed_alpha(IndexedColor::Background, 2, 3) - .oklab_blend(tui.indexed_alpha(IndexedColor::Foreground, 1, 3)); - let floater_fg = tui.contrasted(floater_bg); + state.apply_colorscheme_to_tui(&mut tui); tui.setup_modifier_translations(ModifierTranslations { ctrl: loc(LocId::Ctrl), alt: loc(LocId::Alt), shift: loc(LocId::Shift), }); - tui.set_floater_default_bg(floater_bg); - tui.set_floater_default_fg(floater_fg); - tui.set_modal_default_bg(floater_bg); - tui.set_modal_default_fg(floater_fg); sys::inject_window_size_into_stdin(); @@ -175,6 +167,10 @@ fn run() -> apperr::Result<()> { write_osc_clipboard(&mut tui, &mut state, &mut output); } + if tui.take_clipboard_request() { + output.push_str("\x1b]52;c;?\x1b\\"); + } + #[cfg(feature = "debug-latency")] { // Print the number of passes and latency in the top right corner. @@ -218,6 +214,8 @@ fn run() -> apperr::Result<()> { } } + state.save_session(); + Ok(()) } @@ -261,17 +259,29 @@ fn handle_args(state: &mut State) -> apperr::Result { } for p in &paths { - state.documents.add_file_path(p)?; + let prefs = state.preferences.clone(); + let doc = state.documents.add_file_path(p)?; + prefs.apply_to_document(doc); + state.mark_file_recent_path(p); + } + + if !paths.is_empty() { + state.skip_session_restore = true; } if let Some(mut file) = sys::open_stdin_if_redirected() { + state.skip_session_restore = true; + let prefs = state.preferences.clone(); let doc = state.documents.add_untitled()?; + prefs.apply_to_document(doc); let mut tb = doc.buffer.borrow_mut(); tb.read_file(&mut file, None)?; tb.mark_as_dirty(); } else if paths.is_empty() { // No files were passed, and stdin is not redirected. - state.documents.add_untitled()?; + let prefs = state.preferences.clone(); + let doc = state.documents.add_untitled()?; + prefs.apply_to_document(doc); } if dir.is_none() @@ -302,6 +312,7 @@ fn print_version() { fn draw(ctx: &mut Context, state: &mut State) { draw_menubar(ctx, state); + draw_tabstrip(ctx, state); draw_editor(ctx, state); draw_statusbar(ctx, state); @@ -329,6 +340,16 @@ fn draw(ctx: &mut Context, state: &mut State) { if state.wants_about { draw_dialog_about(ctx, state); } + if state.wants_preferences { + draw_dialog_preferences(ctx, state); + } + if state.wants_recent_files { + draw_recent_files_dialog(ctx, state); + } + if state.wants_command_palette { + draw_command_palette(ctx, state); + return; + } if ctx.clipboard_ref().wants_host_sync() { draw_handle_clipboard_change(ctx, state); } @@ -339,7 +360,15 @@ fn draw(ctx: &mut Context, state: &mut State) { if let Some(key) = ctx.keyboard_input() { // Shortcuts that are not handled as part of the textarea, etc. - if key == kbmod::CTRL | vk::N { + if key == vk::F1 { + state.wants_command_palette = true; + state.command_palette_filter.clear(); + state.command_palette_selection = 0; + state.command_palette_reset_selection = true; + state.command_palette_focus_filter = true; + ctx.needs_rerender(); + return; + } else if key == kbmod::CTRL | vk::N { draw_add_untitled_document(ctx, state); } else if key == kbmod::CTRL | vk::O { state.wants_file_picker = StateFilePicker::Open; @@ -652,9 +681,11 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) } if color_responses == indexed_colors.len() { - tui.setup_indexed_colors(indexed_colors); + state.set_system_palette(indexed_colors); } + state.apply_colorscheme_to_tui(tui); + RestoreModes } diff --git a/src/bin/edit/session.rs b/src/bin/edit/session.rs new file mode 100644 index 00000000000..1587dbcefc1 --- /dev/null +++ b/src/bin/edit/session.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fs, io}; + +use serde::{Deserialize, Serialize}; + +use crate::state; + +const SESSION_FILE_NAME: &str = "session.json"; +pub(crate) const SESSION_VERSION: u32 = 1; + +#[derive(Default, Serialize, Deserialize)] +pub struct SessionFile { + pub version: u32, + #[serde(default)] + pub open_documents: Vec, + #[serde(default)] + pub recent_files: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct SessionDocument { + pub path: String, + pub line: i64, + pub column: i64, +} + +pub fn load() -> Option { + let mut path = state::config_dir()?; + path.push(SESSION_FILE_NAME); + + let text = fs::read_to_string(path).ok()?; + let session: SessionFile = serde_json::from_str(&text).ok()?; + if session.version != SESSION_VERSION { + return None; + } + Some(session) +} + +pub fn save(data: &SessionFile) -> io::Result<()> { + let mut dir = match state::config_dir() { + Some(dir) => dir, + None => return Ok(()), + }; + fs::create_dir_all(&dir)?; + + dir.push(SESSION_FILE_NAME); + let text = serde_json::to_string_pretty(data)?; + fs::write(dir, text) +} diff --git a/src/bin/edit/state.rs b/src/bin/edit/state.rs index 451060bf6db..e0c32805559 100644 --- a/src/bin/edit/state.rs +++ b/src/bin/edit/state.rs @@ -3,17 +3,19 @@ use std::borrow::Cow; use std::ffi::{OsStr, OsString}; -use std::mem; use std::path::{Path, PathBuf}; +use std::{env, fs, mem}; -use edit::framebuffer::IndexedColor; +use edit::framebuffer::{self, INDEXED_COLORS_COUNT, IndexedColor}; use edit::helpers::*; +use edit::input::vk; use edit::oklab::StraightRgba; use edit::tui::*; -use edit::{apperr, buffer, icu, sys}; +use edit::{apperr, arena_format, buffer, icu, path, sys}; -use crate::documents::DocumentManager; +use crate::documents::{Document, DocumentManager}; use crate::localization::*; +use crate::{localization, session}; #[repr(transparent)] pub struct FormatApperr(apperr::Error); @@ -92,6 +94,19 @@ impl> From<&T> for DisplayablePathBuf { } } +const RECENT_FILES_LIMIT: usize = 15; +const SESSION_DOCUMENT_LIMIT: usize = 8; +const TAB_WIDTH_WIDGET_IDS: [&str; 8] = [ + "tab-width-1", + "tab-width-2", + "tab-width-3", + "tab-width-4", + "tab-width-5", + "tab-width-6", + "tab-width-7", + "tab-width-8", +]; + pub struct StateSearch { pub kind: StateSearchKind, pub focus: bool, @@ -132,6 +147,7 @@ pub struct State { pub menubar_color_fg: StraightRgba, pub documents: DocumentManager, + pub recent_files: Vec, // A ring buffer of the last 10 errors. pub error_log: [String; 10], @@ -161,7 +177,10 @@ pub struct State { pub wants_statusbar_focus: bool, pub wants_indentation_picker: bool, pub wants_go_to_file: bool, + pub wants_preferences: bool, pub wants_about: bool, + pub wants_recent_files: bool, + pub wants_command_palette: bool, pub wants_close: bool, pub wants_exit: bool, pub wants_goto: bool, @@ -172,15 +191,26 @@ pub struct State { pub osc_clipboard_sync: bool, pub osc_clipboard_always_send: bool, pub exit: bool, + pub skip_session_restore: bool, + pub preferences: Preferences, + pub command_palette_filter: String, + pub command_palette_selection: usize, + pub command_palette_reset_selection: bool, + pub command_palette_focus_filter: bool, + pub preferences_focus_reset: bool, + system_palette: [StraightRgba; INDEXED_COLORS_COUNT], } impl State { pub fn new() -> apperr::Result { + let preferences = Preferences::load_from_disk(); + preferences.apply_language(); Ok(Self { menubar_color_bg: StraightRgba::zero(), menubar_color_fg: StraightRgba::zero(), documents: Default::default(), + recent_files: Vec::new(), error_log: [const { String::new() }; 10], error_log_index: 0, @@ -209,7 +239,10 @@ impl State { wants_encoding_change: StateEncodingChange::None, wants_indentation_picker: false, wants_go_to_file: false, + wants_preferences: false, wants_about: false, + wants_recent_files: false, + wants_command_palette: false, wants_close: false, wants_exit: false, wants_goto: false, @@ -220,13 +253,23 @@ impl State { osc_clipboard_sync: false, osc_clipboard_always_send: false, exit: false, + skip_session_restore: false, + preferences, + command_palette_filter: String::new(), + command_palette_selection: 0, + command_palette_reset_selection: false, + command_palette_focus_filter: false, + preferences_focus_reset: false, + system_palette: framebuffer::DEFAULT_THEME, }) } } pub fn draw_add_untitled_document(ctx: &mut Context, state: &mut State) { - if let Err(err) = state.documents.add_untitled() { - error_log_add(ctx, state, err); + let prefs = state.preferences.clone(); + match state.documents.add_untitled() { + Ok(doc) => prefs.apply_to_document(doc), + Err(err) => error_log_add(ctx, state, err), } } @@ -273,3 +316,984 @@ pub fn draw_error_log(ctx: &mut Context, state: &mut State) { state.error_log_count = 0; } } + +pub fn draw_dialog_preferences(ctx: &mut Context, state: &mut State) { + ctx.modal_begin("preferences", loc(LocId::PreferencesDialogTitle)); + ctx.attr_focus_well(); + ctx.attr_padding(Rect::three(1, 2, 1)); + let mut close = false; + if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) { + close = true; + } + + ctx.block_begin("content"); + ctx.attr_padding(Rect::three(0, 0, 1)); + { + macro_rules! take_focus { + ($ctx:expr) => { + if state.preferences_focus_reset { + $ctx.steal_focus(); + state.preferences_focus_reset = false; + } + }; + } + + ctx.table_begin("preferences-controls"); + ctx.attr_padding(Rect::three(0, 0, 1)); + ctx.inherit_focus(); + + ctx.table_next_row(); + ctx.label("general-label", loc(LocId::PreferencesGeneral)); + ctx.focus_on_first_present(); + + ctx.table_next_row(); + ctx.attr_focusable(); + if ctx.checkbox( + "pref-auto-close", + loc(LocId::PreferencesAutoClose), + &mut state.preferences.auto_close_pairs, + ) { + state.apply_preferences_to_documents(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + + ctx.table_next_row(); + ctx.attr_focusable(); + if ctx.checkbox( + "pref-line-highlight", + loc(LocId::PreferencesLineHighlight), + &mut state.preferences.line_highlight, + ) { + state.apply_preferences_to_documents(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + + ctx.table_next_row(); + ctx.attr_focusable(); + if ctx.checkbox( + "pref-line-numbers", + loc(LocId::PreferencesShowLineNumbers), + &mut state.preferences.show_line_numbers, + ) { + state.apply_preferences_to_documents(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + + ctx.table_next_row(); + ctx.attr_focusable(); + if ctx.checkbox( + "pref-word-wrap", + loc(LocId::PreferencesWordWrap), + &mut state.preferences.word_wrap, + ) { + state.apply_preferences_to_documents(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + + ctx.table_next_row(); + ctx.attr_focusable(); + if ctx.checkbox( + "pref-indent-tabs", + loc(LocId::PreferencesIndentWithTabs), + &mut state.preferences.indent_with_tabs, + ) { + state.apply_preferences_to_documents(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + + ctx.table_next_row(); + ctx.label("spacer-indent-tabwidth", " "); + + ctx.table_next_row(); + ctx.label("tab-width-label", loc(LocId::PreferencesTabWidth)); + + for width in 1..=8 { + ctx.table_next_row(); + ctx.attr_focusable(); + let selected = state.preferences.tab_width == width; + let text = + arena_format!(ctx.arena(), "{} {}", if selected { "(●)" } else { "(○)" }, width); + let widget_id = TAB_WIDTH_WIDGET_IDS[(width - 1) as usize]; + if ctx.button(widget_id, &text, ButtonStyle::default()) + && state.preferences.tab_width != width + { + state.preferences.tab_width = width; + state.apply_preferences_to_documents(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + } + + ctx.table_next_row(); + ctx.label("spacer-colorscheme", " "); + + ctx.table_next_row(); + ctx.label("colorscheme-label", loc(LocId::PreferencesColorscheme)); + + for &scheme in ColorScheme::ALL.iter() { + ctx.table_next_row(); + ctx.attr_focusable(); + let selected = state.preferences.colorscheme == scheme; + let text = arena_format!( + ctx.arena(), + "{} {}", + if selected { "(●)" } else { "(○)" }, + loc(scheme.label_loc()) + ); + if ctx.button(scheme.widget_id(), &text, ButtonStyle::default()) + && state.preferences.colorscheme != scheme + { + state.preferences.colorscheme = scheme; + state.apply_colorscheme_to_context(ctx); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + } + + ctx.table_next_row(); + ctx.label("spacer-colors-language", " "); + + ctx.table_next_row(); + ctx.label("language-label", loc(LocId::PreferencesLanguage)); + + ctx.table_next_row(); + ctx.attr_focusable(); + let system_selected = state.preferences.language.is_none(); + let system_text = arena_format!( + ctx.arena(), + "{} {}", + if system_selected { "(●)" } else { "(○)" }, + loc(LocId::PreferencesLanguageSystem) + ); + if ctx.button("language-system", &system_text, ButtonStyle::default()) && !system_selected { + state.preferences.language = None; + localization::reset_language(); + state.save_preferences(); + ctx.needs_rerender(); + } + take_focus!(ctx); + + let mut lang_idx = 0u64; + for (tag, _) in localization::unique_languages() { + ctx.table_next_row(); + ctx.attr_focusable(); + ctx.next_block_id_mixin(lang_idx); + lang_idx += 1; + + let selected = state + .preferences + .language + .as_deref() + .map_or(false, |saved| lang_tag_eq(saved, tag)); + let pretty_tag = tag.replace('_', "-"); + let text = arena_format!( + ctx.arena(), + "{} {} ({})", + if selected { "(●)" } else { "(○)" }, + localization::language_display_name(tag), + pretty_tag + ); + if ctx.button("language-option", &text, ButtonStyle::default()) && !selected { + if localization::set_language_tag(tag).is_some() { + state.preferences.language = Some(tag.to_string()); + state.save_preferences(); + ctx.needs_rerender(); + } + } + take_focus!(ctx); + } + + ctx.table_next_row(); + ctx.label("spacer-language-close", " "); + + ctx.table_next_row(); + ctx.attr_focusable(); + if ctx.button("preferences-close", loc(LocId::SearchClose), ButtonStyle::default()) { + close = true; + } + take_focus!(ctx); + + ctx.table_end(); + } + ctx.block_end(); + + if close || ctx.modal_end() { + state.wants_preferences = false; + state.preferences_focus_reset = false; + } +} + +pub fn draw_recent_files_dialog(ctx: &mut Context, state: &mut State) { + if state.recent_files.is_empty() { + state.wants_recent_files = false; + return; + } + + let mut close = false; + let mut open_path: Option = None; + + ctx.modal_begin("recent-files", loc(LocId::RecentFilesDialogTitle)); + ctx.attr_focus_well(); + ctx.attr_padding(Rect::three(1, 2, 1)); + { + if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) { + close = true; + } + + ctx.block_begin("recent-list"); + ctx.attr_padding(Rect::three(0, 0, 1)); + for (idx, entry) in state.recent_files.iter().enumerate() { + ctx.next_block_id_mixin(idx as u64); + ctx.attr_overflow(Overflow::TruncateTail); + if ctx.button("recent-entry", entry.as_str(), ButtonStyle::default()) { + open_path = Some(entry.as_path().to_path_buf()); + } + } + ctx.block_end(); + + ctx.attr_position(Position::Center); + if ctx.button("recent-close", loc(LocId::SearchClose), ButtonStyle::default()) { + close = true; + } + } + if ctx.modal_end() { + close = true; + } + + if let Some(path) = open_path { + let prefs = state.preferences.clone(); + match state.documents.add_file_path(&path) { + Ok(doc) => { + prefs.apply_to_document(doc); + state.mark_file_recent_path(&path); + state.wants_recent_files = false; + ctx.needs_rerender(); + } + Err(err) => error_log_add(ctx, state, err), + } + return; + } + + if close { + state.wants_recent_files = false; + } +} + +impl State { + pub fn apply_preferences_to_documents(&mut self) { + let prefs = self.preferences.clone(); + for doc in self.documents.iter_mut() { + prefs.apply_to_document(doc); + } + } + + pub fn save_preferences(&self) { + self.preferences.save_to_disk(); + } + + pub fn initialize_session(&mut self) { + if let Some(session_file) = session::load() { + self.set_recent_files_from_session(session_file.recent_files); + if !self.skip_session_restore { + self.restore_session_documents(&session_file.open_documents); + } + } + } + + pub fn save_session(&self) { + let mut session_file = session::SessionFile { + version: session::SESSION_VERSION, + open_documents: Vec::new(), + recent_files: Vec::new(), + }; + + for doc in self.documents.iter().take(SESSION_DOCUMENT_LIMIT) { + if let Some(path) = &doc.path { + let cursor = doc.buffer.borrow().cursor_logical_pos(); + session_file.open_documents.push(session::SessionDocument { + path: path.to_string_lossy().into_owned(), + line: cursor.y as i64, + column: cursor.x as i64, + }); + } + } + + for entry in self.recent_files.iter().take(RECENT_FILES_LIMIT) { + session_file.recent_files.push(entry.as_str().to_string()); + } + + let _ = session::save(&session_file); + } + + pub fn mark_file_recent_path>(&mut self, path: P) { + let normalized = path::normalize(path.as_ref()); + if normalized.as_os_str().is_empty() { + return; + } + self.recent_files.retain(|entry| entry.as_path() != normalized.as_path()); + self.recent_files.insert(0, DisplayablePathBuf::from_path(normalized)); + if self.recent_files.len() > RECENT_FILES_LIMIT { + self.recent_files.truncate(RECENT_FILES_LIMIT); + } + } + + fn set_recent_files_from_session(&mut self, entries: Vec) { + self.recent_files.clear(); + for entry in entries.into_iter().rev() { + if entry.is_empty() { + continue; + } + self.mark_file_recent_path(PathBuf::from(entry)); + } + } + + fn restore_session_documents(&mut self, entries: &[session::SessionDocument]) { + let prefs = self.preferences.clone(); + for entry in entries.iter().rev().take(SESSION_DOCUMENT_LIMIT) { + if entry.path.is_empty() { + continue; + } + let path = PathBuf::from(&entry.path); + let mut opened = false; + if let Ok(doc) = self.documents.add_file_path(&path) { + opened = true; + prefs.apply_to_document(doc); + { + let mut tb = doc.buffer.borrow_mut(); + let target = Point { x: clamp_coord(entry.column), y: clamp_coord(entry.line) }; + tb.cursor_move_to_logical(target); + } + } + if opened { + self.mark_file_recent_path(&path); + } + } + } + + pub fn set_system_palette(&mut self, palette: [StraightRgba; INDEXED_COLORS_COUNT]) { + self.system_palette = palette; + } + + pub fn current_palette(&self) -> [StraightRgba; INDEXED_COLORS_COUNT] { + self.palette_for_scheme(self.preferences.colorscheme) + } + + pub fn apply_colorscheme_to_context(&mut self, ctx: &mut Context) { + ctx.set_color_palette(self.current_palette()); + let (floater_bg, floater_fg) = self.refresh_theme_colors_with( + |idx| ctx.indexed(idx), + |idx, n, d| ctx.indexed_alpha(idx, n, d), + |color| ctx.contrasted(color), + ); + ctx.set_floater_default_bg(floater_bg); + ctx.set_floater_default_fg(floater_fg); + ctx.set_modal_default_bg(floater_bg); + ctx.set_modal_default_fg(floater_fg); + } + + pub fn apply_colorscheme_to_tui(&mut self, tui: &mut Tui) { + tui.setup_indexed_colors(self.current_palette()); + let (floater_bg, floater_fg) = self.refresh_theme_colors_with( + |idx| tui.indexed(idx), + |idx, n, d| tui.indexed_alpha(idx, n, d), + |color| tui.contrasted(color), + ); + tui.set_floater_default_bg(floater_bg); + tui.set_floater_default_fg(floater_fg); + tui.set_modal_default_bg(floater_bg); + tui.set_modal_default_fg(floater_fg); + } + + fn refresh_theme_colors_with( + &mut self, + mut indexed: F, + mut indexed_alpha: G, + mut contrasted: H, + ) -> (StraightRgba, StraightRgba) + where + F: FnMut(IndexedColor) -> StraightRgba, + G: FnMut(IndexedColor, u32, u32) -> StraightRgba, + H: FnMut(StraightRgba) -> StraightRgba, + { + self.menubar_color_bg = indexed(IndexedColor::Background).oklab_blend(indexed_alpha( + IndexedColor::BrightBlue, + 1, + 2, + )); + self.menubar_color_fg = contrasted(self.menubar_color_bg); + let floater_bg = indexed_alpha(IndexedColor::Background, 2, 3).oklab_blend(indexed_alpha( + IndexedColor::Foreground, + 1, + 3, + )); + let floater_fg = contrasted(floater_bg); + (floater_bg, floater_fg) + } + + fn palette_for_scheme(&self, scheme: ColorScheme) -> [StraightRgba; INDEXED_COLORS_COUNT] { + match scheme { + ColorScheme::System => self.system_palette, + ColorScheme::Midnight => COLOR_SCHEME_MIDNIGHT, + ColorScheme::Daylight => COLOR_SCHEME_DAYLIGHT, + ColorScheme::Nord => COLOR_SCHEME_NORD, + ColorScheme::HighContrast => COLOR_SCHEME_HIGH_CONTRAST, + ColorScheme::GruvboxDark => COLOR_SCHEME_GRUVBOX_DARK, + ColorScheme::GruvboxLight => COLOR_SCHEME_GRUVBOX_LIGHT, + ColorScheme::Dracula => COLOR_SCHEME_DRACULA, + ColorScheme::Kanagawa => COLOR_SCHEME_KANAGAWA, + ColorScheme::Tokyonight => COLOR_SCHEME_TOKYONIGHT, + ColorScheme::Monokai => COLOR_SCHEME_MONOKAI, + ColorScheme::AtomOneDark => COLOR_SCHEME_ATOM_ONE_DARK, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ColorScheme { + System, + Midnight, + Daylight, + Nord, + HighContrast, + GruvboxDark, + GruvboxLight, + Dracula, + Kanagawa, + Tokyonight, + Monokai, + AtomOneDark, +} + +impl ColorScheme { + const ALL: [ColorScheme; 12] = [ + ColorScheme::System, + ColorScheme::Midnight, + ColorScheme::Daylight, + ColorScheme::Nord, + ColorScheme::HighContrast, + ColorScheme::GruvboxDark, + ColorScheme::GruvboxLight, + ColorScheme::Dracula, + ColorScheme::Kanagawa, + ColorScheme::Tokyonight, + ColorScheme::Monokai, + ColorScheme::AtomOneDark, + ]; + + fn label_loc(self) -> LocId { + match self { + ColorScheme::System => LocId::PreferencesSchemeSystem, + ColorScheme::Midnight => LocId::PreferencesSchemeMidnight, + ColorScheme::Daylight => LocId::PreferencesSchemeDaylight, + ColorScheme::Nord => LocId::PreferencesSchemeNord, + ColorScheme::HighContrast => LocId::PreferencesSchemeHighContrast, + ColorScheme::GruvboxDark => LocId::PreferencesSchemeGruvboxDark, + ColorScheme::GruvboxLight => LocId::PreferencesSchemeGruvboxLight, + ColorScheme::Dracula => LocId::PreferencesSchemeDracula, + ColorScheme::Kanagawa => LocId::PreferencesSchemeKanagawa, + ColorScheme::Tokyonight => LocId::PreferencesSchemeTokyonight, + ColorScheme::Monokai => LocId::PreferencesSchemeMonokai, + ColorScheme::AtomOneDark => LocId::PreferencesSchemeAtom, + } + } + + fn widget_id(self) -> &'static str { + match self { + ColorScheme::System => "scheme-system", + ColorScheme::Midnight => "scheme-midnight", + ColorScheme::Daylight => "scheme-daylight", + ColorScheme::Nord => "scheme-nord", + ColorScheme::HighContrast => "scheme-high-contrast", + ColorScheme::GruvboxDark => "scheme-gruvbox-dark", + ColorScheme::GruvboxLight => "scheme-gruvbox-light", + ColorScheme::Dracula => "scheme-dracula", + ColorScheme::Kanagawa => "scheme-kanagawa", + ColorScheme::Tokyonight => "scheme-tokyonight", + ColorScheme::Monokai => "scheme-monokai", + ColorScheme::AtomOneDark => "scheme-atom-one-dark", + } + } + + fn as_str(self) -> &'static str { + match self { + ColorScheme::System => "system", + ColorScheme::Midnight => "midnight", + ColorScheme::Daylight => "daylight", + ColorScheme::Nord => "nord", + ColorScheme::HighContrast => "high_contrast", + ColorScheme::GruvboxDark => "gruvbox_dark", + ColorScheme::GruvboxLight => "gruvbox_light", + ColorScheme::Dracula => "dracula", + ColorScheme::Kanagawa => "kanagawa", + ColorScheme::Tokyonight => "tokyonight", + ColorScheme::Monokai => "monokai", + ColorScheme::AtomOneDark => "atom_one_dark", + } + } + + fn from_str(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "system" => Some(ColorScheme::System), + "midnight" => Some(ColorScheme::Midnight), + "daylight" => Some(ColorScheme::Daylight), + "nord" => Some(ColorScheme::Nord), + "high_contrast" | "high-contrast" => Some(ColorScheme::HighContrast), + "gruvbox_dark" | "gruvbox-dark" => Some(ColorScheme::GruvboxDark), + "gruvbox_light" | "gruvbox-light" => Some(ColorScheme::GruvboxLight), + "dracula" => Some(ColorScheme::Dracula), + "kanagawa" => Some(ColorScheme::Kanagawa), + "tokyonight" | "tokyo_night" | "tokyo-night" => Some(ColorScheme::Tokyonight), + "monokai" => Some(ColorScheme::Monokai), + "atom_one_dark" | "atom-one-dark" | "atom" => Some(ColorScheme::AtomOneDark), + _ => None, + } + } +} + +#[derive(Clone)] +pub struct Preferences { + pub auto_close_pairs: bool, + pub line_highlight: bool, + pub colorscheme: ColorScheme, + pub show_line_numbers: bool, + pub word_wrap: bool, + pub indent_with_tabs: bool, + pub tab_width: u8, + pub language: Option, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + auto_close_pairs: true, + line_highlight: true, + colorscheme: ColorScheme::System, + show_line_numbers: true, + word_wrap: false, + indent_with_tabs: false, + tab_width: 4, + language: None, + } + } +} + +impl Preferences { + fn apply_to_text_buffer(&self, tb: &mut buffer::TextBuffer) { + tb.set_auto_pair_enabled(self.auto_close_pairs); + tb.set_line_highlight_enabled(self.line_highlight); + tb.set_margin_enabled(self.show_line_numbers); + tb.set_word_wrap(self.word_wrap); + tb.set_indent_with_tabs(self.indent_with_tabs); + tb.set_tab_size(CoordType::from(self.tab_width)); + } + + pub fn apply_to_document(&self, doc: &mut Document) { + let mut tb = doc.buffer.borrow_mut(); + self.apply_to_text_buffer(&mut tb); + } + + fn load_from_disk() -> Self { + let Some(path) = preferences_file_path() else { + return Self::default(); + }; + let Ok(text) = fs::read_to_string(path) else { + return Self::default(); + }; + let mut prefs = Preferences::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + match key { + "auto_close_pairs" => { + if let Some(val) = parse_bool(value) { + prefs.auto_close_pairs = val; + } + } + "line_highlight" => { + if let Some(val) = parse_bool(value) { + prefs.line_highlight = val; + } + } + "colorscheme" => { + if let Some(val) = ColorScheme::from_str(value) { + prefs.colorscheme = val; + } + } + "show_line_numbers" => { + if let Some(val) = parse_bool(value) { + prefs.show_line_numbers = val; + } + } + "word_wrap" => { + if let Some(val) = parse_bool(value) { + prefs.word_wrap = val; + } + } + "indent_with_tabs" => { + if let Some(val) = parse_bool(value) { + prefs.indent_with_tabs = val; + } + } + "tab_width" => { + if let Some(val) = parse_u8_in_range(value, 1, 8) { + prefs.tab_width = val; + } + } + "language" => { + let value = value.trim(); + if value.is_empty() { + prefs.language = None; + } else { + prefs.language = Some(value.to_string()); + } + } + _ => {} + } + } + prefs + } + + fn save_to_disk(&self) { + let Some(path) = preferences_file_path() else { + return; + }; + if let Some(parent) = path.parent() { + if fs::create_dir_all(parent).is_err() { + return; + } + } + let contents = format!( + "auto_close_pairs={}\n\ + line_highlight={}\n\ + colorscheme={}\n\ + show_line_numbers={}\n\ + word_wrap={}\n\ + indent_with_tabs={}\n\ + tab_width={}\n\ + language={}\n", + self.auto_close_pairs, + self.line_highlight, + self.colorscheme.as_str(), + self.show_line_numbers, + self.word_wrap, + self.indent_with_tabs, + self.tab_width, + self.language.as_deref().unwrap_or(""), + ); + let _ = fs::write(path, contents); + } + + fn apply_language(&self) { + if let Some(lang) = &self.language { + if localization::set_language_tag(lang).is_none() { + // If the stored tag is invalid, fall back to system default. + localization::reset_language(); + } + } + } +} + +fn normalize_lang_tag_value(tag: &str) -> String { + tag.chars() + .map(|c| match c { + 'A'..='Z' => c.to_ascii_lowercase(), + '-' => '_', + _ => c, + }) + .collect() +} + +fn lang_tag_eq(a: &str, b: &str) -> bool { + normalize_lang_tag_value(a) == normalize_lang_tag_value(b) +} + +const fn rgba(color: u32) -> StraightRgba { + StraightRgba::from_be(color) +} + +const COLOR_SCHEME_MIDNIGHT: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x073642ff), + rgba(0xdc322fff), + rgba(0x859900ff), + rgba(0xb58900ff), + rgba(0x268bd2ff), + rgba(0xd33682ff), + rgba(0x2aa198ff), + rgba(0xeee8d5ff), + rgba(0x002b36ff), + rgba(0xcb4b16ff), + rgba(0x586e75ff), + rgba(0x657b83ff), + rgba(0x839496ff), + rgba(0x6c71c4ff), + rgba(0x93a1a1ff), + rgba(0xfdf6e3ff), + rgba(0x002b36ff), + rgba(0x839496ff), +]; + +const COLOR_SCHEME_DAYLIGHT: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0xeee8d5ff), + rgba(0xdc322fff), + rgba(0x859900ff), + rgba(0xb58900ff), + rgba(0x268bd2ff), + rgba(0xd33682ff), + rgba(0x2aa198ff), + rgba(0x073642ff), + rgba(0xfdf6e3ff), + rgba(0xcb4b16ff), + rgba(0x93a1a1ff), + rgba(0x839496ff), + rgba(0x657b83ff), + rgba(0x6c71c4ff), + rgba(0x586e75ff), + rgba(0x002b36ff), + rgba(0xfdf6e3ff), + rgba(0x586e75ff), +]; + +const COLOR_SCHEME_NORD: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x2e3440ff), + rgba(0xbf616aff), + rgba(0xa3be8cff), + rgba(0xebcb8bff), + rgba(0x81a1c1ff), + rgba(0xb48eadff), + rgba(0x88c0d0ff), + rgba(0xe5e9f0ff), + rgba(0x3b4252ff), + rgba(0xbf616aff), + rgba(0xa3be8cff), + rgba(0xebcb8bff), + rgba(0x81a1c1ff), + rgba(0xb48eadff), + rgba(0x8fbcbbff), + rgba(0xeceff4ff), + rgba(0x2e3440ff), + rgba(0xe5e9f0ff), +]; + +const COLOR_SCHEME_HIGH_CONTRAST: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x000000ff), + rgba(0xff5555ff), + rgba(0x55ff55ff), + rgba(0xffff55ff), + rgba(0x5555ffff), + rgba(0xff55ffff), + rgba(0x55ffffff), + rgba(0xffffffff), + rgba(0x000000ff), + rgba(0xff0000ff), + rgba(0x00ff00ff), + rgba(0xffff00ff), + rgba(0x0000ffff), + rgba(0xff00ffff), + rgba(0x00ffffff), + rgba(0xffffffff), + rgba(0x000000ff), + rgba(0xffffffff), +]; + +const COLOR_SCHEME_GRUVBOX_DARK: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x282828ff), + rgba(0xcc241dff), + rgba(0x98971aff), + rgba(0xd79921ff), + rgba(0x458588ff), + rgba(0xb16286ff), + rgba(0x689d6aff), + rgba(0xa89984ff), + rgba(0x928374ff), + rgba(0xfb4934ff), + rgba(0xb8bb26ff), + rgba(0xfabd2fff), + rgba(0x83a598ff), + rgba(0xd3869bff), + rgba(0x8ec07cff), + rgba(0xebdbb2ff), + rgba(0x282828ff), + rgba(0xebdbb2ff), +]; + +const COLOR_SCHEME_GRUVBOX_LIGHT: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0xfbf1c7ff), + rgba(0xcc241dff), + rgba(0x98971aff), + rgba(0xd79921ff), + rgba(0x458588ff), + rgba(0xb16286ff), + rgba(0x689d6aff), + rgba(0x7c6f64ff), + rgba(0x928374ff), + rgba(0x9d0006ff), + rgba(0x79740eff), + rgba(0xb57614ff), + rgba(0x076678ff), + rgba(0x8f3f71ff), + rgba(0x427b58ff), + rgba(0x3c3836ff), + rgba(0xfbf1c7ff), + rgba(0x3c3836ff), +]; + +const COLOR_SCHEME_DRACULA: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x282a36ff), + rgba(0xff5555ff), + rgba(0x50fa7bff), + rgba(0xf1fa8cff), + rgba(0x6272a4ff), + rgba(0xff79c6ff), + rgba(0x8be9fdff), + rgba(0xf8f8f2ff), + rgba(0x44475aff), + rgba(0xff6e6eff), + rgba(0x69ff94ff), + rgba(0xffffa5ff), + rgba(0xbd93f9ff), + rgba(0xff92dfff), + rgba(0xa4ffffff), + rgba(0xffffffff), + rgba(0x282a36ff), + rgba(0xf8f8f2ff), +]; + +const COLOR_SCHEME_KANAGAWA: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x1f1f28ff), + rgba(0xc34043ff), + rgba(0x76946aff), + rgba(0xc0a36eff), + rgba(0x7e9cd8ff), + rgba(0x957fb8ff), + rgba(0x6a9589ff), + rgba(0xdcd7baff), + rgba(0x2a2a37ff), + rgba(0xe82424ff), + rgba(0x98bb6cff), + rgba(0xe6c384ff), + rgba(0x7fb4caff), + rgba(0x938aa9ff), + rgba(0x7aa89fff), + rgba(0xf2ecbcff), + rgba(0x1f1f28ff), + rgba(0xdcd7baff), +]; + +const COLOR_SCHEME_TOKYONIGHT: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x1a1b26ff), + rgba(0xf7768eff), + rgba(0x9ece6aff), + rgba(0xe0af68ff), + rgba(0x7aa2f7ff), + rgba(0xbb9af7ff), + rgba(0x7dcfffff), + rgba(0xc0caf5ff), + rgba(0x24283bff), + rgba(0xff9e64ff), + rgba(0xc3e88dff), + rgba(0xffc777ff), + rgba(0x82aaffff), + rgba(0xc5a3ffff), + rgba(0x89dcebff), + rgba(0xe5e9f0ff), + rgba(0x1a1b26ff), + rgba(0xc0caf5ff), +]; + +const COLOR_SCHEME_MONOKAI: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x272822ff), + rgba(0xf92672ff), + rgba(0xa6e22eff), + rgba(0xf4bf75ff), + rgba(0x66d9efff), + rgba(0xae81ffff), + rgba(0xa1efe4ff), + rgba(0xf9f8f5ff), + rgba(0x383830ff), + rgba(0xfd5ff1ff), + rgba(0xb6e354ff), + rgba(0xfbe760ff), + rgba(0x9effffff), + rgba(0xc4a3ffff), + rgba(0xaffff8ff), + rgba(0xffffffff), + rgba(0x272822ff), + rgba(0xf9f8f5ff), +]; + +const COLOR_SCHEME_ATOM_ONE_DARK: [StraightRgba; INDEXED_COLORS_COUNT] = [ + rgba(0x282c34ff), + rgba(0xe06c75ff), + rgba(0x98c379ff), + rgba(0xe5c07bff), + rgba(0x61afefff), + rgba(0xc678ddff), + rgba(0x56b6c2ff), + rgba(0xabb2bfff), + rgba(0x323842ff), + rgba(0xff7b86ff), + rgba(0xb1d196ff), + rgba(0xffd689ff), + rgba(0x73c8ffff), + rgba(0xd7a1ffff), + rgba(0x65d4d5ff), + rgba(0xe6eaf3ff), + rgba(0x282c34ff), + rgba(0xabb2bfff), +]; + +pub(crate) fn config_dir() -> Option { + let base = if cfg!(windows) { + env::var_os("APPDATA").map(PathBuf::from) + } else { + env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + }?; + + #[cfg(windows)] + let subdir = PathBuf::from("Microsoft").join("Edit"); + #[cfg(not(windows))] + let subdir = PathBuf::from("edit"); + + Some(base.join(subdir)) +} + +fn preferences_file_path() -> Option { + config_dir().map(|dir| dir.join("preferences.toml")) +} + +fn parse_bool(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +fn parse_u8_in_range(value: &str, min: u8, max: u8) -> Option { + value.parse::().ok().map(|v| v.clamp(min, max)) +} + +fn clamp_coord(value: i64) -> CoordType { + value.clamp(0, isize::MAX as i64) as CoordType +} diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index 6f0a714744e..8c3454604a5 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -42,6 +42,7 @@ use crate::clipboard::Clipboard; use crate::document::{ReadableDocument, WriteableDocument}; use crate::framebuffer::{Framebuffer, IndexedColor}; use crate::helpers::*; +use crate::highlight::{self, HighlightClass, HighlightSpan, SyntaxKind}; use crate::oklab::StraightRgba; use crate::simd::memchr2; use crate::unicode::{self, Cursor, MeasurementConfig, Utf8Chars}; @@ -58,6 +59,7 @@ const VISUAL_SPACE: &str = "・"; const VISUAL_SPACE_PREFIX_ADD: usize = '・'.len_utf8() - 1; const VISUAL_TAB: &str = "→ "; const VISUAL_TAB_PREFIX_ADD: usize = '→'.len_utf8() - 1; +const HIGHLIGHT_LEFT_CONTEXT_BYTES: usize = 2048; /// Stores statistics about the whole document. #[derive(Copy, Clone)] @@ -179,6 +181,7 @@ struct ActiveEditGroupInfo { } /// Char- or word-wise navigation? Your choice. +#[derive(Clone, Copy, PartialEq, Eq)] pub enum CursorMovement { Grapheme, Word, @@ -243,8 +246,10 @@ pub struct TextBuffer { newlines_are_crlf: bool, insert_final_newline: bool, overtype: bool, + auto_pair_enabled: bool, wants_cursor_visibility: bool, + syntax: SyntaxKind, } impl TextBuffer { @@ -291,8 +296,10 @@ impl TextBuffer { newlines_are_crlf: cfg!(windows), // Windows users want CRLF insert_final_newline: false, overtype: false, + auto_pair_enabled: true, wants_cursor_visibility: false, + syntax: SyntaxKind::Plain, }) } @@ -347,6 +354,14 @@ impl TextBuffer { } } + pub fn syntax(&self) -> SyntaxKind { + self.syntax + } + + pub fn set_syntax(&mut self, syntax: SyntaxKind) { + self.syntax = syntax; + } + /// The newline type used in the document. LF or CRLF. pub fn is_crlf(&self) -> bool { self.newlines_are_crlf @@ -578,6 +593,14 @@ impl TextBuffer { self.line_highlight_enabled = enabled; } + pub fn auto_pair_enabled(&self) -> bool { + self.auto_pair_enabled + } + + pub fn set_auto_pair_enabled(&mut self, enabled: bool) { + self.auto_pair_enabled = enabled; + } + /// Sets a ruler column, e.g. 80. pub fn set_ruler(&mut self, column: CoordType) { self.ruler = column; @@ -1721,6 +1744,8 @@ impl TextBuffer { let text_width = width - self.margin_width; let mut visualizer_buf = [0xE2, 0x90, 0x80]; // U+2400 in UTF8 let mut line = ArenaString::new_in(&scratch); + let mut highlight_line_text = ArenaString::new_in(&scratch); + let mut highlight_spans: Vec = Vec::new(); let mut visual_pos_x_max = 0; // Pick the cursor closer to the `origin.y`. @@ -1738,9 +1763,12 @@ impl TextBuffer { }; line.reserve(width as usize * 2); + let highlight_enabled = self.syntax.has_highlighting(); for y in 0..height { line.clear(); + highlight_line_text.clear(); + highlight_spans.clear(); let visual_line = origin.y + y; let mut cursor_beg = @@ -1749,6 +1777,22 @@ impl TextBuffer { cursor_beg, Point { x: origin.x + text_width, y: visual_line }, ); + let mut highlight_offset_base = cursor_beg.offset; + let mut highlight_has_visible = false; + + if highlight_enabled && cursor_beg.offset > 0 { + let line_start = self.goto_line_start(cursor_beg, cursor_beg.logical_pos.y); + let available = cursor_beg.offset.saturating_sub(line_start.offset); + if available > 0 { + let context_bytes = available.min(HIGHLIGHT_LEFT_CONTEXT_BYTES); + highlight_offset_base = cursor_beg.offset - context_bytes; + self.append_utf8_range( + highlight_offset_base, + cursor_beg.offset, + &mut highlight_line_text, + ); + } + } // Accelerate the next render pass by remembering where we started off. if y == 0 { @@ -1883,6 +1927,11 @@ impl TextBuffer { break; }; + if highlight_enabled { + highlight_line_text.push(ch); + highlight_has_visible = true; + } + if ch == ' ' || ch == '\t' { let is_tab = ch == '\t'; let visualize = selection_off.contains(&global_off); @@ -1963,6 +2012,20 @@ impl TextBuffer { visual_pos_x_max = visual_pos_x_max.max(cursor_end.visual_pos.x); } + if highlight_enabled && highlight_has_visible { + highlight::highlight_line(self.syntax, &highlight_line_text, &mut highlight_spans); + self.paint_highlight_spans( + cursor_end, + highlight_offset_base, + &highlight_spans, + &selection_off, + origin, + destination, + text_width, + fb, + ); + } + fb.replace_text(destination.top + y, destination.left, destination.right, &line); cursor = cursor_end; @@ -2068,6 +2131,288 @@ impl TextBuffer { } } + /// Handles automatic pair insertion and skipping for typed characters. + pub fn handle_auto_pair_typed(&mut self, ch: char) -> bool { + if !self.auto_pair_enabled { + return false; + } + if self.try_insert_matching_pair(ch) { + return true; + } + if self.try_insert_quote_pair(ch) { + return true; + } + if self.try_skip_closing_delimiter(ch) { + return true; + } + false + } + + fn try_insert_matching_pair(&mut self, ch: char) -> bool { + let closing = match ch { + '(' => ')', + '[' => ']', + '{' => '}', + _ => return false, + }; + self.insert_pair(ch, closing); + true + } + + fn try_insert_quote_pair(&mut self, ch: char) -> bool { + match ch { + '"' | '`' => { + if self.try_skip_closing_delimiter(ch) { + return true; + } + self.insert_pair(ch, ch); + true + } + '\'' => { + if self.has_selection() { + self.insert_pair('\'', '\''); + true + } else { + false + } + } + _ => false, + } + } + + fn try_skip_closing_delimiter(&mut self, ch: char) -> bool { + if !matches!(ch, ')' | ']' | '}' | '"' | '\'' | '`') { + return false; + } + if self.byte_at(self.cursor.offset) == Some(ch as u8) { + self.cursor_move_delta(CursorMovement::Grapheme, 1); + return true; + } + false + } + + fn insert_pair(&mut self, open: char, close: char) { + let mut open_buf = [0u8; 4]; + let mut close_buf = [0u8; 4]; + let open_bytes = open.encode_utf8(&mut open_buf).as_bytes(); + let close_bytes = close.encode_utf8(&mut close_buf).as_bytes(); + + if self.has_selection() { + let selection = self.extract_selection(true); + self.write_canon(open_bytes); + if !selection.is_empty() { + self.write_raw(&selection); + } + self.write_canon(close_bytes); + } else { + self.write_canon(open_bytes); + self.write_canon(close_bytes); + } + + self.cursor_move_delta(CursorMovement::Grapheme, -1); + } + + fn byte_at(&self, offset: usize) -> Option { + self.read_forward(offset).first().copied() + } + + fn char_at(&self, offset: usize) -> Option { + let chunk = self.read_forward(offset); + if chunk.is_empty() { + return None; + } + Utf8Chars::new(chunk, 0).next() + } + + fn char_before(&self, offset: usize) -> Option { + if offset == 0 { + return None; + } + let chunk = self.read_backward(offset); + if chunk.is_empty() { + return None; + } + let mut it = Utf8Chars::new(chunk, 0); + let mut last = None; + while let Some(ch) = it.next() { + last = Some(ch); + } + last + } + + fn prev_non_whitespace_byte(&self, mut offset: usize) -> Option { + if offset == 0 { + return None; + } + + while offset > 0 { + let chunk = self.read_backward(offset); + if chunk.is_empty() { + break; + } + + for &byte in chunk.iter().rev() { + offset = offset.saturating_sub(1); + if !byte.is_ascii_whitespace() { + return Some(byte); + } + if offset == 0 { + break; + } + } + } + + None + } + + fn push_indent_columns(&self, buffer: &mut ArenaString, mut columns: CoordType) { + if columns <= 0 { + return; + } + + if self.indent_with_tabs && self.tab_size > 0 { + let tab_count = columns / self.tab_size; + if tab_count > 0 { + buffer.push_repeat('\t', tab_count as usize); + columns -= tab_count * self.tab_size; + } + } + + if columns > 0 { + buffer.push_repeat(' ', columns as usize); + } + } + + fn should_increase_indent_after(&self, byte: u8) -> bool { + matches!(byte, b'{' | b'[' | b'(') || (byte == b':' && self.syntax.indent_after_colon()) + } + + fn try_delete_matching_pair_before_cursor(&mut self) -> bool { + let cursor_offset = self.cursor.offset; + let Some(open) = self.char_before(cursor_offset) else { + return false; + }; + let Some(close) = self.char_at(cursor_offset) else { + return false; + }; + if !is_matching_pair(open, close) { + return false; + } + + let start = self.cursor_move_delta_internal(self.cursor, CursorMovement::Grapheme, -1); + let end = self.cursor_move_delta_internal(self.cursor, CursorMovement::Grapheme, 1); + self.edit_begin(HistoryType::Delete, start); + self.edit_delete(end); + self.edit_end(); + self.set_selection(None); + self.cursor_move_to_offset(start.offset); + true + } + + fn paint_highlight_spans( + &mut self, + cursor_hint: Cursor, + line_offset: usize, + spans: &[HighlightSpan], + selection: &Range, + origin: Point, + destination: Rect, + text_width: CoordType, + fb: &mut Framebuffer, + ) { + if spans.is_empty() { + return; + } + + let mut cursor = self.cursor_move_to_offset_internal(cursor_hint, line_offset); + let mut current_offset = line_offset; + + for span in spans { + let start = line_offset + span.range.start; + let end = line_offset + span.range.end; + if selection.start < selection.end && end > selection.start && start < selection.end { + continue; + } + + if start < current_offset { + cursor = self.cursor_move_to_offset_internal(cursor_hint, start); + } else { + cursor = self.cursor_move_to_offset_internal(cursor, start); + } + let start_cursor = cursor; + cursor = self.cursor_move_to_offset_internal(cursor, end); + let end_cursor = cursor; + current_offset = end; + + self.paint_highlight_segment( + start_cursor, + end_cursor, + span.class, + origin, + destination, + text_width, + fb, + ); + } + } + + fn paint_highlight_segment( + &self, + start: Cursor, + end: Cursor, + class: HighlightClass, + origin: Point, + destination: Rect, + text_width: CoordType, + fb: &mut Framebuffer, + ) { + if start.offset >= end.offset { + return; + } + + let color = self.highlight_color(class, fb); + let mut row = start.visual_pos.y; + + while row <= end.visual_pos.y { + let row_start_x = if row == start.visual_pos.y { start.visual_pos.x } else { origin.x }; + let row_end_x = + if row == end.visual_pos.y { end.visual_pos.x } else { origin.x + text_width }; + + if row_end_x <= row_start_x { + row += 1; + continue; + } + + let screen_y = destination.top + row - origin.y; + if screen_y < destination.top || screen_y >= destination.bottom { + row += 1; + continue; + } + + let base_left = destination.left + self.margin_width; + let mut left = base_left + (row_start_x - origin.x).clamp(0, text_width); + let mut right = base_left + (row_end_x - origin.x).clamp(0, text_width); + left = left.clamp(base_left, destination.right); + right = right.clamp(base_left, destination.right); + + if left < right { + fb.blend_fg(Rect { left, top: screen_y, right, bottom: screen_y + 1 }, color); + } + row += 1; + } + } + + fn highlight_color(&self, class: HighlightClass, fb: &Framebuffer) -> StraightRgba { + match class { + HighlightClass::Keyword => fb.indexed(IndexedColor::BrightMagenta), + HighlightClass::Type => fb.indexed(IndexedColor::BrightBlue), + HighlightClass::String => fb.indexed(IndexedColor::BrightGreen), + HighlightClass::Number => fb.indexed(IndexedColor::BrightCyan), + HighlightClass::Comment => fb.indexed_alpha(IndexedColor::BrightBlack, 1, 2), + HighlightClass::Macro => fb.indexed(IndexedColor::Yellow), + } + } + /// Inserts the user input `text` at the current cursor position. /// Replaces tabs with whitespace if needed, etc. pub fn write_canon(&mut self, text: &[u8]) { @@ -2158,16 +2503,14 @@ impl TextBuffer { newline_buffer.clear(); newline_buffer.push_str(if self.newlines_are_crlf { "\r\n" } else { "\n" }); + let mut newline_indentation = 0; + let mut block_indent_columns = 0; + if !raw { // We'll give the next line the same indentation as the previous one. - // This block figures out how much that is. We can't reuse that value, - // because " a\n a\n" should give the 3rd line a total indentation of 4. - // Assuming your terminal has bracketed paste, this won't be a concern though. - // (If it doesn't, use a different terminal.) let line_beg = self.goto_line_start(self.cursor, self.cursor.logical_pos.y); let limit = self.cursor.offset; let mut off = line_beg.offset; - let mut newline_indentation = 0; 'outer: while off < limit { let chunk = self.read_forward(off); @@ -2186,20 +2529,34 @@ impl TextBuffer { off += chunk.len(); } - // If tabs are enabled, add as many tabs as we can. - if self.indent_with_tabs { - let tab_count = newline_indentation / self.tab_size; - newline_buffer.push_repeat('\t', tab_count as usize); - newline_indentation -= tab_count * self.tab_size; + if let Some(prev) = self.prev_non_whitespace_byte(self.cursor.offset) { + if self.should_increase_indent_after(prev) { + block_indent_columns = self.tab_size_eval(newline_indentation.max(0)); + newline_indentation += block_indent_columns; + } } - // If tabs are disabled, or if the indentation wasn't a multiple of the tab size, - // add spaces to make up the difference. - newline_buffer.push_repeat(' ', newline_indentation as usize); + self.push_indent_columns(&mut newline_buffer, newline_indentation); } self.edit_write(newline_buffer.as_bytes()); + if !raw && block_indent_columns > 0 { + if let Some(next) = self.byte_at(self.cursor.offset) { + if matches!(next, b')' | b']' | b'}') { + let cursor_restore = self.cursor; + newline_buffer.clear(); + newline_buffer.push_str(if self.newlines_are_crlf { "\r\n" } else { "\n" }); + self.push_indent_columns( + &mut newline_buffer, + newline_indentation - block_indent_columns, + ); + self.edit_write(newline_buffer.as_bytes()); + self.set_cursor_internal(cursor_restore); + } + } + } + // Skip one CR/LF/CRLF. if offset >= text.len() { break; @@ -2243,6 +2600,12 @@ impl TextBuffer { /// The selection is cleared after the call. /// Deletes characters from the buffer based on a delta from the cursor. pub fn delete(&mut self, granularity: CursorMovement, delta: CoordType) { + if delta == -1 && granularity == CursorMovement::Grapheme && !self.has_selection() { + if self.try_delete_matching_pair_before_cursor() { + return; + } + } + if delta == 0 { return; } @@ -2844,6 +3207,23 @@ impl TextBuffer { pub fn read_forward(&self, off: usize) -> &[u8] { self.buffer.read_forward(off) } + + fn append_utf8_range(&self, mut start: usize, end: usize, out: &mut ArenaString) { + let end = end.min(self.text_length()); + start = start.min(end); + while start < end { + let chunk = self.read_forward(start); + if chunk.is_empty() { + break; + } + let take = (end - start).min(chunk.len()); + let mut it = Utf8Chars::new(&chunk[..take], 0); + while let Some(ch) = it.next() { + out.push(ch); + } + start += take; + } + } } pub enum Bom { @@ -2858,6 +3238,13 @@ pub enum Bom { const BOM_MAX_LEN: usize = 4; +fn is_matching_pair(open: char, close: char) -> bool { + matches!( + (open, close), + ('(', ')') | ('[', ']') | ('{', '}') | ('\"', '\"') | ('\'', '\'') | ('`', '`') + ) +} + fn detect_bom(bytes: &[u8]) -> Option<&'static str> { if bytes.len() >= 4 { if bytes.starts_with(b"\xFF\xFE\x00\x00") { diff --git a/src/clipboard.rs b/src/clipboard.rs index 413de71fbd9..6aaeb2de2d2 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -39,11 +39,9 @@ impl Clipboard { /// Fill the clipboard with the given data. pub fn write(&mut self, data: Vec) { - if !data.is_empty() { - self.data = data; - self.line_copy = false; - self.wants_host_sync = true; - } + self.line_copy = false; + self.wants_host_sync = !data.is_empty(); + self.data = data; } /// See [`Clipboard::is_line_copy`]. diff --git a/src/framebuffer.rs b/src/framebuffer.rs index b26a715d661..3ed5b9861ea 100644 --- a/src/framebuffer.rs +++ b/src/framebuffer.rs @@ -131,8 +131,8 @@ impl Framebuffer { /// successfully detect the light/dark mode of the terminal. pub fn set_indexed_colors(&mut self, colors: [StraightRgba; INDEXED_COLORS_COUNT]) { self.indexed_colors = colors; - self.background_fill = StraightRgba::zero(); - self.foreground_fill = StraightRgba::zero(); + self.background_fill = self.indexed_colors[IndexedColor::Background as usize]; + self.foreground_fill = self.indexed_colors[IndexedColor::Foreground as usize]; self.auto_colors = [ self.indexed_colors[IndexedColor::Black as usize], diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 00000000000..d0ab487df02 --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,910 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ops::Range; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SyntaxKind { + Plain, + Rust, + Cpp, + Json, + Toml, + Shell, + Python, + Markdown, +} + +impl Default for SyntaxKind { + fn default() -> Self { + Self::Plain + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HighlightClass { + Keyword, + Type, + String, + Number, + Comment, + Macro, +} + +#[derive(Clone, Debug)] +pub struct HighlightSpan { + pub range: Range, + pub class: HighlightClass, +} + +impl SyntaxKind { + pub fn from_path(path: Option<&Path>) -> Self { + let Some(path) = path else { + return Self::Plain; + }; + + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); + let ext = path.extension().and_then(|s| s.to_str()).map(|s| s.to_ascii_lowercase()); + + match ext.as_deref() { + Some("rs") => SyntaxKind::Rust, + Some("c" | "h" | "cc" | "hh" | "cpp" | "hpp" | "cxx" | "hxx" | "ino") => { + SyntaxKind::Cpp + } + Some("java" | "kt" | "kts" | "swift" | "go") => SyntaxKind::Cpp, + Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs") => SyntaxKind::Cpp, + Some("cs") => SyntaxKind::Cpp, + Some("json" | "jsonc") => SyntaxKind::Json, + Some("toml") => SyntaxKind::Toml, + Some("yaml" | "yml") => SyntaxKind::Toml, + Some("py" | "pyi") => SyntaxKind::Python, + Some("sh" | "bash" | "zsh" | "fish" | "ps1") => SyntaxKind::Shell, + Some("md" | "mdx" | "markdown") => SyntaxKind::Markdown, + _ => match name.as_str() { + "cargo.toml" | "cargo.lock" | "pyproject.toml" | "poetry.lock" => SyntaxKind::Toml, + "package.json" | "tsconfig.json" | "composer.json" => SyntaxKind::Json, + _ => SyntaxKind::Plain, + }, + } + } + + pub fn has_highlighting(self) -> bool { + !matches!(self, SyntaxKind::Plain) + } + + pub fn indent_after_colon(self) -> bool { + matches!(self, SyntaxKind::Python) + } +} + +pub fn highlight_line(kind: SyntaxKind, line: &str, out: &mut Vec) { + out.clear(); + if !kind.has_highlighting() || line.is_empty() { + return; + } + + match kind { + SyntaxKind::Plain => {} + SyntaxKind::Rust => highlight_rust(line, out), + SyntaxKind::Cpp => highlight_c_like(line, out, CPP_KEYWORDS, CPP_TYPES, true), + SyntaxKind::Json => highlight_json(line, out), + SyntaxKind::Toml => highlight_toml(line, out), + SyntaxKind::Shell => highlight_shell(line, out), + SyntaxKind::Python => highlight_python(line, out), + SyntaxKind::Markdown => highlight_markdown(line, out), + } +} + +const RUST_KEYWORDS: &[&str] = &[ + "as", "async", "await", "break", "const", "continue", "crate", "else", "enum", "extern", "fn", + "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", + "return", "Self", "self", "static", "struct", "super", "trait", "type", "unsafe", "use", + "where", "while", +]; + +const RUST_TYPES: &[&str] = &[ + "bool", "char", "usize", "isize", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", + "i128", "f32", "f64", "String", "Vec", "Option", "Result", +]; + +const CPP_KEYWORDS: &[&str] = &[ + "alignas", + "alignof", + "and", + "and_eq", + "asm", + "auto", + "bitand", + "bitor", + "bool", + "break", + "case", + "catch", + "char", + "char8_t", + "char16_t", + "char32_t", + "class", + "compl", + "concept", + "const", + "consteval", + "constexpr", + "constinit", + "const_cast", + "continue", + "co_await", + "co_return", + "co_yield", + "decltype", + "default", + "delete", + "do", + "double", + "dynamic_cast", + "else", + "enum", + "explicit", + "export", + "extern", + "false", + "float", + "for", + "friend", + "goto", + "if", + "inline", + "int", + "long", + "mutable", + "namespace", + "new", + "noexcept", + "not", + "not_eq", + "nullptr", + "operator", + "or", + "or_eq", + "private", + "protected", + "public", + "register", + "reinterpret_cast", + "requires", + "return", + "short", + "signed", + "sizeof", + "static", + "static_assert", + "static_cast", + "struct", + "switch", + "template", + "this", + "thread_local", + "throw", + "true", + "try", + "typedef", + "typeid", + "typename", + "union", + "unsigned", + "using", + "virtual", + "void", + "volatile", + "wchar_t", + "while", + "xor", + "xor_eq", + "override", + "final", +]; + +const CPP_TYPES: &[&str] = &[ + "int8_t", + "uint8_t", + "int16_t", + "uint16_t", + "int32_t", + "uint32_t", + "int64_t", + "uint64_t", + "size_t", + "ptrdiff_t", + "ssize_t", + "wint_t", + "clock_t", + "time_t", + "FILE", + "std", + "string", + "wstring", + "vector", + "map", + "set", + "unordered_map", + "unordered_set", + "unique_ptr", + "shared_ptr", + "weak_ptr", + "tuple", + "array", + "optional", + "variant", + "span", +]; + +const PYTHON_KEYWORDS: &[&str] = &[ + "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", + "else", "except", "False", "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True", "try", "while", + "with", "yield", +]; + +fn highlight_c_like( + line: &str, + out: &mut Vec, + keywords: &[&str], + types: &[&str], + highlight_macros_flag: bool, +) { + let bytes = line.as_bytes(); + let comment_idx = find_line_comment(bytes, b'/', b'/'); + let (body, comment) = split_comment(bytes, comment_idx); + + highlight_strings(body, true, true, out); + let occupied = build_occupied(body.len(), out, &[HighlightClass::String]); + highlight_numbers_with_mask(body, Some(&occupied), true, out); + highlight_words_with_mask(body, keywords, HighlightClass::Keyword, Some(&occupied), out); + highlight_words_with_mask(body, types, HighlightClass::Type, Some(&occupied), out); + if highlight_macros_flag { + highlight_macros(body, Some(&occupied), out); + } + highlight_preprocessor(bytes, out); + + if let Some(range) = comment { + push_span(out, range, HighlightClass::Comment); + } +} + +fn highlight_rust(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let comment_idx = find_line_comment(bytes, b'/', b'/'); + let (body, comment) = split_comment(bytes, comment_idx); + + highlight_rust_strings(body, out); + highlight_rust_char_literals(body, out); + let occupied = build_occupied(body.len(), out, &[HighlightClass::String]); + highlight_numbers_with_mask(body, Some(&occupied), true, out); + highlight_words_with_mask(body, RUST_KEYWORDS, HighlightClass::Keyword, Some(&occupied), out); + highlight_words_with_mask(body, RUST_TYPES, HighlightClass::Type, Some(&occupied), out); + highlight_rust_lifetimes(body, Some(&occupied), out); + highlight_macros(body, Some(&occupied), out); + highlight_preprocessor(body, out); + + if let Some(range) = comment { + push_span(out, range, HighlightClass::Comment); + } +} + +fn highlight_json(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + highlight_strings(bytes, false, true, out); + let occupied = build_occupied(bytes.len(), out, &[HighlightClass::String]); + highlight_numbers_with_mask(bytes, Some(&occupied), false, out); + highlight_words_with_mask( + bytes, + &["true", "false", "null"], + HighlightClass::Keyword, + Some(&occupied), + out, + ); +} + +fn highlight_toml(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let comment_idx = find_line_comment(bytes, b'#', b'#'); + let (body, comment) = split_comment(bytes, comment_idx); + + highlight_strings(body, true, true, out); + let occupied = build_occupied(body.len(), out, &[HighlightClass::String]); + highlight_numbers_with_mask(body, Some(&occupied), false, out); + + if let Some(range) = find_key_span(body) { + push_span(out, range, HighlightClass::Keyword); + } + + if let Some(range) = comment { + push_span(out, range, HighlightClass::Comment); + } +} + +fn highlight_shell(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let comment_idx = find_line_comment(bytes, b'#', b'#'); + let (body, comment) = split_comment(bytes, comment_idx); + + highlight_strings(body, true, true, out); + let occupied = build_occupied(body.len(), out, &[HighlightClass::String]); + highlight_words_with_mask( + body, + &["if", "then", "fi", "for", "in", "do", "done", "case", "esac", "function"], + HighlightClass::Keyword, + Some(&occupied), + out, + ); + + if let Some(range) = comment { + push_span(out, range, HighlightClass::Comment); + } +} + +fn highlight_python(line: &str, out: &mut Vec) { + let bytes = line.as_bytes(); + let comment_idx = find_line_comment(bytes, b'#', b'#'); + let (body, comment) = split_comment(bytes, comment_idx); + + highlight_strings(body, true, true, out); + let occupied = build_occupied(body.len(), out, &[HighlightClass::String]); + highlight_numbers_with_mask(body, Some(&occupied), true, out); + highlight_words_with_mask(body, PYTHON_KEYWORDS, HighlightClass::Keyword, Some(&occupied), out); + + if let Some(range) = comment { + push_span(out, range, HighlightClass::Comment); + } +} + +fn highlight_markdown(line: &str, out: &mut Vec) { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + push_span(out, 0..line.len(), HighlightClass::Keyword); + return; + } + + if trimmed.starts_with("```") { + push_span(out, 0..line.len(), HighlightClass::Comment); + return; + } + + let mut idx = 0; + let bytes = line.as_bytes(); + while idx < bytes.len() { + if bytes[idx] == b'`' { + let start = idx; + idx += 1; + while idx < bytes.len() && bytes[idx] != b'`' { + idx += 1; + } + if idx < bytes.len() { + idx += 1; + } + push_span(out, start..idx, HighlightClass::String); + } else { + idx += 1; + } + } +} + +fn highlight_rust_strings(bytes: &[u8], out: &mut Vec) { + let mut idx = 0; + while idx < bytes.len() { + match bytes[idx] { + b'"' => { + let end = scan_rust_standard_string(bytes, idx, false).unwrap_or(bytes.len()); + push_span(out, idx..end, HighlightClass::String); + idx = end; + } + b'b' => { + let Some(next) = bytes.get(idx + 1).copied() else { + idx += 1; + continue; + }; + match next { + b'"' => { + let end = + scan_rust_standard_string(bytes, idx + 1, false).unwrap_or(bytes.len()); + push_span(out, idx..end, HighlightClass::String); + idx = end; + } + b'\'' => { + if let Some(end) = scan_rust_standard_string(bytes, idx + 1, true) { + push_span(out, idx..end, HighlightClass::String); + idx = end; + } else { + idx += 1; + } + } + b'r' => { + if let Some(end) = scan_rust_raw_string(bytes, idx + 2) { + push_span(out, idx..end, HighlightClass::String); + idx = end; + } else { + idx += 1; + } + } + _ => idx += 1, + } + } + b'r' => { + if let Some(end) = scan_rust_raw_string(bytes, idx + 1) { + push_span(out, idx..end, HighlightClass::String); + idx = end; + } else { + idx += 1; + } + } + _ => idx += 1, + } + } +} + +fn scan_rust_standard_string( + bytes: &[u8], + quote_idx: usize, + require_terminator: bool, +) -> Option { + let &delim = bytes.get(quote_idx)?; + let mut idx = quote_idx + 1; + while idx < bytes.len() { + let b = bytes[idx]; + idx += 1; + if b == b'\\' && idx < bytes.len() { + idx += 1; + continue; + } + if b == delim { + return Some(idx); + } + } + if require_terminator { None } else { Some(bytes.len()) } +} + +fn scan_rust_raw_string(bytes: &[u8], mut idx: usize) -> Option { + let mut hashes = 0; + while idx < bytes.len() && bytes[idx] == b'#' { + idx += 1; + hashes += 1; + } + if idx >= bytes.len() || bytes[idx] != b'"' { + return None; + } + idx += 1; // Skip the opening quote. + let mut cursor = idx; + while cursor < bytes.len() { + if bytes[cursor] == b'"' { + let mut end = cursor + 1; + let mut matched = 0; + while matched < hashes && end < bytes.len() && bytes[end] == b'#' { + end += 1; + matched += 1; + } + if matched == hashes { + return Some(end); + } + } + cursor += 1; + } + Some(bytes.len()) +} + +fn highlight_rust_char_literals(bytes: &[u8], out: &mut Vec) { + let mut idx = 0; + while idx < bytes.len() { + if bytes[idx] == b'b' && idx + 1 < bytes.len() && bytes[idx + 1] == b'\'' { + if let Some(end) = scan_rust_char_literal(bytes, idx + 1) { + push_span(out, idx..end, HighlightClass::String); + idx = end; + continue; + } + idx += 1; + continue; + } + + if bytes[idx] == b'\'' { + if let Some(end) = scan_rust_char_literal(bytes, idx) { + push_span(out, idx..end, HighlightClass::String); + idx = end; + } else { + idx += 1; + } + continue; + } + + idx += 1; + } +} + +fn scan_rust_char_literal(bytes: &[u8], quote_idx: usize) -> Option { + let mut idx = quote_idx + 1; + if idx >= bytes.len() { + return None; + } + + if bytes[idx] == b'\\' { + idx += 1; + if idx >= bytes.len() { + return None; + } + match bytes[idx] { + b'x' => { + idx += 1; + let mut consumed = 0; + while consumed < 2 && idx < bytes.len() && bytes[idx].is_ascii_hexdigit() { + idx += 1; + consumed += 1; + } + if consumed == 0 { + return None; + } + } + b'u' => { + idx += 1; + if idx >= bytes.len() || bytes[idx] != b'{' { + return None; + } + idx += 1; + let mut consumed = 0; + while idx < bytes.len() && bytes[idx].is_ascii_hexdigit() { + idx += 1; + consumed += 1; + } + if consumed == 0 || idx >= bytes.len() || bytes[idx] != b'}' { + return None; + } + idx += 1; + } + _ => { + idx += 1; + } + } + } else { + let slice = std::str::from_utf8(&bytes[idx..]).ok()?; + let mut chars = slice.chars(); + let ch = chars.next()?; + idx += ch.len_utf8(); + } + + if idx < bytes.len() && bytes[idx] == b'\'' { Some(idx + 1) } else { None } +} + +fn highlight_rust_lifetimes(bytes: &[u8], occupied: Option<&[bool]>, out: &mut Vec) { + let mut idx = 0; + while idx < bytes.len() { + if occupied.map_or(false, |mask| mask.get(idx).copied().unwrap_or(false)) { + idx += 1; + continue; + } + if bytes[idx] != b'\'' { + idx += 1; + continue; + } + let next = idx + 1; + if next >= bytes.len() || !is_ident_start(bytes[next]) { + idx += 1; + continue; + } + let start = idx; + idx += 2; + while idx < bytes.len() && is_ident_continue(bytes[idx]) { + idx += 1; + } + push_span(out, start..idx, HighlightClass::Type); + } +} + +fn highlight_strings( + bytes: &[u8], + allow_single: bool, + allow_double: bool, + out: &mut Vec, +) { + let mut idx = 0; + while idx < bytes.len() { + let ch = bytes[idx]; + let is_single = allow_single && ch == b'\''; + let is_double = allow_double && ch == b'"'; + if !is_single && !is_double { + idx += 1; + continue; + } + + let delim = ch; + let start = idx; + idx += 1; + while idx < bytes.len() { + let b = bytes[idx]; + idx += 1; + if b == b'\\' && idx < bytes.len() { + idx += 1; + continue; + } + if b == delim { + break; + } + } + + push_span(out, start..idx, HighlightClass::String); + } +} + +fn highlight_numbers_with_mask( + bytes: &[u8], + occupied: Option<&[bool]>, + allow_alpha_suffix: bool, + out: &mut Vec, +) { + let mut idx = 0; + while idx < bytes.len() { + if occupied.map_or(false, |mask| mask.get(idx).copied().unwrap_or(false)) { + idx += 1; + continue; + } + if !bytes[idx].is_ascii_digit() { + idx += 1; + continue; + } + let start = idx; + idx += 1; + if idx < bytes.len() + && bytes[start] == b'0' + && matches!(bytes[idx], b'b' | b'B' | b'o' | b'O' | b'x' | b'X') + { + idx += 1; + } + + let mut allow_fraction = true; + while idx < bytes.len() { + let b = bytes[idx]; + if b.is_ascii_hexdigit() || b == b'_' { + idx += 1; + continue; + } + if allow_fraction && b == b'.' { + if idx + 1 < bytes.len() && bytes[idx + 1].is_ascii_digit() { + idx += 1; + continue; + } + break; + } + if matches!(b, b'e' | b'E' | b'p' | b'P') { + let mut exp_idx = idx + 1; + if exp_idx < bytes.len() && matches!(bytes[exp_idx], b'+' | b'-') { + exp_idx += 1; + } + let exp_start = exp_idx; + while exp_idx < bytes.len() && bytes[exp_idx].is_ascii_digit() { + exp_idx += 1; + } + if exp_idx == exp_start { + break; + } + idx = exp_idx; + allow_fraction = false; + continue; + } + break; + } + + if allow_alpha_suffix { + while idx < bytes.len() && (bytes[idx].is_ascii_alphanumeric() || bytes[idx] == b'#') { + idx += 1; + } + } + + push_span(out, start..idx, HighlightClass::Number); + } +} + +fn highlight_words_with_mask( + bytes: &[u8], + words: &[&str], + class: HighlightClass, + occupied: Option<&[bool]>, + out: &mut Vec, +) { + if words.is_empty() { + return; + } + let mut idx = 0; + while idx < bytes.len() { + if occupied.map_or(false, |mask| mask.get(idx).copied().unwrap_or(false)) { + idx += 1; + continue; + } + if !is_ident_start(bytes[idx]) { + idx += 1; + continue; + } + let start = idx; + idx += 1; + while idx < bytes.len() && is_ident_continue(bytes[idx]) { + idx += 1; + } + + if let Ok(word) = std::str::from_utf8(&bytes[start..idx]) { + if words.iter().any(|w| *w == word) { + push_span(out, start..idx, class); + } + } + } +} + +fn highlight_macros(bytes: &[u8], occupied: Option<&[bool]>, out: &mut Vec) { + let mut idx = 0; + while idx < bytes.len() { + if occupied.map_or(false, |mask| mask.get(idx).copied().unwrap_or(false)) { + idx += 1; + continue; + } + if !is_ident_start(bytes[idx]) { + idx += 1; + continue; + } + + let start = idx; + idx += 1; + while idx < bytes.len() && is_ident_continue(bytes[idx]) { + idx += 1; + } + + if idx < bytes.len() && bytes[idx] == b'!' { + push_span(out, start..idx + 1, HighlightClass::Macro); + idx += 1; + } + } +} + +fn highlight_preprocessor(bytes: &[u8], out: &mut Vec) { + let mut idx = 0; + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + if idx < bytes.len() && bytes[idx] == b'#' { + push_span(out, idx..bytes.len(), HighlightClass::Macro); + } +} + +fn find_key_span(bytes: &[u8]) -> Option> { + let mut idx = 0; + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + let mut start = idx; + while idx < bytes.len() && bytes[idx] != b'=' { + idx += 1; + } + + while start < idx && bytes[start].is_ascii_whitespace() { + start += 1; + } + while idx > start && bytes[idx - 1].is_ascii_whitespace() { + idx -= 1; + } + if start < idx { Some(start..idx) } else { None } +} + +fn find_line_comment(bytes: &[u8], first: u8, second: u8) -> Option { + let mut idx = 0; + let mut in_string = None; + while idx < bytes.len() { + let b = bytes[idx]; + if let Some(delim) = in_string { + idx += 1; + if b == b'\\' && idx < bytes.len() { + idx += 1; + continue; + } + if b == delim { + in_string = None; + } + continue; + } + + if b == b'"' || b == b'\'' { + in_string = Some(b); + idx += 1; + continue; + } + + if first == second { + if b == first { + return Some(idx); + } + } else if b == first && idx + 1 < bytes.len() && bytes[idx + 1] == second { + return Some(idx); + } + idx += 1; + } + None +} + +fn split_comment<'a>(bytes: &'a [u8], idx: Option) -> (&'a [u8], Option>) { + match idx { + Some(pos) => { + let (body, comment) = bytes.split_at(pos); + (body, Some(pos..pos + comment.len())) + } + None => (bytes, None), + } +} + +fn push_span(spans: &mut Vec, range: Range, class: HighlightClass) { + if range.start < range.end { + spans.push(HighlightSpan { range, class }); + } +} + +fn is_ident_start(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphabetic() +} + +fn is_ident_continue(byte: u8) -> bool { + is_ident_start(byte) || byte.is_ascii_digit() +} + +fn build_occupied(len: usize, spans: &[HighlightSpan], classes: &[HighlightClass]) -> Vec { + let mut occupied = vec![false; len]; + for span in spans { + if !classes.contains(&span.class) { + continue; + } + let start = span.range.start.min(len); + let end = span.range.end.min(len); + for i in start..end { + occupied[i] = true; + } + } + occupied +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect_spans(kind: SyntaxKind, line: &str) -> Vec { + let mut spans = Vec::new(); + highlight_line(kind, line, &mut spans); + spans + } + + fn has_span(spans: &[HighlightSpan], line: &str, class: HighlightClass, needle: &str) -> bool { + spans.iter().any(|span| span.class == class && &line[span.range.clone()] == needle) + } + + #[test] + fn rust_lifetimes_are_not_strings() { + let line = "fn foo<'a>(x: &'a str, y: &'static str) {}"; + let spans = collect_spans(SyntaxKind::Rust, line); + assert!(has_span(&spans, line, HighlightClass::Type, "'a")); + assert!(has_span(&spans, line, HighlightClass::Type, "'static")); + + let lifetime_idx = line.find("'static").unwrap(); + let in_string = spans.iter().any(|span| { + span.class == HighlightClass::String + && span.range.start <= lifetime_idx + && lifetime_idx < span.range.end + }); + assert!(!in_string, "lifetime token should not be highlighted as a string"); + } + + #[test] + fn rust_raw_and_byte_strings_are_supported() { + let line = "let msg = r#\"hello\"#; let data = br##\"hi\"##;"; + let spans = collect_spans(SyntaxKind::Rust, line); + assert!(has_span(&spans, line, HighlightClass::String, "r#\"hello\"#")); + assert!(has_span(&spans, line, HighlightClass::String, "br##\"hi\"##")); + } + + #[test] + fn numbers_cover_prefixes_and_suffixes() { + let line = "let size = 10usize + 0b1010;"; + let spans = collect_spans(SyntaxKind::Rust, line); + assert!(has_span(&spans, line, HighlightClass::Number, "10usize")); + assert!(has_span(&spans, line, HighlightClass::Number, "0b1010")); + } +} diff --git a/src/input.rs b/src/input.rs index 9e6c12a2a9c..591799bf600 100644 --- a/src/input.rs +++ b/src/input.rs @@ -9,7 +9,7 @@ use std::mem; use crate::helpers::{CoordType, Point, Size}; -use crate::vt; +use crate::{base64, vt}; /// Represents a key/modifier combination. /// @@ -270,6 +270,7 @@ pub struct Parser { x10_mouse_want: bool, x10_mouse_buf: [u8; 3], x10_mouse_len: usize, + osc_buf: String, } impl Parser { @@ -283,6 +284,7 @@ impl Parser { x10_mouse_want: false, x10_mouse_buf: [0; 3], x10_mouse_len: 0, + osc_buf: String::new(), } } @@ -330,6 +332,11 @@ impl<'input> Iterator for Stream<'_, '_, 'input> { vt::Token::Text(text) => { return Some(Input::Text(text)); } + vt::Token::Osc { data, partial } => { + if let Some(input) = self.handle_osc_sequence(data, partial) { + return Some(input); + } + } vt::Token::Ctrl(ch) => match ch { '\0' | '\t' | '\r' => return Some(Input::Keyboard(InputKey::new(ch as u32))), '\n' => return Some(Input::Keyboard(kbmod::CTRL | vk::RETURN)), @@ -526,6 +533,42 @@ impl<'input> Stream<'_, '_, 'input> { } } + fn handle_osc_sequence(&mut self, data: &str, partial: bool) -> Option> { + if partial { + self.parser.osc_buf.push_str(data); + return None; + } + + if self.parser.osc_buf.is_empty() { + return Self::parse_clipboard_osc(data); + } + + self.parser.osc_buf.push_str(data); + let result = Self::parse_clipboard_osc(&self.parser.osc_buf); + self.parser.osc_buf.clear(); + result + } + + fn parse_clipboard_osc(data: &str) -> Option> { + let mut parts = data.splitn(3, ';'); + match parts.next()? { + "52" => {} + _ => return None, + } + + // selection parameter, e.g. "c". We currently accept any value. + let _ = parts.next(); + let payload = parts.next().unwrap_or(""); + + if payload == "?" { + return None; + } + + let bytes = if payload.is_empty() { Vec::new() } else { base64::decode(payload)? }; + + Some(Input::Paste(bytes)) + } + /// Implements the X10 mouse protocol via `CSI M CbCxCy`. /// /// You want to send numeric mouse coordinates. @@ -584,3 +627,20 @@ impl<'input> Stream<'_, '_, 'input> { modifiers } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc52_response_emits_paste() { + let mut vt_parser = vt::Parser::new(); + let mut parser = Parser::new(); + let vt_stream = vt_parser.parse("\x1b]52;c;U3lzdGVtIFBhc3Rl\x07"); + let mut iter = parser.parse(vt_stream); + match iter.next() { + Some(Input::Paste(bytes)) => assert_eq!(bytes, b"System Paste"), + _ => panic!("unexpected input"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4a150da197b..bc8e13d1ebe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ pub mod framebuffer; pub mod fuzzy; pub mod hash; pub mod helpers; +pub mod highlight; pub mod icu; pub mod input; pub mod oklab; diff --git a/src/tui.rs b/src/tui.rs index 78308f0d679..c877733444a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -162,6 +162,7 @@ use crate::oklab::StraightRgba; use crate::{apperr, arena_format, input, simd, unicode}; const ROOT_ID: u64 = 0x14057B7EF767814F; // Knuth's MMIX constant +const CLIPBOARD_REQUEST_TIMEOUT: time::Duration = time::Duration::from_millis(150); const SHIFT_TAB: InputKey = vk::TAB.with_modifiers(kbmod::SHIFT); const KBMOD_FOR_WORD_NAV: InputKeyMod = if cfg!(target_os = "macos") { kbmod::ALT } else { kbmod::CTRL }; @@ -365,12 +366,21 @@ pub struct Tui { /// The clipboard contents. clipboard: Clipboard, + clipboard_query_pending: bool, + clipboard_request_started: Option, + clipboard_host_supported: Option, + synthetic_ctrl_v_pending: bool, settling_have: i32, settling_want: i32, read_timeout: time::Duration, } +enum CtrlVPasteDecision { + PasteNow, + Wait, +} + impl Tui { /// Creates a new [`Tui`] instance for storing state across frames. pub fn new() -> apperr::Result { @@ -415,6 +425,10 @@ impl Tui { cached_text_buffers: Vec::with_capacity(16), clipboard: Default::default(), + clipboard_query_pending: false, + clipboard_request_started: None, + clipboard_host_supported: None, + synthetic_ctrl_v_pending: false, settling_have: 0, settling_want: 0, @@ -502,6 +516,53 @@ impl Tui { &mut self.clipboard } + fn ctrl_v_decision(&mut self) -> CtrlVPasteDecision { + if self.synthetic_ctrl_v_pending { + self.synthetic_ctrl_v_pending = false; + return CtrlVPasteDecision::PasteNow; + } + + if self.clipboard_request_started.is_some() { + return CtrlVPasteDecision::Wait; + } + + if self.clipboard_host_supported == Some(false) { + return CtrlVPasteDecision::PasteNow; + } + + self.clipboard_request_started = Some(std::time::Instant::now()); + self.clipboard_query_pending = true; + self.needs_more_settling(); + CtrlVPasteDecision::Wait + } + + fn mark_clipboard_paste_received(&mut self) { + if self.clipboard_request_started.is_some() { + self.clipboard_request_started = None; + self.clipboard_host_supported = Some(true); + } + self.synthetic_ctrl_v_pending = true; + self.needs_more_settling(); + } + + pub fn take_clipboard_request(&mut self) -> bool { + mem::take(&mut self.clipboard_query_pending) + } + + fn poll_clipboard_timeout(&mut self) { + if let Some(started) = self.clipboard_request_started + && started.elapsed() > CLIPBOARD_REQUEST_TIMEOUT + { + self.clipboard_request_started = None; + self.clipboard_query_pending = false; + if self.clipboard_host_supported.is_none() { + self.clipboard_host_supported = Some(false); + } + self.synthetic_ctrl_v_pending = true; + self.needs_more_settling(); + } + } + /// Starts a new frame and returns a [`Context`] for it. pub fn create_context<'a, 'input>( &'a mut self, @@ -533,12 +594,14 @@ impl Tui { // `self.needs_settling() == true`. However, there's a possibility for it being true from // a previous frame, and we do have fresh new input. In that case want `input_consumed` // to be false of course which is ensured by checking for `input.is_none()`. - let input_consumed = self.needs_settling() && input.is_none(); + let mut input_consumed = self.needs_settling() && input.is_none(); if self.scroll_to_focused() { self.needs_more_settling(); } + self.poll_clipboard_timeout(); + match input { None => {} Some(Input::Resize(resize)) => { @@ -562,6 +625,7 @@ impl Tui { let clipboard = self.clipboard_mut(); clipboard.write(paste); clipboard.mark_as_synchronized(); + self.mark_clipboard_paste_received(); input_keyboard = Some(kbmod::CTRL | vk::V); } Some(Input::Keyboard(keyboard)) => { @@ -682,6 +746,11 @@ impl Tui { } } + if input_keyboard.is_none() && self.synthetic_ctrl_v_pending { + input_keyboard = Some(kbmod::CTRL | vk::V); + input_consumed = false; + } + if !input_consumed { // Every time there's input, we naturally need to re-render at least once. self.settling_have = 0; @@ -1390,6 +1459,27 @@ impl<'a> Context<'a, '_> { self.tui.framebuffer.contrasted(color) } + pub fn set_color_palette(&mut self, colors: [StraightRgba; INDEXED_COLORS_COUNT]) { + self.tui.setup_indexed_colors(colors); + self.tui.framebuffer.set_indexed_colors(colors); + } + + pub fn set_floater_default_bg(&mut self, color: StraightRgba) { + self.tui.set_floater_default_bg(color); + } + + pub fn set_floater_default_fg(&mut self, color: StraightRgba) { + self.tui.set_floater_default_fg(color); + } + + pub fn set_modal_default_bg(&mut self, color: StraightRgba) { + self.tui.set_modal_default_bg(color); + } + + pub fn set_modal_default_fg(&mut self, color: StraightRgba) { + self.tui.set_modal_default_fg(color); + } + /// Returns the clipboard. pub fn clipboard_ref(&self) -> &Clipboard { &self.tui.clipboard @@ -1400,6 +1490,11 @@ impl<'a> Context<'a, '_> { &mut self.tui.clipboard } + /// Returns whether the terminal supports OSC 52 clipboard reads. + pub fn clipboard_host_support(&self) -> Option { + self.tui.clipboard_host_supported + } + /// Tell the UI framework that your state changed and you need another layout pass. pub fn needs_rerender(&mut self) { // If this hits, the call stack is responsible is trying to deadlock you. @@ -1525,7 +1620,7 @@ impl<'a> Context<'a, '_> { self.next_block_id_mixin = id; } - fn attr_focusable(&mut self) { + pub fn attr_focusable(&mut self) { let mut last_node = self.tree.last_node.borrow_mut(); last_node.attributes.focusable = true; } @@ -2013,6 +2108,7 @@ impl<'a> Context<'a, '_> { pub fn button(&mut self, classname: &'static str, text: &str, style: ButtonStyle) -> bool { self.button_label(classname, text, style); self.attr_focusable(); + self.inherit_focus(); if self.is_focused() { self.attr_reverse(); } @@ -2024,6 +2120,7 @@ impl<'a> Context<'a, '_> { pub fn checkbox(&mut self, classname: &'static str, text: &str, checked: &mut bool) -> bool { self.styled_label_begin(classname); self.attr_focusable(); + self.inherit_focus(); if self.is_focused() { self.attr_reverse(); } @@ -2324,6 +2421,15 @@ impl<'a> Context<'a, '_> { let mut write: &[u8] = &[]; if let Some(input) = &self.input_text { + if !single_line { + let mut chars = input.chars(); + if let (Some(ch), None) = (chars.next(), chars.next()) { + if tb.handle_auto_pair_typed(ch) { + self.set_input_consumed(); + return true; + } + } + } write = input.as_bytes(); } else if let Some(input) = &self.input_keyboard { let key = input.key(); @@ -2682,7 +2788,10 @@ impl<'a> Context<'a, '_> { _ => return false, }, vk::V => match modifiers { - kbmod::CTRL => tb.paste(self.clipboard_ref()), + kbmod::CTRL => match self.tui.ctrl_v_decision() { + CtrlVPasteDecision::PasteNow => tb.paste(self.clipboard_ref()), + CtrlVPasteDecision::Wait => self.needs_rerender(), + }, _ => return false, }, vk::Y => match modifiers { @@ -2935,6 +3044,7 @@ impl<'a> Context<'a, '_> { let selected_before; let selected_now; let focused; + let select_requested = select; { let mut list = list.borrow_mut(); let content = match &mut list.content { @@ -2948,15 +3058,16 @@ impl<'a> Context<'a, '_> { focused = self.is_focused(); // Inherit the default selection & Click changes selection - selected_now = selected_before || (select && content.selected == 0) || focused; + selected_now = selected_before || select_requested || focused; // Note down the selected node for keyboard navigation. if selected_now { content.selected_node = Some(self.tree.last_node); - if !selected_before { - content.selected = item_id; - self.needs_rerender(); - } + } + + if !selected_before && (select_requested || focused) { + content.selected = item_id; + self.needs_rerender(); } }