diff --git a/.gitignore b/.gitignore index 9c2e570..c65c81a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ -*.profraw -*.user .idea .vs -CMakeSettings.json -bin +*.profraw lcov.info -obj -out target diff --git a/.vscode/launch.json b/.vscode/launch.json index 1897343..b57b8b7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "program": "${workspaceFolder}/target/debug/edit", "cwd": "${workspaceFolder}", "args": [ - "${workspaceFolder}/src/main.rs" + "${workspaceFolder}/src/bin/edit/main.rs" ], }, { @@ -21,7 +21,7 @@ "program": "${workspaceFolder}/target/debug/edit", "cwd": "${workspaceFolder}", "args": [ - "${workspaceFolder}/src/main.rs" + "${workspaceFolder}/src/bin/edit/main.rs" ], } ] diff --git a/Cargo.toml b/Cargo.toml index 829c619..05c810f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "edit" version = "0.3.0" edition = "2024" +[[bench]] +name = "lib" +harness = false + [features] debug-layout = [] debug-latency = [] @@ -47,7 +51,3 @@ features = [ [dev-dependencies] criterion = "0.5" - -[[bench]] -name = "lib" -harness = false diff --git a/src/apperr.rs b/src/apperr.rs index 05dd251..0df92db 100644 --- a/src/apperr.rs +++ b/src/apperr.rs @@ -1,5 +1,4 @@ -use crate::loc::{LocId, loc}; -use crate::{icu, sys}; +use crate::sys; use std::io; use std::result; @@ -27,15 +26,6 @@ impl Error { pub const fn new_sys(code: u32) -> Self { Error::Sys(code) } - - pub fn message(&self) -> String { - match *self { - APP_ICU_MISSING => loc(LocId::ErrorIcuMissing).to_string(), - Error::App(code) => format!("Unknown app error code: {code}"), - Error::Icu(code) => icu::apperr_format(code), - Error::Sys(code) => sys::apperr_format(code), - } - } } impl From for Error { diff --git a/src/bin/edit/documents.rs b/src/bin/edit/documents.rs new file mode 100644 index 0000000..91b6a10 --- /dev/null +++ b/src/bin/edit/documents.rs @@ -0,0 +1,312 @@ +use edit::apperr; +use edit::buffer::{RcTextBuffer, TextBuffer}; +use edit::helpers::{CoordType, Point}; +use edit::simd::memrchr2; +use edit::sys; +use std::collections::LinkedList; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub enum DocumentPath { + None, + Preliminary(PathBuf), + Canonical(PathBuf), +} + +impl DocumentPath { + pub fn as_path(&self) -> Option<&Path> { + match self { + DocumentPath::None => None, + DocumentPath::Preliminary(p) | DocumentPath::Canonical(p) => Some(p), + } + } + + pub fn eq_canonical(&self, path: &Path) -> bool { + match self { + DocumentPath::Canonical(p) => p == path, + _ => false, + } + } +} + +pub struct Document { + pub buffer: RcTextBuffer, + pub path: DocumentPath, + pub filename: String, + pub new_file_counter: usize, +} + +impl Document { + fn update_file_mode(&mut self) { + let mut tb = self.buffer.borrow_mut(); + tb.set_ruler(if self.filename == "COMMIT_EDITMSG" { + 72 + } else { + 0 + }); + } +} + +#[derive(Default)] +pub struct DocumentManager { + list: LinkedList, +} + +impl DocumentManager { + #[inline] + pub fn len(&self) -> usize { + self.list.len() + } + + #[inline] + pub fn active(&self) -> Option<&Document> { + self.list.front() + } + + #[inline] + pub fn active_mut(&mut self) -> Option<&mut Document> { + self.list.front_mut() + } + + #[inline] + pub fn update_active bool>(&mut self, mut func: F) -> bool { + let mut cursor = self.list.cursor_front_mut(); + while let Some(doc) = cursor.current() { + if func(doc) { + let list = cursor.remove_current_as_list().unwrap(); + self.list.cursor_front_mut().splice_before(list); + return true; + } + cursor.move_next(); + } + false + } + + pub fn remove_active(&mut self) { + self.list.pop_front(); + } + + pub fn add_untitled(&mut self) -> apperr::Result<&mut Document> { + let buffer = TextBuffer::new_rc(false)?; + { + let mut tb = buffer.borrow_mut(); + tb.set_margin_enabled(true); + tb.set_line_highlight_enabled(true); + } + + let mut doc = Document { + buffer, + path: DocumentPath::None, + filename: Default::default(), + new_file_counter: 0, + }; + self.gen_untitled_name(&mut doc); + + self.list.push_front(doc); + Ok(self.list.front_mut().unwrap()) + } + + pub fn gen_untitled_name(&self, doc: &mut Document) { + let mut new_file_counter = 0; + for doc in &self.list { + new_file_counter = new_file_counter.max(doc.new_file_counter); + } + new_file_counter += 1; + + doc.filename = format!("Untitled-{new_file_counter}"); + doc.new_file_counter = new_file_counter; + } + + pub fn add_file_path(&mut self, path: &Path) -> apperr::Result<&mut Document> { + let (path, goto) = Self::parse_filename_goto(path); + + let canonical = match sys::canonicalize(path).map_err(apperr::Error::from) { + Ok(path) => Some(path), + Err(err) if sys::apperr_is_not_found(err) => None, + Err(err) => return Err(err), + }; + let canonical_ref = canonical.as_deref(); + let canonical_is_file = canonical_ref.is_some_and(|p| p.is_file()); + + // Check if the file is already open. + if let Some(canon) = canonical_ref { + if self.update_active(|doc| doc.path.eq_canonical(canon)) { + let doc = self.active_mut().unwrap(); + if let Some(goto) = goto { + doc.buffer.borrow_mut().cursor_move_to_logical(goto); + } + return Ok(doc); + } + } + + let buffer = TextBuffer::new_rc(false)?; + { + let mut tb = buffer.borrow_mut(); + tb.set_margin_enabled(true); + tb.set_line_highlight_enabled(true); + + if canonical_is_file && let Some(canon) = canonical_ref { + tb.read_file_path(canon, None)?; + + if let Some(goto) = goto + && goto != Point::default() + { + tb.cursor_move_to_logical(goto); + } + } + } + + let path = match canonical { + // Path exists and is a file. + Some(path) if canonical_is_file => DocumentPath::Canonical(path), + // Path doesn't exist at all. + None => DocumentPath::Preliminary(path.to_path_buf()), + // Path exists but is not a file (a directory?). + _ => DocumentPath::None, + }; + let filename = path + .as_path() + .map_or(Default::default(), Self::get_filename_from_path); + let mut doc = Document { + buffer, + path, + filename, + new_file_counter: 0, + }; + + if doc.filename.is_empty() { + self.gen_untitled_name(&mut doc); + } + doc.update_file_mode(); + + self.list.push_front(doc); + Ok(self.list.front_mut().unwrap()) + } + + pub fn save_active(&mut self, new_path: Option<&Path>) -> apperr::Result<()> { + let Some(doc) = self.active_mut() else { + return Ok(()); + }; + + { + let path = new_path.or_else(|| doc.path.as_path()).unwrap(); + let mut tb = doc.buffer.borrow_mut(); + tb.write_file(path)?; + } + + // Turn the new_path or existing preliminary path into a canonical path. + // Now that the file exists, that should theoretically work. + if let Some(path) = new_path.or_else(|| match &doc.path { + DocumentPath::Preliminary(path) => Some(path), + _ => None, + }) { + let path = sys::canonicalize(path)?; + doc.filename = Self::get_filename_from_path(&path); + doc.path = DocumentPath::Canonical(path); + doc.update_file_mode(); + } + + Ok(()) + } + + pub fn get_filename_from_path(path: &Path) -> String { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + } + + // Parse a filename in the form of "filename:line:char". + // Returns the position of the first colon and the line/char coordinates. + fn parse_filename_goto(path: &Path) -> (&Path, Option) { + fn parse(s: &[u8]) -> Option { + if s.is_empty() { + return None; + } + + let mut num: CoordType = 0; + for &b in s { + if !b.is_ascii_digit() { + return None; + } + let digit = (b - b'0') as CoordType; + num = num.checked_mul(10)?.checked_add(digit)?; + } + Some(num) + } + + let bytes = path.as_os_str().as_encoded_bytes(); + let colend = match memrchr2(b':', b':', bytes, bytes.len()) { + // Reject filenames that would result in an empty filename after stripping off the :line:char suffix. + // For instance, a filename like ":123:456" will not be processed by this function. + Some(colend) if colend > 0 => colend, + _ => return (path, None), + }; + + let last = match parse(&bytes[colend + 1..]) { + Some(last) => last, + None => return (path, None), + }; + let last = (last - 1).max(0); + let mut len = colend; + let mut goto = Point { x: 0, y: last }; + + if let Some(colbeg) = memrchr2(b':', b':', bytes, colend) { + // Same here: Don't allow empty filenames. + if colbeg != 0 { + if let Some(first) = parse(&bytes[colbeg + 1..colend]) { + let first = (first - 1).max(0); + len = colbeg; + goto = Point { x: last, y: first }; + } + } + } + + // Strip off the :line:char suffix. + let path = &bytes[..len]; + let path = unsafe { OsStr::from_encoded_bytes_unchecked(path) }; + let path = Path::new(path); + (path, Some(goto)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_last_numbers() { + fn parse(s: &str) -> (&str, Option) { + let (p, g) = DocumentManager::parse_filename_goto(Path::new(s)); + (p.to_str().unwrap(), g) + } + + assert_eq!(parse("123"), ("123", None)); + assert_eq!(parse("abc"), ("abc", None)); + assert_eq!(parse(":123"), (":123", None)); + assert_eq!(parse("abc:123"), ("abc", Some(Point { x: 0, y: 122 }))); + assert_eq!(parse("45:123"), ("45", Some(Point { x: 0, y: 122 }))); + assert_eq!(parse(":45:123"), (":45", Some(Point { x: 0, y: 122 }))); + assert_eq!(parse("abc:45:123"), ("abc", Some(Point { x: 122, y: 44 }))); + assert_eq!( + parse("abc:def:123"), + ("abc:def", Some(Point { x: 0, y: 122 })) + ); + assert_eq!(parse("1:2:3"), ("1", Some(Point { x: 2, y: 1 }))); + assert_eq!(parse("::3"), (":", Some(Point { x: 0, y: 2 }))); + assert_eq!(parse("1::3"), ("1:", Some(Point { x: 0, y: 2 }))); + assert_eq!(parse(""), ("", None)); + assert_eq!(parse(":"), (":", None)); + assert_eq!(parse("::"), ("::", None)); + assert_eq!(parse("a:1"), ("a", Some(Point { x: 0, y: 0 }))); + assert_eq!(parse("1:a"), ("1:a", None)); + assert_eq!( + parse("file.txt:10"), + ("file.txt", Some(Point { x: 0, y: 9 })) + ); + assert_eq!( + parse("file.txt:10:5"), + ("file.txt", Some(Point { x: 4, y: 9 })) + ); + } +} diff --git a/src/bin/edit/draw_editor.rs b/src/bin/edit/draw_editor.rs new file mode 100644 index 0000000..5bffafe --- /dev/null +++ b/src/bin/edit/draw_editor.rs @@ -0,0 +1,304 @@ +use crate::loc::*; +use crate::state::*; +use edit::framebuffer::IndexedColor; +use edit::helpers::*; +use edit::icu; +use edit::input::kbmod; +use edit::input::vk; +use edit::tui::*; + +pub fn draw_editor(ctx: &mut Context, state: &mut State) { + if !matches!( + state.wants_search.kind, + StateSearchKind::Hidden | StateSearchKind::Disabled + ) { + draw_search(ctx, state); + } + + let size = ctx.size(); + // TODO: The layout code should be able to just figure out the height on its own. + let height_reduction = match state.wants_search.kind { + StateSearchKind::Search => 4, + StateSearchKind::Replace => 5, + _ => 2, + }; + + if let Some(doc) = state.documents.active() { + ctx.textarea("textarea", doc.buffer.clone()); + ctx.inherit_focus(); + } else { + ctx.block_begin("empty"); + ctx.block_end(); + } + + ctx.attr_intrinsic_size(Size { + width: 0, + height: size.height - height_reduction, + }); +} + +fn draw_search(ctx: &mut Context, state: &mut State) { + enum SearchAction { + None, + Search, + Replace, + ReplaceAll, + } + + if let Err(err) = icu::init() { + error_log_add(ctx, state, err); + state.wants_search.kind = StateSearchKind::Disabled; + return; + } + + let Some(doc) = state.documents.active() else { + state.wants_search.kind = StateSearchKind::Hidden; + return; + }; + + let mut action = SearchAction::None; + let mut focus = StateSearchKind::Hidden; + + if state.wants_search.focus { + state.wants_search.focus = false; + focus = StateSearchKind::Search; + + // If the selection is empty, focus the search input field. + // Otherwise, focus the replace input field, if it exists. + if let Some(selection) = doc.buffer.borrow_mut().extract_user_selection(false) { + state.search_needle = string_from_utf8_lossy_owned(selection); + focus = state.wants_search.kind; + } + } + + ctx.block_begin("search"); + ctx.attr_focus_well(); + ctx.attr_background_rgba(ctx.indexed(IndexedColor::White)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Black)); + { + if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) { + state.wants_search.kind = StateSearchKind::Hidden; + } + + ctx.table_begin("needle"); + ctx.table_set_cell_gap(Size { + width: 1, + height: 0, + }); + { + { + ctx.table_next_row(); + ctx.label("label", Overflow::Clip, loc(LocId::SearchNeedleLabel)); + + if ctx.editline("needle", &mut state.search_needle) { + action = SearchAction::Search; + } + if !state.search_success { + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); + } + ctx.attr_intrinsic_size(Size { + width: COORD_TYPE_SAFE_MAX, + height: 1, + }); + if focus == StateSearchKind::Search { + ctx.steal_focus(); + } + if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) { + action = SearchAction::Search; + } + } + + if state.wants_search.kind == StateSearchKind::Replace { + ctx.table_next_row(); + ctx.label("label", Overflow::Clip, loc(LocId::SearchReplacementLabel)); + + ctx.editline("replacement", &mut state.search_replacement); + ctx.attr_intrinsic_size(Size { + width: COORD_TYPE_SAFE_MAX, + height: 1, + }); + if focus == StateSearchKind::Replace { + ctx.steal_focus(); + } + if ctx.is_focused() { + if ctx.consume_shortcut(vk::RETURN) { + action = SearchAction::Replace; + } else if ctx.consume_shortcut(kbmod::CTRL_ALT | vk::RETURN) { + action = SearchAction::ReplaceAll; + } + } + } + } + ctx.table_end(); + + ctx.table_begin("options"); + ctx.table_set_cell_gap(Size { + width: 2, + height: 0, + }); + { + ctx.table_next_row(); + + let mut change = false; + change |= ctx.checkbox( + "match-case", + Overflow::Clip, + loc(LocId::SearchMatchCase), + &mut state.search_options.match_case, + ); + change |= ctx.checkbox( + "whole-word", + Overflow::Clip, + loc(LocId::SearchWholeWord), + &mut state.search_options.whole_word, + ); + change |= ctx.checkbox( + "use-regex", + Overflow::Clip, + loc(LocId::SearchUseRegex), + &mut state.search_options.use_regex, + ); + if change { + action = SearchAction::Search; + state.wants_search.focus = true; + ctx.needs_rerender(); + } + + if state.wants_search.kind == StateSearchKind::Replace + && ctx.button("replace-all", Overflow::Clip, loc(LocId::SearchReplaceAll)) + { + action = SearchAction::ReplaceAll; + } + + if ctx.button("close", Overflow::Clip, loc(LocId::SearchClose)) { + state.wants_search.kind = StateSearchKind::Hidden; + } + } + ctx.table_end(); + } + ctx.block_end(); + + state.search_success = match action { + SearchAction::None => return, + SearchAction::Search => doc + .buffer + .borrow_mut() + .find_and_select(&state.search_needle, state.search_options), + SearchAction::Replace => doc.buffer.borrow_mut().find_and_replace( + &state.search_needle, + state.search_options, + &state.search_replacement, + ), + SearchAction::ReplaceAll => doc.buffer.borrow_mut().find_and_replace_all( + &state.search_needle, + state.search_options, + &state.search_replacement, + ), + } + .is_ok(); + + ctx.needs_rerender(); +} + +pub fn draw_handle_save(ctx: &mut Context, state: &mut State) { + if let Some(doc) = state.documents.active() { + if let Some(path) = doc.path.as_path() { + if let Err(err) = { doc.buffer.borrow_mut().write_file(path) } { + error_log_add(ctx, state, err); + } + } else { + // No path? Show the file picker. + state.wants_file_picker = StateFilePicker::SaveAs; + state.wants_save = false; + ctx.needs_rerender(); + } + } + + state.wants_save = false; +} + +pub fn draw_handle_wants_close(ctx: &mut Context, state: &mut State) { + let Some(doc) = state.documents.active() else { + state.wants_close = false; + return; + }; + + if !doc.buffer.borrow().is_dirty() { + state.documents.remove_active(); + state.wants_close = false; + ctx.needs_rerender(); + return; + } + + enum Action { + None, + Save, + Discard, + Cancel, + } + let mut action = Action::None; + + ctx.modal_begin("unsaved-changes", loc(LocId::UnsavedChangesDialogTitle)); + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); + { + ctx.label( + "description", + Overflow::Clip, + loc(LocId::UnsavedChangesDialogDescription), + ); + ctx.attr_padding(Rect::three(1, 2, 1)); + + ctx.table_begin("choices"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(0, 2, 1)); + ctx.attr_position(Position::Center); + ctx.table_set_cell_gap(Size { + width: 2, + height: 0, + }); + { + ctx.table_next_row(); + ctx.inherit_focus(); + + if ctx.button("yes", Overflow::Clip, loc(LocId::UnsavedChangesDialogYes)) { + action = Action::Save; + } + ctx.inherit_focus(); + if ctx.button("no", Overflow::Clip, loc(LocId::UnsavedChangesDialogNo)) { + action = Action::Discard; + } + if ctx.button( + "cancel", + Overflow::Clip, + loc(LocId::UnsavedChangesDialogCancel), + ) { + action = Action::Cancel; + } + + // TODO: This should highlight the corresponding letter in the label. + if ctx.consume_shortcut(vk::S) { + action = Action::Save; + } else if ctx.consume_shortcut(vk::N) { + action = Action::Discard; + } + } + ctx.table_end(); + } + if ctx.modal_end() { + action = Action::Cancel; + } + + match action { + Action::None => return, + Action::Save => state.wants_save = true, + Action::Discard => state.documents.remove_active(), + Action::Cancel => { + state.wants_exit = false; + state.wants_close = false; + } + } + + ctx.toss_focus_up(); +} diff --git a/src/bin/edit/draw_filepicker.rs b/src/bin/edit/draw_filepicker.rs new file mode 100644 index 0000000..16924c3 --- /dev/null +++ b/src/bin/edit/draw_filepicker.rs @@ -0,0 +1,258 @@ +use crate::documents::*; +use crate::loc::*; +use crate::state::*; +use edit::framebuffer::IndexedColor; +use edit::helpers::*; +use edit::icu; +use edit::input::vk; +use edit::tui::*; +use std::cmp::Ordering; +use std::path::Component; +use std::path::PathBuf; + +pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { + let width = (ctx.size().width - 20).max(10); + let height = (ctx.size().height - 10).max(10); + let mut doit = None; + + ctx.modal_begin( + "file-picker", + if state.wants_file_picker == StateFilePicker::Open { + loc(LocId::FileOpen) + } else { + loc(LocId::FileSaveAs) + }, + ); + ctx.attr_intrinsic_size(Size { width, height }); + { + let mut activated = false; + + ctx.table_begin("path"); + ctx.table_set_columns(&[0, COORD_TYPE_SAFE_MAX]); + ctx.table_set_cell_gap(Size { + width: 1, + height: 0, + }); + ctx.attr_padding(Rect::two(1, 1)); + ctx.inherit_focus(); + { + ctx.table_next_row(); + + ctx.label( + "dir-label", + Overflow::Clip, + loc(LocId::SaveAsDialogPathLabel), + ); + ctx.label( + "dir", + Overflow::TruncateMiddle, + state.file_picker_pending_dir.as_str(), + ); + + ctx.table_next_row(); + ctx.inherit_focus(); + + ctx.label( + "name-label", + Overflow::Clip, + loc(LocId::SaveAsDialogNameLabel), + ); + ctx.editline("name", &mut state.file_picker_pending_name); + ctx.inherit_focus(); + if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) { + activated = true; + } + } + ctx.table_end(); + + if state.file_picker_entries.is_none() { + draw_dialog_saveas_refresh_files(state); + } + + let files = state.file_picker_entries.as_ref().unwrap(); + + ctx.scrollarea_begin( + "directory", + Size { + width: 0, + // -1 for the label (top) + // -1 for the label (bottom) + // -1 for the editline (bottom) + height: height - 3, + }, + ); + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 0x3f)); + ctx.next_block_id_mixin(state.file_picker_pending_dir.as_str().len() as u64); + { + ctx.list_begin("files"); + ctx.inherit_focus(); + for entry in files.iter() { + match ctx.list_item( + state.file_picker_pending_name == entry.as_str(), + Overflow::TruncateMiddle, + entry.as_str(), + ) { + ListSelection::Unchanged => {} + ListSelection::Selected => { + state.file_picker_pending_name = entry.as_str().to_string() + } + ListSelection::Activated => activated = true, + } + } + ctx.list_end(); + } + ctx.scrollarea_end(); + + if activated { + doit = draw_file_picker_update_path(state); + + // Check if the file already exists and show an overwrite warning in that case. + if state.wants_file_picker == StateFilePicker::SaveAs + && let Some(path) = doit.as_deref() + && let Some(doc) = state.documents.active() + && !doc.path.eq_canonical(path) + && path.exists() + { + state.file_picker_overwrite_warning = doit.take(); + } + } + } + if ctx.modal_end() { + state.wants_file_picker = StateFilePicker::None; + } + + if state.file_picker_overwrite_warning.is_some() { + let mut save; + + ctx.modal_begin("overwrite", loc(LocId::FileOverwriteWarning)); + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); + { + ctx.label( + "description", + Overflow::TruncateTail, + loc(LocId::FileOverwriteWarningDescription), + ); + ctx.attr_padding(Rect::three(1, 2, 1)); + + ctx.table_begin("choices"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(0, 2, 1)); + ctx.attr_position(Position::Center); + ctx.table_set_cell_gap(Size { + width: 2, + height: 0, + }); + { + ctx.table_next_row(); + ctx.inherit_focus(); + + save = ctx.button("yes", Overflow::Clip, loc(LocId::Yes)); + ctx.inherit_focus(); + + if ctx.button("no", Overflow::Clip, loc(LocId::No)) { + state.file_picker_overwrite_warning = None; + } + } + ctx.table_end(); + + save |= ctx.consume_shortcut(vk::Y); + if ctx.consume_shortcut(vk::N) { + state.file_picker_overwrite_warning = None; + } + } + if ctx.modal_end() { + state.file_picker_overwrite_warning = None; + } + + if save { + doit = state.file_picker_overwrite_warning.take(); + } + } + + if let Some(path) = doit { + let res = if state.wants_file_picker == StateFilePicker::SaveAs { + state.documents.save_active(Some(&path)) + } else { + state.documents.add_file_path(&path).map(|_| ()) + }; + match res { + Ok(..) => { + ctx.needs_rerender(); + state.wants_file_picker = StateFilePicker::None; + } + Err(err) => error_log_add(ctx, state, err), + } + } +} + +// Returns Some(path) if the path refers to a file. +fn draw_file_picker_update_path(state: &mut State) -> Option { + let path = state.file_picker_pending_dir.as_path(); + let path = path.join(&state.file_picker_pending_name); + let mut normalized = PathBuf::new(); + + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => _ = normalized.pop(), + _ => normalized.push(c.as_os_str()), + } + } + + let (dir, name) = if normalized.is_dir() { + (normalized.as_path(), String::new()) + } else { + let dir = normalized.parent().unwrap_or(&normalized); + let name = DocumentManager::get_filename_from_path(&normalized); + (dir, name) + }; + if dir != state.file_picker_pending_dir.as_path() { + state.file_picker_pending_dir = DisplayablePathBuf::new(dir.to_path_buf()); + state.file_picker_entries = None; + } + + state.file_picker_pending_name = name; + if state.file_picker_pending_name.is_empty() { + None + } else { + Some(normalized) + } +} + +fn draw_dialog_saveas_refresh_files(state: &mut State) { + let dir = state.file_picker_pending_dir.as_path(); + let mut files = Vec::new(); + + if dir.parent().is_some() { + files.push(DisplayablePathBuf::from("..")); + } + + if let Ok(iter) = std::fs::read_dir(dir) { + for entry in iter.flatten() { + if let Ok(metadata) = entry.metadata() { + let mut name = entry.file_name(); + if metadata.is_dir() { + name.push("/"); + } + files.push(DisplayablePathBuf::from(name)); + } + } + } + + // Sort directories first, then by name, case-insensitive. + files[1..].sort_by(|a, b| { + let a = a.as_bytes(); + let b = b.as_bytes(); + + let a_is_dir = a.last() == Some(&b'/'); + let b_is_dir = b.last() == Some(&b'/'); + + match b_is_dir.cmp(&a_is_dir) { + Ordering::Equal => icu::compare_strings(a, b), + other => other, + } + }); + + state.file_picker_entries = Some(files); +} diff --git a/src/bin/edit/draw_menubar.rs b/src/bin/edit/draw_menubar.rs new file mode 100644 index 0000000..f73835c --- /dev/null +++ b/src/bin/edit/draw_menubar.rs @@ -0,0 +1,157 @@ +use crate::loc::*; +use crate::state::*; +use edit::helpers::*; +use edit::input::{kbmod, vk}; +use edit::tui::*; + +pub fn draw_menubar(ctx: &mut Context, state: &mut State) { + ctx.menubar_begin(); + ctx.attr_background_rgba(state.menubar_color_bg); + ctx.attr_foreground_rgba(state.menubar_color_fg); + { + if ctx.menubar_menu_begin(loc(LocId::File), 'F') { + draw_menu_file(ctx, state); + } + if state.documents.active().is_some() && ctx.menubar_menu_begin(loc(LocId::Edit), 'E') { + draw_menu_edit(ctx, state); + } + if ctx.menubar_menu_begin(loc(LocId::View), 'V') { + draw_menu_view(ctx, state); + } + if ctx.menubar_menu_begin(loc(LocId::Help), 'H') { + draw_menu_help(ctx, state); + } + } + ctx.menubar_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); + } + if ctx.menubar_menu_button(loc(LocId::FileOpen), 'O', kbmod::CTRL | vk::O) { + state.wants_file_picker = StateFilePicker::Open; + } + if ctx.menubar_menu_button(loc(LocId::FileSave), 'S', kbmod::CTRL | vk::S) { + state.wants_save = true; + } + if ctx.menubar_menu_button(loc(LocId::FileSaveAs), 'A', vk::NULL) { + state.wants_file_picker = StateFilePicker::SaveAs; + } + if state.documents.active().is_some() + && ctx.menubar_menu_button(loc(LocId::FileClose), 'C', kbmod::CTRL | vk::W) + { + state.wants_close = true; + } + if ctx.menubar_menu_button(loc(LocId::FileExit), 'X', kbmod::CTRL | vk::Q) { + state.wants_exit = true; + } + ctx.menubar_menu_end(); +} + +fn draw_menu_edit(ctx: &mut Context, state: &mut State) { + let doc = state.documents.active().unwrap(); + let mut tb = doc.buffer.borrow_mut(); + + if ctx.menubar_menu_button(loc(LocId::EditUndo), 'U', kbmod::CTRL | vk::Z) { + tb.undo(); + ctx.needs_rerender(); + } + if ctx.menubar_menu_button(loc(LocId::EditRedo), 'R', kbmod::CTRL | vk::Y) { + tb.redo(); + ctx.needs_rerender(); + } + if ctx.menubar_menu_button(loc(LocId::EditCut), 'T', kbmod::CTRL | vk::X) { + ctx.set_clipboard(tb.extract_selection(true)); + } + if ctx.menubar_menu_button(loc(LocId::EditCopy), 'C', kbmod::CTRL | vk::C) { + ctx.set_clipboard(tb.extract_selection(false)); + } + if ctx.menubar_menu_button(loc(LocId::EditPaste), 'P', kbmod::CTRL | vk::V) { + tb.write(ctx.get_clipboard(), true); + ctx.needs_rerender(); + } + if state.wants_search.kind != StateSearchKind::Disabled { + if ctx.menubar_menu_button(loc(LocId::EditFind), 'F', kbmod::CTRL | vk::F) { + state.wants_search.kind = StateSearchKind::Search; + state.wants_search.focus = true; + } + if ctx.menubar_menu_button(loc(LocId::EditReplace), 'R', kbmod::CTRL | vk::R) { + state.wants_search.kind = StateSearchKind::Replace; + state.wants_search.focus = true; + } + } + ctx.menubar_menu_end(); +} + +fn draw_menu_view(ctx: &mut Context, state: &mut State) { + if ctx.menubar_menu_button(loc(LocId::ViewFocusStatusbar), 'S', vk::NULL) { + state.wants_statusbar_focus = true; + } + + if let Some(doc) = state.documents.active() { + let mut tb = doc.buffer.borrow_mut(); + let word_wrap = tb.is_word_wrap_enabled(); + + if ctx.menubar_menu_checkbox(loc(LocId::ViewWordWrap), 'W', kbmod::ALT | vk::Z, word_wrap) { + tb.set_word_wrap(!word_wrap); + ctx.needs_rerender(); + } + } + + ctx.menubar_menu_end(); +} + +fn draw_menu_help(ctx: &mut Context, state: &mut State) { + if ctx.menubar_menu_button(loc(LocId::HelpAbout), 'A', vk::NULL) { + state.wants_about = true; + } + ctx.menubar_menu_end(); +} + +pub fn draw_dialog_about(ctx: &mut Context, state: &mut State) { + ctx.modal_begin("about", loc(LocId::AboutDialogTitle)); + { + ctx.block_begin("content"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(1, 2, 1)); + { + ctx.label("description", Overflow::TruncateTail, "Microsoft Edit"); + ctx.attr_position(Position::Center); + + ctx.label( + "version", + Overflow::TruncateHead, + &format!( + "{}{}", + loc(LocId::AboutDialogVersion), + env!("CARGO_PKG_VERSION") + ), + ); + ctx.attr_position(Position::Center); + + ctx.label( + "copyright", + Overflow::TruncateTail, + "Copyright (c) Microsoft Corp 2025", + ); + ctx.attr_position(Position::Center); + + ctx.block_begin("choices"); + ctx.inherit_focus(); + ctx.attr_padding(Rect::three(1, 2, 0)); + ctx.attr_position(Position::Center); + { + if ctx.button("ok", Overflow::Clip, loc(LocId::Ok)) { + state.wants_about = false; + } + ctx.inherit_focus(); + } + ctx.block_end(); + } + ctx.block_end(); + } + if ctx.modal_end() { + state.wants_about = false; + } +} diff --git a/src/bin/edit/draw_statusbar.rs b/src/bin/edit/draw_statusbar.rs new file mode 100644 index 0000000..059378c --- /dev/null +++ b/src/bin/edit/draw_statusbar.rs @@ -0,0 +1,326 @@ +use std::ptr; + +use crate::documents::*; +use crate::loc::*; +use crate::state::*; +use edit::framebuffer::IndexedColor; +use edit::helpers::*; +use edit::icu; +use edit::input::vk; +use edit::tui::*; + +pub fn draw_statusbar(ctx: &mut Context, state: &mut State) { + ctx.table_begin("statusbar"); + ctx.attr_background_rgba(state.menubar_color_bg); + ctx.attr_foreground_rgba(state.menubar_color_fg); + ctx.table_set_cell_gap(Size { + width: 2, + height: 0, + }); + ctx.attr_intrinsic_size(Size { + width: COORD_TYPE_SAFE_MAX, + height: 1, + }); + ctx.attr_padding(Rect::two(0, 1)); + + if let Some(doc) = state.documents.active() { + let mut tb = doc.buffer.borrow_mut(); + + ctx.table_next_row(); + + if ctx.button( + "newline", + Overflow::Clip, + if tb.is_crlf() { "CRLF" } else { "LF" }, + ) { + let is_crlf = tb.is_crlf(); + tb.normalize_newlines(!is_crlf); + } + if state.wants_statusbar_focus { + state.wants_statusbar_focus = false; + ctx.steal_focus(); + } + + state.wants_encoding_picker |= ctx.button("encoding", Overflow::Clip, tb.encoding()); + if state.wants_encoding_picker { + if matches!(&doc.path, DocumentPath::Canonical(_)) { + ctx.block_begin("frame"); + ctx.attr_float(FloatSpec { + anchor: Anchor::Last, + gravity_x: 0.0, + gravity_y: 1.0, + offset_x: 0, + offset_y: 0, + }); + ctx.attr_padding(Rect::two(0, 1)); + ctx.attr_border(); + { + ctx.list_begin("options"); + ctx.focus_on_first_present(); + { + if ctx.list_item(false, Overflow::Clip, loc(LocId::EncodingReopen)) + == ListSelection::Activated + { + state.wants_encoding_change = StateEncodingChange::Reopen; + } + if ctx.list_item(false, Overflow::Clip, loc(LocId::EncodingConvert)) + == ListSelection::Activated + { + state.wants_encoding_change = StateEncodingChange::Convert; + } + } + ctx.list_end(); + } + ctx.block_end(); + } else { + // Can't reopen a file that doesn't exist. + state.wants_encoding_change = StateEncodingChange::Convert; + } + + if !ctx.contains_focus() { + state.wants_encoding_picker = false; + ctx.needs_rerender(); + } + } + + state.wants_indentation_picker |= ctx.button( + "indentation", + Overflow::Clip, + &format!( + "{}:{}", + loc(if tb.indent_with_tabs() { + LocId::IndentationTabs + } else { + LocId::IndentationSpaces + }), + tb.tab_size(), + ), + ); + if state.wants_indentation_picker { + ctx.table_begin("indentation-picker"); + ctx.attr_float(FloatSpec { + anchor: Anchor::Last, + gravity_x: 0.0, + gravity_y: 1.0, + offset_x: 0, + offset_y: 0, + }); + ctx.attr_border(); + ctx.attr_padding(Rect::two(0, 1)); + ctx.table_set_cell_gap(Size { + width: 1, + height: 0, + }); + { + if ctx.consume_shortcut(vk::RETURN) { + ctx.toss_focus_up(); + } + + ctx.table_next_row(); + + ctx.list_begin("type"); + ctx.focus_on_first_present(); + ctx.attr_padding(Rect::two(0, 1)); + { + if ctx.list_item( + tb.indent_with_tabs(), + Overflow::Clip, + loc(LocId::IndentationTabs), + ) != ListSelection::Unchanged + { + tb.set_indent_with_tabs(true); + ctx.needs_rerender(); + } + if ctx.list_item( + !tb.indent_with_tabs(), + Overflow::Clip, + loc(LocId::IndentationSpaces), + ) != ListSelection::Unchanged + { + tb.set_indent_with_tabs(false); + ctx.needs_rerender(); + } + } + ctx.list_end(); + + ctx.list_begin("width"); + ctx.attr_padding(Rect::two(0, 2)); + { + for width in 1u8..=8 { + let ch = [b'0' + width]; + let label = unsafe { std::str::from_utf8_unchecked(&ch) }; + + if ctx.list_item(tb.tab_size() == width as i32, Overflow::Clip, label) + != ListSelection::Unchanged + { + tb.set_tab_size(width as i32); + ctx.needs_rerender(); + } + } + } + ctx.list_end(); + } + ctx.table_end(); + + if !ctx.contains_focus() { + state.wants_indentation_picker = false; + ctx.needs_rerender(); + } + } + + ctx.label( + "location", + Overflow::Clip, + &format!( + "{}:{}", + tb.get_cursor_logical_pos().y + 1, + tb.get_cursor_logical_pos().x + 1 + ), + ); + + #[cfg(any(feature = "debug-layout", feature = "debug-latency"))] + ctx.label( + "stats", + Overflow::Clip, + &format!( + "{}/{}", + tb.get_logical_line_count(), + tb.get_visual_line_count(), + ), + ); + + if tb.is_overtype() && ctx.button("overtype", Overflow::Clip, "OVR") { + tb.set_overtype(false); + ctx.needs_rerender(); + } + + if tb.is_dirty() { + ctx.label("dirty", Overflow::Clip, "*"); + } + + ctx.block_begin("filename-container"); + ctx.attr_intrinsic_size(Size { + width: COORD_TYPE_SAFE_MAX, + height: 1, + }); + { + let total = state.documents.len(); + let mut filename = doc.filename.as_str(); + let filename_buf; + + if total > 1 { + filename_buf = format!("{} + {}", filename, total - 1); + filename = &filename_buf; + } + + state.wants_document_picker |= + ctx.button("filename", Overflow::TruncateMiddle, filename); + ctx.attr_position(Position::Right); + } + ctx.block_end(); + } + + ctx.table_end(); +} + +pub fn draw_dialog_encoding_change(ctx: &mut Context, state: &mut State) { + let doc = state.documents.active().unwrap(); + let reopen = state.wants_encoding_change == StateEncodingChange::Reopen; + let width = (ctx.size().width - 20).max(10); + let height = (ctx.size().height - 10).max(10); + let mut change = None; + + ctx.modal_begin( + "encode", + if reopen { + loc(LocId::EncodingReopen) + } else { + loc(LocId::EncodingConvert) + }, + ); + { + ctx.scrollarea_begin("scrollarea", Size { width, height }); + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 0x3f)); + ctx.inherit_focus(); + { + let encodings = icu::get_available_encodings(); + + ctx.list_begin("encodings"); + ctx.inherit_focus(); + for encoding in encodings { + if ctx.list_item( + encoding.as_str() == doc.buffer.borrow().encoding(), + Overflow::Clip, + encoding.as_str(), + ) == ListSelection::Activated + { + change = Some(encoding); + break; + } + } + ctx.list_end(); + } + ctx.scrollarea_end(); + } + if ctx.modal_end() { + state.wants_encoding_change = StateEncodingChange::None; + } + + if let Some(encoding) = change { + let mut tb = doc.buffer.borrow_mut(); + + if reopen && let DocumentPath::Canonical(path) = &doc.path { + let mut res = Ok(()); + if tb.is_dirty() { + res = tb.write_file(path); + } + if res.is_ok() { + res = tb.read_file_path(path, Some(encoding.as_str())); + } + if let Err(err) = res { + drop(tb); + error_log_add(ctx, state, err); + } + } else { + tb.set_encoding(encoding.as_str()); + } + + state.wants_encoding_change = StateEncodingChange::None; + ctx.needs_rerender(); + } +} + +pub fn draw_document_picker(ctx: &mut Context, state: &mut State) { + ctx.modal_begin("document-picker", ""); + { + let width = (ctx.size().width - 20).max(10); + let height = (ctx.size().height - 10).max(10); + + ctx.scrollarea_begin("scrollarea", Size { width, height }); + ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 0x3f)); + ctx.inherit_focus(); + { + ctx.list_begin("documents"); + ctx.inherit_focus(); + + let active = opt_ptr(state.documents.active()); + + if state.documents.update_active(|doc| { + ctx.list_item( + ptr::eq(doc, active), + Overflow::TruncateMiddle, + &doc.filename, + ) == ListSelection::Activated + }) { + state.wants_document_picker = false; + ctx.needs_rerender(); + } + + ctx.list_end(); + } + ctx.scrollarea_end(); + } + if ctx.modal_end() { + state.wants_document_picker = false; + } +} diff --git a/src/loc.rs b/src/bin/edit/loc.rs similarity index 94% rename from src/loc.rs rename to src/bin/edit/loc.rs index ec9359a..4eadb81 100644 --- a/src/loc.rs +++ b/src/bin/edit/loc.rs @@ -6,6 +6,7 @@ pub enum LocId { Alt, Shift, + Ok, Yes, No, @@ -13,9 +14,11 @@ pub enum LocId { // File menu File, + FileNew, FileOpen, FileSave, FileSaveAs, + FileClose, FileExit, // Edit menu @@ -137,6 +140,20 @@ const S_LANG_LUT: [[&str; LangId::Count as usize]; LocId::Count as usize] = [ /* zh_hant */ "Shift", ], + // Ok + [ + /* en */ "Ok", + /* de */ "OK", + /* es */ "Aceptar", + /* fr */ "OK", + /* it */ "OK", + /* ja */ "OK", + /* ko */ "확인", + /* pt_br */ "OK", + /* ru */ "ОК", + /* zh_hans */ "确定", + /* zh_hant */ "確定", + ], // Yes [ /* en */ "Yes", @@ -195,6 +212,20 @@ const S_LANG_LUT: [[&str; LangId::Count as usize]; LocId::Count as usize] = [ /* zh_hans */ "文件", /* zh_hant */ "檔案", ], + // FileNew + [ + /* en */ "New File…", + /* de */ "Neue Datei…", + /* es */ "Nuevo archivo…", + /* fr */ "Nouveau fichier…", + /* it */ "Nuovo file…", + /* ja */ "新規ファイル…", + /* ko */ "새 파일…", + /* pt_br */ "Novo arquivo…", + /* ru */ "Новый файл…", + /* zh_hans */ "新建文件…", + /* zh_hant */ "新增檔案…", + ], // FileOpen [ /* en */ "Open File…", @@ -237,6 +268,20 @@ const S_LANG_LUT: [[&str; LangId::Count as usize]; LocId::Count as usize] = [ /* zh_hans */ "另存为…", /* zh_hant */ "另存新檔…", ], + // FileClose + [ + /* en */ "Close Editor", + /* de */ "Editor schließen", + /* es */ "Cerrar editor", + /* fr */ "Fermer l'éditeur", + /* it */ "Chiudi editor", + /* ja */ "エディターを閉じる", + /* ko */ "편집기 닫기", + /* pt_br */ "Fechar editor", + /* ru */ "Закрыть редактор", + /* zh_hans */ "关闭编辑器", + /* zh_hant */ "關閉編輯器", + ], // FileExit [ /* en */ "Exit", diff --git a/src/bin/edit/main.rs b/src/bin/edit/main.rs new file mode 100644 index 0000000..255d2e9 --- /dev/null +++ b/src/bin/edit/main.rs @@ -0,0 +1,520 @@ +#![feature(linked_list_cursors, os_string_truncate)] + +mod documents; +mod draw_editor; +mod draw_filepicker; +mod draw_menubar; +mod draw_statusbar; +mod loc; +mod state; + +use documents::DocumentPath; +use draw_editor::*; +use draw_filepicker::*; +use draw_menubar::*; +use draw_statusbar::*; +use edit::apperr; +use edit::base64; +use edit::buffer::TextBuffer; +use edit::framebuffer::{self, IndexedColor, alpha_blend}; +use edit::helpers::*; +use edit::input::{self, kbmod, vk}; +use edit::sys; +use edit::tui::*; +use edit::vt::{self, Token}; +use loc::*; +use state::*; +use std::path::PathBuf; +use std::process; + +#[cfg(feature = "debug-latency")] +use std::fmt::Write; + +impl State { + fn new() -> apperr::Result { + let buffer = TextBuffer::new_rc(false)?; + { + let mut tb = buffer.borrow_mut(); + tb.set_margin_enabled(true); + tb.set_line_highlight_enabled(true); + } + + Ok(Self { + menubar_color_bg: 0, + menubar_color_fg: 0, + + documents: Default::default(), + + error_log: [const { String::new() }; 10], + error_log_index: 0, + error_log_count: 0, + + wants_file_picker: StateFilePicker::None, + file_picker_pending_dir: Default::default(), + file_picker_pending_name: Default::default(), + file_picker_entries: None, + file_picker_overwrite_warning: None, + + wants_search: StateSearch { + kind: StateSearchKind::Hidden, + focus: false, + }, + search_needle: Default::default(), + search_replacement: Default::default(), + search_options: Default::default(), + search_success: true, + + wants_save: false, + wants_statusbar_focus: false, + wants_encoding_picker: false, + wants_encoding_change: StateEncodingChange::None, + wants_indentation_picker: false, + wants_document_picker: false, + wants_about: false, + wants_close: false, + wants_exit: false, + + osc_title_filename: Default::default(), + osc_clipboard_generation: 0, + exit: false, + }) + } +} + +fn main() -> process::ExitCode { + if cfg!(debug_assertions) { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + drop(RestoreModes); + drop(sys::Deinit); + hook(info); + })); + } + + match run() { + Ok(()) => process::ExitCode::SUCCESS, + Err(err) => { + sys::write_stdout(&format!("{}\r\n", FormatApperr::from(err))); + process::ExitCode::FAILURE + } + } +} + +fn run() -> apperr::Result<()> { + let _sys_deinit = sys::init()?; + let mut state = State::new()?; + + if handle_args(&mut state)? { + return Ok(()); + } + + loc::init(); + + // sys::init() will switch the terminal to raw mode which prevents the user from pressing Ctrl+C. + // Since the `read_file` call may hang for some reason, we must only call this afterwards. + // `set_modes()` will enable mouse mode which is equally annoying to switch out for users + // and so we do it afterwards, for similar reasons. + sys::switch_modes()?; + let _restore_vt_modes = set_vt_modes(); + + let mut vt_parser = vt::Parser::new(); + let mut input_parser = input::Parser::new(); + let mut tui = Tui::new()?; + + query_color_palette(&mut tui, &mut vt_parser); + state.menubar_color_bg = alpha_blend( + tui.indexed(IndexedColor::Background), + tui.indexed_alpha(IndexedColor::BrightBlue, 0x7f), + ); + state.menubar_color_fg = tui.contrasted(state.menubar_color_bg); + let floater_bg = alpha_blend( + tui.indexed_alpha(IndexedColor::Background, 0xcc), + tui.indexed_alpha(IndexedColor::Foreground, 0x33), + ); + let floater_fg = tui.contrasted(floater_bg); + 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(); + + #[cfg(feature = "debug-latency")] + let mut last_latency_width = 0; + + loop { + let read_timeout = vt_parser.read_timeout().min(tui.read_timeout()); + let Some(input) = sys::read_stdin(read_timeout) else { + break; + }; + + #[cfg(feature = "debug-latency")] + let time_beg = std::time::Instant::now(); + #[cfg(feature = "debug-latency")] + let mut passes = 0usize; + + let vt_iter = vt_parser.parse(&input); + let mut input_iter = input_parser.parse(vt_iter); + + // Process all input. + while { + let input = input_iter.next(); + let more = input.is_some(); + let mut ctx = tui.create_context(input); + + draw(&mut ctx, &mut state); + + #[cfg(feature = "debug-latency")] + { + passes += 1; + } + + more + } {} + + // Continue rendering until the layout has settled. + // This can take >1 frame, if the input focus is tossed between different controls. + while tui.needs_settling() { + let mut ctx = tui.create_context(None); + + draw(&mut ctx, &mut state); + + #[cfg(feature = "debug-layout")] + { + drop(ctx); + state + .buffer + .buffer + .debug_replace_everything(&tui.debug_layout()); + } + + #[cfg(feature = "debug-latency")] + { + passes += 1; + } + } + + if state.exit { + break; + } + + let mut output = tui.render(); + + { + let filename = state.documents.active().map_or("", |d| &d.filename); + if filename != state.osc_title_filename { + write_terminal_title(&mut output, filename); + state.osc_title_filename = filename.to_string(); + } + } + + if state.osc_clipboard_generation != tui.get_clipboard_generation() { + write_osc_clipboard(&mut output, &mut state, &tui); + } + + #[cfg(feature = "debug-latency")] + { + // Print the number of passes and latency in the top right corner. + let time_end = std::time::Instant::now(); + let status = time_end - time_beg; + let status = format!( + "{}P {}B {:.3}μs", + passes, + output.len(), + status.as_nanos() as f64 / 1000.0 + ); + + // "μs" is 3 bytes and 2 columns. + let cols = status.len() as i32 - 3 + 2; + + // Since the status may shrink and grow, we may have to overwrite the previous one with whitespace. + let padding = (last_latency_width - cols).max(0); + + // To avoid moving the cursor, push and pop it onto the VT cursor stack. + _ = write!( + output, + "\x1b7\x1b[0;41;97m\x1b[1;{0}H{1:2$}{3}\x1b8", + tui.size().width - cols - padding + 1, + "", + padding as usize, + status + ); + + last_latency_width = cols; + sys::write_stdout(&output); + } + + sys::write_stdout(&output); + } + + Ok(()) +} + +// Returns true if the application should exit early. +fn handle_args(state: &mut State) -> apperr::Result { + let mut path = None; + + // The best CLI argument parser in the world. + if let Some(arg) = std::env::args_os().nth(1) { + if arg == "-h" || arg == "--help" || (cfg!(windows) && arg == "/?") { + print_help(); + return Ok(true); + } else if arg == "-v" || arg == "--version" { + print_version(); + return Ok(true); + } else if arg == "-" { + // We'll check for a redirected stdin no matter what, so we can just ignore "-". + } else { + path = Some(PathBuf::from(arg)); + } + } + + let doc; + if let Some(mut file) = sys::open_stdin_if_redirected() { + doc = state.documents.add_untitled()?; + let mut tb = doc.buffer.borrow_mut(); + tb.read_file(&mut file, None)?; + tb.mark_as_dirty(); + } else if let Some(path) = path { + doc = state.documents.add_file_path(&path)?; + } else { + doc = state.documents.add_untitled()?; + } + + let cwd = match &doc.path { + DocumentPath::Canonical(path) => path.parent(), + _ => None, + }; + let cwd = match cwd { + Some(cwd) => cwd.to_path_buf(), + None => std::env::current_dir()?, + }; + state.file_picker_pending_dir = DisplayablePathBuf::new(cwd); + state.file_picker_pending_name = doc.filename.clone(); + + Ok(false) +} + +fn print_help() { + sys::write_stdout(concat!( + "Usage: edit [OPTIONS] [FILE]\r\n", + "Options:\r\n", + " -h, --help Print this help message\r\n", + " -v, --version Print the version number\r\n", + )); +} + +fn print_version() { + sys::write_stdout(concat!("edit version ", env!("CARGO_PKG_VERSION"), "\r\n")); +} + +fn draw(ctx: &mut Context, state: &mut State) { + let root_focused = ctx.contains_focus(); + + draw_menubar(ctx, state); + draw_editor(ctx, state); + draw_statusbar(ctx, state); + + if state.wants_exit { + draw_handle_wants_exit(ctx, state); + } + if state.wants_close { + draw_handle_wants_close(ctx, state); + } + if state.wants_file_picker != StateFilePicker::None { + draw_file_picker(ctx, state); + } + if state.wants_save { + draw_handle_save(ctx, state); + } + if state.wants_encoding_change != StateEncodingChange::None { + draw_dialog_encoding_change(ctx, state); + } + if state.wants_document_picker { + draw_document_picker(ctx, state); + } + if state.wants_about { + draw_dialog_about(ctx, state); + } + if state.error_log_count != 0 { + draw_error_log(ctx, state); + } + + if root_focused { + // Shortcuts that are not handled as part of the textarea, etc. + if ctx.consume_shortcut(kbmod::CTRL | vk::N) { + draw_add_untitled_document(ctx, state); + } else if ctx.consume_shortcut(kbmod::CTRL | vk::O) { + state.wants_file_picker = StateFilePicker::Open; + } else if ctx.consume_shortcut(kbmod::CTRL | vk::S) { + state.wants_save = true; + } else if ctx.consume_shortcut(kbmod::CTRL_SHIFT | vk::S) { + state.wants_file_picker = StateFilePicker::SaveAs; + } else if ctx.consume_shortcut(kbmod::CTRL | vk::W) { + state.wants_close = true; + } else if ctx.consume_shortcut(kbmod::CTRL | vk::P) { + state.wants_document_picker = true; + } else if ctx.consume_shortcut(kbmod::CTRL | vk::Q) { + state.wants_exit = true; + } else if state.wants_search.kind != StateSearchKind::Disabled + && ctx.consume_shortcut(kbmod::CTRL | vk::F) + { + state.wants_search.kind = StateSearchKind::Search; + state.wants_search.focus = true; + } else if state.wants_search.kind != StateSearchKind::Disabled + && ctx.consume_shortcut(kbmod::CTRL | vk::R) + { + state.wants_search.kind = StateSearchKind::Replace; + state.wants_search.focus = true; + } + } +} + +fn draw_handle_wants_exit(_ctx: &mut Context, state: &mut State) { + while let Some(doc) = state.documents.active() { + if doc.buffer.borrow().is_dirty() { + state.wants_close = true; + return; + } + state.documents.remove_active(); + } + + if state.documents.len() == 0 { + state.exit = true; + } +} + +fn set_vt_modes() -> RestoreModes { + // 1049: Alternative Screen Buffer + // I put the ASB switch in the beginning, just in case the terminal performs + // some additional state tracking beyond the modes we enable/disable. + // 1002: Cell Motion Mouse Tracking + // 1006: SGR Mouse Mode + // 2004: Bracketed Paste Mode + sys::write_stdout("\x1b[?1049h\x1b[?1002;1006;2004h"); + RestoreModes +} + +#[cold] +fn write_terminal_title(output: &mut String, filename: &str) { + output.push_str("\x1b]0;"); + + if !filename.is_empty() { + output.push_str(&sanitize_control_chars(filename)); + output.push_str(" - "); + } + + output.push_str("edit\x1b\\"); +} + +#[cold] +fn write_osc_clipboard(output: &mut String, state: &mut State, tui: &Tui) { + let clipboard = tui.get_clipboard(); + + if (1..128 * 1024).contains(&clipboard.len()) { + output.push_str("\x1b]52;c;"); + output.push_str(&base64::encode(clipboard)); + output.push_str("\x1b\\"); + } + + state.osc_clipboard_generation = tui.get_clipboard_generation(); +} + +struct RestoreModes; + +impl Drop for RestoreModes { + fn drop(&mut self) { + // Same as in the beginning but in the reverse order. + // It also includes DECSCUSR 0 to reset the cursor style and DECTCEM to show the cursor. + sys::write_stdout("\x1b[0 q\x1b[?25h\x1b]0;\x07\x1b[?1002;1006;2004l\x1b[?1049l"); + } +} + +fn query_color_palette(tui: &mut Tui, vt_parser: &mut vt::Parser) { + let mut indexed_colors = framebuffer::DEFAULT_THEME; + + sys::write_stdout(concat!( + // OSC 4 color table requests for indices 0 through 15 (base colors). + "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?\x07", + "\x1b]4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07", + // OSC 10 and 11 queries for the current foreground and background colors. + "\x1b]10;?\x07\x1b]11;?\x07", + // CSI c reports the terminal capabilities. + // It also helps us to detect the end of the responses, because not all + // terminals support the OSC queries, but all of them support CSI c. + "\x1b[c", + )); + + let mut done = false; + let mut osc_buffer = String::new(); + + while !done { + let Some(input) = sys::read_stdin(vt_parser.read_timeout()) else { + break; + }; + + let mut vt_stream = vt_parser.parse(&input); + while let Some(token) = vt_stream.next() { + match token { + Token::Csi(state) if state.final_byte == 'c' => done = true, + Token::Osc { mut data, partial } => { + if partial { + osc_buffer.push_str(data); + continue; + } + if !osc_buffer.is_empty() { + osc_buffer.push_str(data); + data = &osc_buffer; + } + + let mut splits = data.split_terminator(';'); + + let color = match splits.next().unwrap_or("") { + // The response is `4;;rgb://`. + "4" => match splits.next().unwrap_or("").parse::() { + Ok(val) if val < 16 => &mut indexed_colors[val], + _ => continue, + }, + // The response is `10;rgb://`. + "10" => &mut indexed_colors[IndexedColor::Foreground as usize], + // The response is `11;rgb://`. + "11" => &mut indexed_colors[IndexedColor::Background as usize], + _ => continue, + }; + + let color_param = splits.next().unwrap_or(""); + if !color_param.starts_with("rgb:") { + continue; + } + + let mut iter = color_param[4..].split_terminator('/'); + let rgb_parts = [(); 3].map(|_| iter.next().unwrap_or("0")); + let mut rgb = 0; + + for part in rgb_parts { + if part.len() == 2 || part.len() == 4 { + let Ok(mut val) = usize::from_str_radix(part, 16) else { + continue; + }; + if part.len() == 4 { + val = (val * 0xff + 0x80) / 0xffff; + } + rgb = (rgb >> 8) | ((val as u32) << 16); + } + } + + *color = rgb | 0xff000000; + osc_buffer.clear(); + } + _ => {} + } + } + } + + tui.setup_indexed_colors(indexed_colors); +} diff --git a/src/bin/edit/state.rs b/src/bin/edit/state.rs new file mode 100644 index 0000000..b16864a --- /dev/null +++ b/src/bin/edit/state.rs @@ -0,0 +1,143 @@ +use crate::documents::DocumentManager; +use crate::loc::*; +use edit::framebuffer::IndexedColor; +use edit::helpers::*; +use edit::icu; +use edit::sys; +use edit::tui::*; +use edit::{apperr, buffer}; +use std::path::PathBuf; + +#[repr(transparent)] +pub struct FormatApperr(apperr::Error); + +impl From for FormatApperr { + fn from(err: apperr::Error) -> Self { + FormatApperr(err) + } +} + +impl std::fmt::Display for FormatApperr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + apperr::APP_ICU_MISSING => f.write_str(loc(LocId::ErrorIcuMissing)), + apperr::Error::App(code) => write!(f, "Unknown app error code: {code}"), + apperr::Error::Icu(code) => icu::apperr_format(f, code), + apperr::Error::Sys(code) => sys::apperr_format(f, code), + } + } +} + +pub struct StateSearch { + pub kind: StateSearchKind, + pub focus: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StateSearchKind { + Hidden, + Disabled, + Search, + Replace, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StateFilePicker { + None, + Open, + SaveAs, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum StateEncodingChange { + None, + Convert, + Reopen, +} + +pub struct State { + pub menubar_color_bg: u32, + pub menubar_color_fg: u32, + + pub documents: DocumentManager, + + // A ring buffer of the last 10 errors. + pub error_log: [String; 10], + pub error_log_index: usize, + pub error_log_count: usize, + + pub wants_file_picker: StateFilePicker, + pub file_picker_pending_dir: DisplayablePathBuf, + pub file_picker_pending_name: String, // This could be PathBuf, if `tui` would expose its TextBuffer for editline. + pub file_picker_entries: Option>, + pub file_picker_overwrite_warning: Option, // The path the warning is about. + + pub wants_search: StateSearch, + pub search_needle: String, + pub search_replacement: String, + pub search_options: buffer::SearchOptions, + pub search_success: bool, + + pub wants_save: bool, + pub wants_statusbar_focus: bool, + pub wants_encoding_picker: bool, + pub wants_encoding_change: StateEncodingChange, + pub wants_indentation_picker: bool, + pub wants_document_picker: bool, + pub wants_about: bool, + pub wants_close: bool, + pub wants_exit: bool, + + pub osc_title_filename: String, + pub osc_clipboard_generation: u32, + pub exit: bool, +} + +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); + } +} + +pub fn error_log_add(ctx: &mut Context, state: &mut State, err: apperr::Error) { + let msg = format!("{}", FormatApperr::from(err)); + if !msg.is_empty() { + state.error_log[state.error_log_index] = msg; + state.error_log_index = (state.error_log_index + 1) % state.error_log.len(); + state.error_log_count = state.error_log.len().min(state.error_log_count + 1); + ctx.needs_rerender(); + } +} + +pub fn draw_error_log(ctx: &mut Context, state: &mut State) { + ctx.modal_begin("errors", "Error"); + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); + { + ctx.block_begin("content"); + ctx.attr_padding(Rect::three(0, 2, 1)); + { + let off = state.error_log_index + state.error_log.len() - state.error_log_count; + + for i in 0..state.error_log_count { + let idx = (off + i) % state.error_log.len(); + let msg = &state.error_log[idx][..]; + + if !msg.is_empty() { + ctx.next_block_id_mixin(i as u64); + ctx.label("error", Overflow::TruncateTail, msg); + } + } + } + ctx.block_end(); + + if ctx.button("ok", Overflow::Clip, "Ok") { + state.error_log_count = 0; + } + ctx.attr_position(Position::Center); + ctx.inherit_focus(); + } + if ctx.modal_end() { + state.error_log_count = 0; + } +} diff --git a/src/buffer.rs b/src/buffer.rs index 1707687..61e6d5c 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -511,6 +511,16 @@ impl TextBuffer { self.mark_as_clean(); } + pub fn read_file_path( + &mut self, + path: &Path, + encoding: Option<&'static str>, + ) -> apperr::Result<()> { + let mut file = File::open(path)?; + self.read_file(&mut file, encoding)?; + Ok(()) + } + /// Reads a file from disk into the text buffer, detecting encoding and BOM. pub fn read_file( &mut self, @@ -2281,6 +2291,7 @@ impl TextBuffer { } fn undo_redo(&mut self, undo: bool) { + // Transfer the last entry from the undo stack to the redo stack or vice versa. { let (from, to) = if undo { (&mut self.undo_stack, &mut self.redo_stack) @@ -2288,12 +2299,11 @@ impl TextBuffer { (&mut self.redo_stack, &mut self.undo_stack) }; - let len = from.len(); - if len == 0 { + let Some(list) = from.cursor_back_mut().remove_current_as_list() else { return; - } + }; - to.append(&mut from.split_off(len - 1)); + to.cursor_back_mut().splice_after(list); } let change = { @@ -2584,7 +2594,7 @@ impl GapBuffer { while beg < end { let chunk = self.read_forward(beg); let chunk = &chunk[..chunk.len().min(end - beg)]; - helpers::vec_insert_at(out, out_off, chunk); + helpers::vec_replace(out, out_off, 0, chunk); beg += chunk.len(); out_off += chunk.len(); } diff --git a/src/helpers.rs b/src/helpers.rs index 10e4e1e..eef171f 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -346,16 +346,18 @@ impl DisplayableCString { } } +#[inline(always)] +#[allow(clippy::ptr_eq)] +pub fn opt_ptr(a: Option<&T>) -> *const T { + unsafe { mem::transmute(a) } +} + /// Surprisingly, there's no way in Rust to do a `ptr::eq` on `Option<&T>`. /// Uses `unsafe` so that the debug performance isn't too bad. #[inline(always)] #[allow(clippy::ptr_eq)] pub fn opt_ptr_eq(a: Option<&T>, b: Option<&T>) -> bool { - unsafe { - let a: *const T = mem::transmute(a); - let b: *const T = mem::transmute(b); - a == b - } + opt_ptr(a) == opt_ptr(b) } /// Creates a `&str` from a pointer and a length. @@ -383,36 +385,35 @@ pub const unsafe fn str_from_raw_parts_mut<'a>(ptr: *mut u8, len: usize) -> &'a unsafe { str::from_utf8_unchecked_mut(slice::from_raw_parts_mut(ptr, len)) } } -pub fn vec_insert_at(dst: &mut Vec, off: usize, src: &[T]) { +pub fn vec_replace(dst: &mut Vec, off: usize, remove: usize, src: &[T]) { unsafe { let dst_len = dst.len(); let src_len = src.len(); - - // Make room for the new elements. NOTE that this must be done before - // we call as_mut_ptr, or else we risk accessing a stale pointer. - dst.reserve(src_len); - let off = off.min(dst_len); - let ptr = dst.as_mut_ptr().add(off); + let del_len = remove.min(dst_len - off); - if off < dst_len { - // Move the tail of the vector to make room for the new elements. - ptr::copy(ptr, ptr.add(src_len), dst_len - off); + if del_len == 0 && src_len == 0 { + return; // nothing to do } - // Copy the new elements into the vector. - ptr::copy_nonoverlapping(src.as_ptr(), ptr, src_len); - // Update the length of the vector. - dst.set_len(dst_len + src_len); - } -} + let tail_len = dst_len - off - del_len; + let new_len = dst_len - del_len + src_len; -// How many functions do you want stuck in unstable? Oh all of them? Okay. -pub fn string_from_utf8_lossy_owned(v: Vec) -> String { - if let Cow::Owned(string) = String::from_utf8_lossy(&v) { - string - } else { - unsafe { String::from_utf8_unchecked(v) } + if src_len > del_len { + dst.reserve(src_len - del_len); + } + + // SAFETY: as_mut_ptr() must called after reserve() to ensure that the pointer is valid. + let ptr = dst.as_mut_ptr().add(off); + + // Shift the tail. + if tail_len > 0 && src_len != del_len { + ptr::copy(ptr.add(del_len), ptr.add(src_len), tail_len); + } + + // Copy in the replacement. + ptr::copy_nonoverlapping(src.as_ptr(), ptr, src_len); + dst.set_len(new_len); } } @@ -421,6 +422,14 @@ pub fn vec_replace_all_reuse(dst: &mut Vec, src: &[T]) { dst.extend_from_slice(src); } +pub fn string_from_utf8_lossy_owned(v: Vec) -> String { + if let Cow::Owned(string) = String::from_utf8_lossy(&v) { + string + } else { + unsafe { String::from_utf8_unchecked(v) } + } +} + pub fn file_read_uninit( file: &mut T, buf: &mut [MaybeUninit], diff --git a/src/icu.rs b/src/icu.rs index 2a2fa3f..008b686 100644 --- a/src/icu.rs +++ b/src/icu.rs @@ -39,7 +39,7 @@ pub fn get_available_encodings() -> &'static [DisplayableCString] { } } -pub fn apperr_format(code: u32) -> String { +pub fn apperr_format(f: &mut std::fmt::Formatter<'_>, code: u32) -> std::fmt::Result { fn format(code: u32) -> &'static str { let Ok(f) = init_if_needed() else { return ""; @@ -57,9 +57,9 @@ pub fn apperr_format(code: u32) -> String { let msg = format(code); if !msg.is_empty() { - format!("ICU Error: {msg}") + write!(f, "ICU Error: {msg}") } else { - format!("ICU Error: {code:#08x}") + write!(f, "ICU Error: {code:#08x}") } } diff --git a/src/lib.rs b/src/lib.rs index af909d7..667b332 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -#![feature(allocator_api)] +#![feature(allocator_api, breakpoint, linked_list_cursors)] #![allow(clippy::missing_transmute_annotations, clippy::new_without_default)] #[macro_use] @@ -13,7 +13,6 @@ pub mod fuzzy; pub mod helpers; pub mod icu; pub mod input; -pub mod loc; pub mod simd; pub mod sys; pub mod tui; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index f127454..0000000 --- a/src/main.rs +++ /dev/null @@ -1,1678 +0,0 @@ -#![feature(os_string_truncate)] - -use edit::apperr; -use edit::base64; -use edit::buffer::{self, RcTextBuffer, TextBuffer}; -use edit::framebuffer::{self, IndexedColor, alpha_blend}; -use edit::helpers::*; -use edit::icu; -use edit::input::{self, kbmod, vk}; -use edit::loc::{self, LocId, loc}; -use edit::simd::memrchr2; -use edit::sys; -use edit::tui::*; -use edit::vt::{self, Token}; -use std::cmp; -use std::fs::File; -use std::path::{Component, Path, PathBuf}; -use std::process; - -#[cfg(feature = "debug-latency")] -use std::fmt::Write; - -struct StateSearch { - kind: StateSearchKind, - focus: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StateSearchKind { - Hidden, - Disabled, - Search, - Replace, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StateFilePicker { - None, - - Open, - // Internal state to handle the unsaved changes dialog. - OpenUnsavedChanges, // Show the unsaved changes dialog. - OpenForce, // Ignore any buffer dirty flags. - - Save, - SaveAs, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StateEncodingChange { - None, - Convert, - Reopen, -} - -struct State { - menubar_color_bg: u32, - menubar_color_fg: u32, - - path: Option, - filename: String, - buffer: RcTextBuffer, - - // A ring buffer of the last 10 errors. - error_log: [String; 10], - error_log_index: usize, - error_log_count: usize, - - wants_file_picker: StateFilePicker, - file_picker_pending_dir: DisplayablePathBuf, - file_picker_pending_name: String, // This could be PathBuf, if `tui` would expose its TextBuffer for editline. - file_picker_entries: Option>, - file_picker_overwrite_warning: Option, // The path the warning is about. - - wants_search: StateSearch, - search_needle: String, - search_replacement: String, - search_options: buffer::SearchOptions, - search_success: bool, - - wants_term_title_update: bool, - wants_statusbar_focus: bool, - wants_encoding_picker: bool, - wants_encoding_change: StateEncodingChange, - wants_indentation_picker: bool, - wants_about: bool, - wants_exit: bool, - - osc_clipboard_generation: u32, - exit: bool, -} - -impl State { - fn new() -> apperr::Result { - let buffer = TextBuffer::new_rc(false)?; - { - let mut tb = buffer.borrow_mut(); - tb.set_margin_enabled(true); - tb.set_line_highlight_enabled(true); - } - - Ok(Self { - menubar_color_bg: 0, - menubar_color_fg: 0, - - path: None, - filename: String::new(), - buffer, - - error_log: [const { String::new() }; 10], - error_log_index: 0, - error_log_count: 0, - - wants_file_picker: StateFilePicker::None, - file_picker_pending_dir: Default::default(), - file_picker_pending_name: String::new(), - file_picker_entries: None, - file_picker_overwrite_warning: None, - - wants_search: StateSearch { - kind: StateSearchKind::Hidden, - focus: false, - }, - search_needle: String::new(), - search_replacement: String::new(), - search_options: buffer::SearchOptions::default(), - search_success: true, - - wants_term_title_update: true, - wants_statusbar_focus: false, - wants_encoding_picker: false, - wants_encoding_change: StateEncodingChange::None, - wants_indentation_picker: false, - wants_about: false, - wants_exit: false, - - osc_clipboard_generation: 0, - exit: false, - }) - } - - fn set_path(&mut self, path: PathBuf, filename: String) { - debug_assert!(!filename.is_empty()); - let ruler = if filename == "COMMIT_EDITMSG" { 72 } else { 0 }; - self.buffer.borrow_mut().set_ruler(ruler); - self.filename = filename; - self.path = Some(path); - self.wants_term_title_update = true; - } -} - -fn main() -> process::ExitCode { - if cfg!(debug_assertions) { - let hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - drop(RestoreModes); - drop(sys::Deinit); - hook(info); - })); - } - - match run() { - Ok(()) => process::ExitCode::SUCCESS, - Err(err) => { - let mut msg = err.message(); - msg.push_str("\r\n"); - sys::write_stdout(&msg); - process::ExitCode::FAILURE - } - } -} - -fn run() -> apperr::Result<()> { - let _sys_deinit = sys::init()?; - let mut state = State::new()?; - - if handle_args(&mut state)? { - return Ok(()); - } - - loc::init(); - - // sys::init() will switch the terminal to raw mode which prevents the user from pressing Ctrl+C. - // Since the `read_file` call may hang for some reason, we must only call this afterwards. - // `set_modes()` will enable mouse mode which is equally annoying to switch out for users - // and so we do it afterwards, for similar reasons. - sys::switch_modes()?; - let _restore_vt_modes = set_vt_modes(); - - let mut vt_parser = vt::Parser::new(); - let mut input_parser = input::Parser::new(); - let mut tui = Tui::new()?; - - query_color_palette(&mut tui, &mut vt_parser); - state.menubar_color_bg = alpha_blend( - tui.indexed(IndexedColor::Background), - tui.indexed_alpha(IndexedColor::BrightBlue, 0x7f), - ); - state.menubar_color_fg = tui.contrasted(state.menubar_color_bg); - let floater_bg = alpha_blend( - tui.indexed_alpha(IndexedColor::Background, 0xcc), - tui.indexed_alpha(IndexedColor::Foreground, 0x33), - ); - let floater_fg = tui.contrasted(floater_bg); - 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(); - - #[cfg(feature = "debug-latency")] - let mut last_latency_width = 0; - - loop { - let read_timeout = vt_parser.read_timeout().min(tui.read_timeout()); - let Some(input) = sys::read_stdin(read_timeout) else { - break; - }; - - #[cfg(feature = "debug-latency")] - let time_beg = std::time::Instant::now(); - #[cfg(feature = "debug-latency")] - let mut passes = 0usize; - - let vt_iter = vt_parser.parse(&input); - let mut input_iter = input_parser.parse(vt_iter); - - // Process all input. - while { - let input = input_iter.next(); - let more = input.is_some(); - let mut ctx = tui.create_context(input); - - draw(&mut ctx, &mut state); - - #[cfg(feature = "debug-latency")] - { - passes += 1; - } - - more - } {} - - // Continue rendering until the layout has settled. - // This can take >1 frame, if the input focus is tossed between different controls. - while tui.needs_settling() { - let mut ctx = tui.create_context(None); - - draw(&mut ctx, &mut state); - - #[cfg(feature = "debug-layout")] - { - drop(ctx); - state.buffer.debug_replace_everything(&tui.debug_layout()); - } - - #[cfg(feature = "debug-latency")] - { - passes += 1; - } - } - - if state.exit { - break; - } - - let mut output = tui.render(); - - if state.wants_term_title_update { - write_terminal_title(&mut output, &mut state); - } - - if state.osc_clipboard_generation != tui.get_clipboard_generation() { - write_osc_clipboard(&mut output, &mut state, &tui); - } - - #[cfg(feature = "debug-latency")] - { - // Print the number of passes and latency in the top right corner. - let time_end = std::time::Instant::now(); - let status = time_end - time_beg; - let status = format!( - "{}P {}B {:.3}μs", - passes, - output.len(), - status.as_nanos() as f64 / 1000.0 - ); - - // "μs" is 3 bytes and 2 columns. - let cols = status.len() as i32 - 3 + 2; - - // Since the status may shrink and grow, we may have to overwrite the previous one with whitespace. - let padding = (last_latency_width - cols).max(0); - - // To avoid moving the cursor, push and pop it onto the VT cursor stack. - _ = write!( - output, - "\x1b7\x1b[0;41;97m\x1b[1;{0}H{1:2$}{3}\x1b8", - tui.size().width - cols - padding + 1, - "", - padding as usize, - status - ); - - last_latency_width = cols; - sys::write_stdout(&output); - } - - sys::write_stdout(&output); - } - - Ok(()) -} - -// Returns true if the application should exit early. -fn handle_args(state: &mut State) -> apperr::Result { - let cwd = std::env::current_dir()?; - let mut goto = Point::default(); - - // The best CLI argument parser in the world. - if let Some(path) = std::env::args_os().nth(1) { - if path == "-h" || path == "--help" || (cfg!(windows) && path == "/?") { - print_help(); - return Ok(true); - } else if path == "-v" || path == "--version" { - print_version(); - return Ok(true); - } else if path == "-" { - // We'll check for a redirected stdin no matter what, so we can just ignore "-". - } else { - let mut path = PathBuf::from(path); - let mut filename = get_filename_from_path(&path); - - if let Some((len, pos)) = parse_filename_goto(&filename) { - let path = path.as_mut_os_string(); - path.truncate(path.len() - filename.len() + len); - filename.truncate(len); - goto = pos; - } - - // If the user specified a path, try to figure out the directory - // normalize & check if it exists (= canonicalize), - // and if all that works out, use it as the file picker path. - let mut dir = cwd.join(&path); - if !filename.is_empty() { - dir.pop(); - } - if let Ok(dir) = sys::canonicalize(dir) { - state.file_picker_pending_dir = DisplayablePathBuf::new(dir); - } - - // Only set the text buffer path if the given path wasn't a directory. - if !filename.is_empty() { - state.set_path(path, filename); - } - } - } - - // If the user didn't specify a path, use the current working directory. - if state.file_picker_pending_dir.as_bytes().is_empty() { - state.file_picker_pending_dir = DisplayablePathBuf::new(cwd); - } - - if let Some(mut file) = sys::open_stdin_if_redirected() { - let mut tb = state.buffer.borrow_mut(); - tb.read_file(&mut file, None)?; - tb.mark_as_dirty(); - } else if let Some(path) = &state.path { - if !state.filename.is_empty() { - match file_open(path) { - Ok(mut file) => state.buffer.borrow_mut().read_file(&mut file, None)?, - Err(err) if sys::apperr_is_not_found(err) => {} - Err(err) => return Err(err), - } - } - } - - if goto != Point::default() { - state.buffer.borrow_mut().cursor_move_to_logical(goto); - } - - Ok(false) -} - -fn parse_filename_goto(str: &str) -> Option<(usize, Point)> { - let bytes = str.as_bytes(); - let colend = memrchr2(b':', b':', bytes, bytes.len())?; - - // Reject filenames that would result in an empty filename after stripping off the :line:char suffix. - // For instance, a filename like ":123:456" will not be processed by this function. - if colend == 0 { - return None; - } - - let last = str[colend + 1..].parse::().ok()?; - let last = (last - 1).max(0); - - if let Some(colbeg) = memrchr2(b':', b':', bytes, colend) { - // Same here: Don't allow empty filenames. - if colbeg != 0 { - if let Ok(first) = str[colbeg + 1..colend].parse::() { - let first = (first - 1).max(0); - return Some((colbeg, Point { x: last, y: first })); - } - } - } - - Some((colend, Point { x: 0, y: last })) -} - -fn print_help() { - sys::write_stdout(concat!( - "Usage: edit [OPTIONS] [FILE]\r\n", - "Options:\r\n", - " -h, --help Print this help message\r\n", - " -v, --version Print the version number\r\n", - )); -} - -fn print_version() { - sys::write_stdout(concat!("edit version ", env!("CARGO_PKG_VERSION"), "\r\n")); -} - -fn draw(ctx: &mut Context, state: &mut State) { - let root_focused = ctx.contains_focus(); - - draw_menubar(ctx, state); - if !matches!( - state.wants_search.kind, - StateSearchKind::Hidden | StateSearchKind::Disabled - ) { - draw_search(ctx, state); - } - draw_editor(ctx, state); - draw_statusbar(ctx, state); - - // If the user presses "Save" on the exit dialog we'll possible show a SaveAs dialog. - // The exit dialog should then get hidden. - if state.wants_exit { - draw_handle_wants_exit(ctx, state); - } - - if state.wants_file_picker != StateFilePicker::None { - draw_file_picker(ctx, state); - } - - if state.wants_file_picker == StateFilePicker::Save { - draw_handle_save(ctx, state, None); - } - - if state.wants_encoding_change != StateEncodingChange::None { - draw_dialog_encoding_change(ctx, state); - } - - if state.wants_about { - draw_dialog_about(ctx, state); - } - - if state.error_log_count != 0 { - draw_error_log(ctx, state); - } - - if root_focused { - // Shortcuts that are not handled as part of the textarea, etc. - if ctx.consume_shortcut(kbmod::CTRL | vk::O) { - state.wants_file_picker = StateFilePicker::Open; - } - if ctx.consume_shortcut(kbmod::CTRL | vk::S) { - state.wants_file_picker = StateFilePicker::Save; - } - if ctx.consume_shortcut(kbmod::CTRL_SHIFT | vk::S) { - state.wants_file_picker = StateFilePicker::SaveAs; - } - if ctx.consume_shortcut(kbmod::CTRL | vk::Q) { - state.wants_exit = true; - } - if state.wants_search.kind != StateSearchKind::Disabled { - if ctx.consume_shortcut(kbmod::CTRL | vk::F) { - state.wants_search.kind = StateSearchKind::Search; - state.wants_search.focus = true; - } - if ctx.consume_shortcut(kbmod::CTRL | vk::R) { - state.wants_search.kind = StateSearchKind::Replace; - state.wants_search.focus = true; - } - } - } -} - -fn draw_menubar(ctx: &mut Context, state: &mut State) { - ctx.menubar_begin(); - ctx.attr_background_rgba(state.menubar_color_bg); - ctx.attr_foreground_rgba(state.menubar_color_fg); - { - if ctx.menubar_menu_begin(loc(LocId::File), 'F') { - draw_menu_file(ctx, state); - } - if ctx.menubar_menu_begin(loc(LocId::Edit), 'E') { - draw_menu_edit(ctx, state); - } - if ctx.menubar_menu_begin(loc(LocId::View), 'V') { - draw_menu_view(ctx, state); - } - if ctx.menubar_menu_begin(loc(LocId::Help), 'H') { - draw_menu_help(ctx, state); - } - } - ctx.menubar_end(); -} - -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 ctx.menubar_menu_button(loc(LocId::FileSave), 'S', kbmod::CTRL | vk::S) { - state.wants_file_picker = StateFilePicker::Save; - } - if ctx.menubar_menu_button(loc(LocId::FileSaveAs), 'A', vk::NULL) { - state.wants_file_picker = StateFilePicker::SaveAs; - } - if ctx.menubar_menu_button(loc(LocId::FileExit), 'X', kbmod::CTRL | vk::Q) { - state.wants_exit = true; - } - ctx.menubar_menu_end(); -} - -fn draw_menu_edit(ctx: &mut Context, state: &mut State) { - if ctx.menubar_menu_button(loc(LocId::EditUndo), 'U', kbmod::CTRL | vk::Z) { - state.buffer.borrow_mut().undo(); - ctx.needs_rerender(); - } - if ctx.menubar_menu_button(loc(LocId::EditRedo), 'R', kbmod::CTRL | vk::Y) { - state.buffer.borrow_mut().redo(); - ctx.needs_rerender(); - } - if ctx.menubar_menu_button(loc(LocId::EditCut), 'T', kbmod::CTRL | vk::X) { - ctx.set_clipboard(state.buffer.borrow_mut().extract_selection(true)); - } - if ctx.menubar_menu_button(loc(LocId::EditCopy), 'C', kbmod::CTRL | vk::C) { - ctx.set_clipboard(state.buffer.borrow_mut().extract_selection(false)); - } - if ctx.menubar_menu_button(loc(LocId::EditPaste), 'P', kbmod::CTRL | vk::V) { - state.buffer.borrow_mut().write(ctx.get_clipboard(), true); - ctx.needs_rerender(); - } - if state.wants_search.kind != StateSearchKind::Disabled { - if ctx.menubar_menu_button(loc(LocId::EditFind), 'F', kbmod::CTRL | vk::F) { - state.wants_search.kind = StateSearchKind::Search; - state.wants_search.focus = true; - } - if ctx.menubar_menu_button(loc(LocId::EditReplace), 'R', kbmod::CTRL | vk::R) { - state.wants_search.kind = StateSearchKind::Replace; - state.wants_search.focus = true; - } - } - ctx.menubar_menu_end(); -} - -fn draw_menu_view(ctx: &mut Context, state: &mut State) { - let mut tb = state.buffer.borrow_mut(); - let word_wrap = tb.is_word_wrap_enabled(); - - if ctx.menubar_menu_button(loc(LocId::ViewFocusStatusbar), 'S', vk::NULL) { - state.wants_statusbar_focus = true; - } - if ctx.menubar_menu_checkbox(loc(LocId::ViewWordWrap), 'W', kbmod::ALT | vk::Z, word_wrap) { - tb.set_word_wrap(!word_wrap); - ctx.needs_rerender(); - } - ctx.menubar_menu_end(); -} - -fn draw_menu_help(ctx: &mut Context, state: &mut State) { - if ctx.menubar_menu_button(loc(LocId::HelpAbout), 'A', vk::NULL) { - state.wants_about = true; - } - ctx.menubar_menu_end(); -} - -fn draw_search(ctx: &mut Context, state: &mut State) { - enum SearchAction { - None, - Search, - Replace, - ReplaceAll, - } - - if let Err(err) = icu::init() { - error_log_add(ctx, state, err); - state.wants_search.kind = StateSearchKind::Disabled; - return; - } - - let mut action = SearchAction::None; - let mut focus = StateSearchKind::Hidden; - - if state.wants_search.focus { - state.wants_search.focus = false; - focus = StateSearchKind::Search; - - // If the selection is empty, focus the search input field. - // Otherwise, focus the replace input field, if it exists. - if let Some(selection) = state.buffer.borrow_mut().extract_user_selection(false) { - state.search_needle = string_from_utf8_lossy_owned(selection); - focus = state.wants_search.kind; - } - } - - ctx.block_begin("search"); - ctx.attr_focus_well(); - ctx.attr_background_rgba(ctx.indexed(IndexedColor::White)); - ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Black)); - { - if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) { - state.wants_search.kind = StateSearchKind::Hidden; - } - - ctx.table_begin("needle"); - ctx.table_set_cell_gap(Size { - width: 1, - height: 0, - }); - { - { - ctx.table_next_row(); - ctx.label("label", Overflow::Clip, loc(LocId::SearchNeedleLabel)); - - if ctx.editline("needle", &mut state.search_needle) { - action = SearchAction::Search; - } - if !state.search_success { - ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); - ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); - } - ctx.attr_intrinsic_size(Size { - width: COORD_TYPE_SAFE_MAX, - height: 1, - }); - if focus == StateSearchKind::Search { - ctx.steal_focus(); - } - if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) { - action = SearchAction::Search; - } - } - - if state.wants_search.kind == StateSearchKind::Replace { - ctx.table_next_row(); - ctx.label("label", Overflow::Clip, loc(LocId::SearchReplacementLabel)); - - ctx.editline("replacement", &mut state.search_replacement); - ctx.attr_intrinsic_size(Size { - width: COORD_TYPE_SAFE_MAX, - height: 1, - }); - if focus == StateSearchKind::Replace { - ctx.steal_focus(); - } - if ctx.is_focused() { - if ctx.consume_shortcut(vk::RETURN) { - action = SearchAction::Replace; - } else if ctx.consume_shortcut(kbmod::CTRL_ALT | vk::RETURN) { - action = SearchAction::ReplaceAll; - } - } - } - } - ctx.table_end(); - - ctx.table_begin("options"); - ctx.table_set_cell_gap(Size { - width: 2, - height: 0, - }); - { - ctx.table_next_row(); - - let mut change = false; - change |= ctx.checkbox( - "match-case", - Overflow::Clip, - loc(LocId::SearchMatchCase), - &mut state.search_options.match_case, - ); - change |= ctx.checkbox( - "whole-word", - Overflow::Clip, - loc(LocId::SearchWholeWord), - &mut state.search_options.whole_word, - ); - change |= ctx.checkbox( - "use-regex", - Overflow::Clip, - loc(LocId::SearchUseRegex), - &mut state.search_options.use_regex, - ); - if change { - action = SearchAction::Search; - state.wants_search.focus = true; - ctx.needs_rerender(); - } - - if state.wants_search.kind == StateSearchKind::Replace - && ctx.button("replace-all", Overflow::Clip, loc(LocId::SearchReplaceAll)) - { - action = SearchAction::ReplaceAll; - } - - if ctx.button("close", Overflow::Clip, loc(LocId::SearchClose)) { - state.wants_search.kind = StateSearchKind::Hidden; - } - } - ctx.table_end(); - } - ctx.block_end(); - - state.search_success = match action { - SearchAction::None => return, - SearchAction::Search => state - .buffer - .borrow_mut() - .find_and_select(&state.search_needle, state.search_options), - SearchAction::Replace => state.buffer.borrow_mut().find_and_replace( - &state.search_needle, - state.search_options, - &state.search_replacement, - ), - SearchAction::ReplaceAll => state.buffer.borrow_mut().find_and_replace_all( - &state.search_needle, - state.search_options, - &state.search_replacement, - ), - } - .is_ok(); - - ctx.needs_rerender(); -} - -fn draw_editor(ctx: &mut Context, state: &mut State) { - let size = ctx.size(); - // TODO: The layout code should be able to just figure out the height on its own. - let height_reduction = match state.wants_search.kind { - StateSearchKind::Search => 4, - StateSearchKind::Replace => 5, - _ => 2, - }; - - ctx.textarea("textarea", state.buffer.clone()); - ctx.inherit_focus(); - ctx.attr_intrinsic_size(Size { - width: 0, - height: size.height - height_reduction, - }); -} - -fn draw_statusbar(ctx: &mut Context, state: &mut State) { - ctx.table_begin("statusbar"); - ctx.attr_background_rgba(state.menubar_color_bg); - ctx.attr_foreground_rgba(state.menubar_color_fg); - ctx.table_set_cell_gap(Size { - width: 2, - height: 0, - }); - ctx.attr_padding(Rect::two(0, 1)); - { - ctx.table_next_row(); - - let mut tb = state.buffer.borrow_mut(); - - if ctx.button( - "newline", - Overflow::Clip, - if tb.is_crlf() { "CRLF" } else { "LF" }, - ) { - let is_crlf = tb.is_crlf(); - tb.normalize_newlines(!is_crlf); - } - if state.wants_statusbar_focus { - state.wants_statusbar_focus = false; - ctx.steal_focus(); - } - - state.wants_encoding_picker |= ctx.button("encoding", Overflow::Clip, tb.encoding()); - if state.wants_encoding_picker { - if state.path.is_some() { - ctx.block_begin("frame"); - ctx.attr_float(FloatSpec { - anchor: Anchor::Last, - gravity_x: 0.0, - gravity_y: 1.0, - offset_x: 0, - offset_y: 0, - }); - ctx.attr_padding(Rect::two(0, 1)); - ctx.attr_border(); - { - ctx.list_begin("options"); - ctx.focus_on_first_present(); - { - if ctx.list_item(false, Overflow::Clip, loc(LocId::EncodingReopen)) - == ListSelection::Activated - { - state.wants_encoding_change = StateEncodingChange::Reopen; - } - if ctx.list_item(false, Overflow::Clip, loc(LocId::EncodingConvert)) - == ListSelection::Activated - { - state.wants_encoding_change = StateEncodingChange::Convert; - } - } - ctx.list_end(); - } - ctx.block_end(); - } else { - // Can't reopen a file that doesn't exist. - state.wants_encoding_change = StateEncodingChange::Convert; - } - - if !ctx.contains_focus() { - state.wants_encoding_picker = false; - ctx.needs_rerender(); - } - } - - state.wants_indentation_picker |= ctx.button( - "indentation", - Overflow::Clip, - &format!( - "{}:{}", - loc(if tb.indent_with_tabs() { - LocId::IndentationTabs - } else { - LocId::IndentationSpaces - }), - tb.tab_size(), - ), - ); - if state.wants_indentation_picker { - ctx.table_begin("indentation-picker"); - ctx.attr_float(FloatSpec { - anchor: Anchor::Last, - gravity_x: 0.0, - gravity_y: 1.0, - offset_x: 0, - offset_y: 0, - }); - ctx.attr_border(); - ctx.attr_padding(Rect::two(0, 1)); - ctx.table_set_cell_gap(Size { - width: 1, - height: 0, - }); - { - if ctx.consume_shortcut(vk::RETURN) { - ctx.toss_focus_up(); - } - - ctx.table_next_row(); - - ctx.list_begin("type"); - ctx.focus_on_first_present(); - ctx.attr_padding(Rect::two(0, 1)); - { - if ctx.list_item( - tb.indent_with_tabs(), - Overflow::Clip, - loc(LocId::IndentationTabs), - ) != ListSelection::Unchanged - { - tb.set_indent_with_tabs(true); - ctx.needs_rerender(); - } - if ctx.list_item( - !tb.indent_with_tabs(), - Overflow::Clip, - loc(LocId::IndentationSpaces), - ) != ListSelection::Unchanged - { - tb.set_indent_with_tabs(false); - ctx.needs_rerender(); - } - } - ctx.list_end(); - - ctx.list_begin("width"); - ctx.attr_padding(Rect::two(0, 2)); - { - for width in 1u8..=8 { - let ch = [b'0' + width]; - let label = unsafe { std::str::from_utf8_unchecked(&ch) }; - - if ctx.list_item(tb.tab_size() == width as i32, Overflow::Clip, label) - != ListSelection::Unchanged - { - tb.set_tab_size(width as i32); - ctx.needs_rerender(); - } - } - } - ctx.list_end(); - } - ctx.table_end(); - - if !ctx.contains_focus() { - state.wants_indentation_picker = false; - ctx.needs_rerender(); - } - } - - ctx.label( - "location", - Overflow::Clip, - &format!( - "{}:{}", - tb.get_cursor_logical_pos().y + 1, - tb.get_cursor_logical_pos().x + 1 - ), - ); - - #[cfg(any(feature = "debug-layout", feature = "debug-latency"))] - ctx.label( - "stats", - Overflow::Clip, - &format!( - "{}/{}", - tb.get_logical_line_count(), - tb.get_visual_line_count(), - ), - ); - - if tb.is_overtype() && ctx.button("overtype", Overflow::Clip, "OVR") { - tb.set_overtype(false); - ctx.needs_rerender(); - } - - if tb.is_dirty() { - ctx.label("dirty", Overflow::Clip, "*"); - } - - ctx.block_begin("filename-container"); - ctx.attr_intrinsic_size(Size { - width: COORD_TYPE_SAFE_MAX, - height: 1, - }); - { - ctx.label("filename", Overflow::TruncateMiddle, &state.filename); - ctx.attr_position(Position::Right); - } - ctx.block_end(); - } - ctx.table_end(); -} - -fn draw_file_picker(ctx: &mut Context, state: &mut State) { - if state.wants_file_picker == StateFilePicker::Open { - state.wants_file_picker = if state.buffer.borrow().is_dirty() { - StateFilePicker::OpenUnsavedChanges - } else { - StateFilePicker::OpenForce - }; - } - - if state.wants_file_picker == StateFilePicker::OpenUnsavedChanges { - match draw_unsaved_changes_dialog(ctx) { - UnsavedChangesDialogResult::None => return, - UnsavedChangesDialogResult::Save => { - // TODO: Ideally this would be a special case of `StateFilePicker::Save`, - // where the open dialog reopens right after saving. - // But that felt annoying to implement so I didn't do it. - state.wants_file_picker = StateFilePicker::Save; - } - UnsavedChangesDialogResult::Discard => { - state.wants_file_picker = StateFilePicker::OpenForce; - } - UnsavedChangesDialogResult::Cancel => { - state.wants_file_picker = StateFilePicker::None; - return; - } - } - } - - if state.wants_file_picker == StateFilePicker::Save { - if state.path.is_some() { - // `draw_handle_save` will handle things. - return; - } - state.wants_file_picker = StateFilePicker::SaveAs; - } - - let width = (ctx.size().width - 20).max(10); - let height = (ctx.size().height - 10).max(10); - let mut save_path = None; - - ctx.modal_begin( - "file-picker", - if state.wants_file_picker == StateFilePicker::OpenForce { - loc(LocId::FileOpen) - } else { - loc(LocId::FileSaveAs) - }, - ); - ctx.attr_intrinsic_size(Size { width, height }); - { - let mut activated = false; - - ctx.table_begin("path"); - ctx.table_set_columns(&[0, COORD_TYPE_SAFE_MAX]); - ctx.table_set_cell_gap(Size { - width: 1, - height: 0, - }); - ctx.attr_padding(Rect::two(1, 1)); - ctx.inherit_focus(); - { - ctx.table_next_row(); - - ctx.label( - "dir-label", - Overflow::Clip, - loc(LocId::SaveAsDialogPathLabel), - ); - ctx.label( - "dir", - Overflow::TruncateMiddle, - state.file_picker_pending_dir.as_str(), - ); - - ctx.table_next_row(); - ctx.inherit_focus(); - - ctx.label( - "name-label", - Overflow::Clip, - loc(LocId::SaveAsDialogNameLabel), - ); - ctx.editline("name", &mut state.file_picker_pending_name); - ctx.inherit_focus(); - if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) { - activated = true; - } - } - ctx.table_end(); - - if state.file_picker_entries.is_none() { - draw_dialog_saveas_refresh_files(state); - } - - let files = state.file_picker_entries.as_ref().unwrap(); - - ctx.scrollarea_begin( - "directory", - Size { - width: 0, - // -1 for the label (top) - // -1 for the label (bottom) - // -1 for the editline (bottom) - height: height - 3, - }, - ); - ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 0x3f)); - ctx.next_block_id_mixin(state.file_picker_pending_dir.as_str().len() as u64); - { - ctx.list_begin("files"); - ctx.inherit_focus(); - for entry in files.iter() { - match ctx.list_item( - state.file_picker_pending_name == entry.as_str(), - Overflow::TruncateMiddle, - entry.as_str(), - ) { - ListSelection::Unchanged => {} - ListSelection::Selected => { - state.file_picker_pending_name = entry.as_str().to_string(); - } - ListSelection::Activated => { - activated = true; - } - } - } - ctx.list_end(); - } - ctx.scrollarea_end(); - - if activated { - if let Some(path) = draw_file_picker_update_path(state) { - if state.wants_file_picker == StateFilePicker::OpenForce { - // File Open? Just load the file and store the path if it was successful. - if draw_handle_load_impl(ctx, state, Some(&path), None) { - save_path = Some(path); - } - } else { - // File Save? Check if the file exists and show a warning if it does. - // Otherwise, save the file and store the path if it was successful. - if path.exists() && Some(&path) != state.path.as_ref() { - state.file_picker_overwrite_warning = Some(path); - } else if draw_handle_save_impl(ctx, state, Some(&path)) { - save_path = Some(path); - } - } - } - } - } - if ctx.modal_end() { - state.wants_file_picker = StateFilePicker::None; - } - - if state.file_picker_overwrite_warning.is_some() { - let mut save; - - ctx.modal_begin("overwrite", loc(LocId::FileOverwriteWarning)); - ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); - ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); - { - ctx.label( - "description", - Overflow::TruncateTail, - loc(LocId::FileOverwriteWarningDescription), - ); - ctx.attr_padding(Rect::three(1, 2, 1)); - - ctx.table_begin("choices"); - ctx.inherit_focus(); - ctx.attr_padding(Rect::three(0, 2, 1)); - ctx.attr_position(Position::Center); - ctx.table_set_cell_gap(Size { - width: 2, - height: 0, - }); - { - ctx.table_next_row(); - ctx.inherit_focus(); - - save = ctx.button("yes", Overflow::Clip, loc(LocId::Yes)); - ctx.inherit_focus(); - - if ctx.button("no", Overflow::Clip, loc(LocId::No)) { - state.file_picker_overwrite_warning = None; - } - } - ctx.table_end(); - - save |= ctx.consume_shortcut(vk::Y); - if ctx.consume_shortcut(vk::N) { - state.file_picker_overwrite_warning = None; - } - } - if ctx.modal_end() { - state.file_picker_overwrite_warning = None; - } - - if save { - let path = state.file_picker_overwrite_warning.take(); - if draw_handle_save_impl(ctx, state, path.as_ref()) { - save_path = path; - } - state.file_picker_overwrite_warning = None; - } - } - - if let Some(path) = save_path { - // Only update the path if the save was successful. - state.set_path(path, state.file_picker_pending_name.clone()); - state.wants_file_picker = StateFilePicker::None; - state.file_picker_entries = None; - } -} - -// Returns Some(path) if the caller should attempt to save the file. -fn draw_file_picker_update_path(state: &mut State) -> Option { - let path = state.file_picker_pending_dir.as_path(); - let path = path.join(&state.file_picker_pending_name); - let mut normalized = PathBuf::new(); - - for c in path.components() { - match c { - Component::CurDir => {} - Component::ParentDir => _ = normalized.pop(), - _ => normalized.push(c.as_os_str()), - } - } - - let (dir, name) = if normalized.is_dir() { - (normalized.as_path(), String::new()) - } else { - let dir = normalized.parent().unwrap_or(&normalized); - let name = get_filename_from_path(&normalized); - (dir, name) - }; - if dir != state.file_picker_pending_dir.as_path() { - state.file_picker_pending_dir = DisplayablePathBuf::new(dir.to_path_buf()); - state.file_picker_entries = None; - } - - state.file_picker_pending_name = name; - if state.file_picker_pending_name.is_empty() { - None - } else { - Some(normalized) - } -} - -fn draw_dialog_saveas_refresh_files(state: &mut State) { - let dir = state.file_picker_pending_dir.as_path(); - let mut files = Vec::new(); - - if dir.parent().is_some() { - files.push(DisplayablePathBuf::from("..")); - } - - if let Ok(iter) = std::fs::read_dir(dir) { - for entry in iter.flatten() { - if let Ok(metadata) = entry.metadata() { - let mut name = entry.file_name(); - if metadata.is_dir() { - name.push("/"); - } - files.push(DisplayablePathBuf::from(name)); - } - } - } - - // Sort directories first, then by name, case-insensitive. - files[1..].sort_by(|a, b| { - let a = a.as_bytes(); - let b = b.as_bytes(); - - let a_is_dir = a.last() == Some(&b'/'); - let b_is_dir = b.last() == Some(&b'/'); - - match b_is_dir.cmp(&a_is_dir) { - cmp::Ordering::Equal => icu::compare_strings(a, b), - other => other, - } - }); - - state.file_picker_entries = Some(files); -} - -fn draw_handle_load_impl( - ctx: &mut Context, - state: &mut State, - path: Option<&PathBuf>, - encoding: Option<&'static str>, -) -> bool { - let Some(path) = path.or(state.path.as_ref()) else { - return false; - }; - - if let Err(err) = file_open(path) - .and_then(|mut file| state.buffer.borrow_mut().read_file(&mut file, encoding)) - { - error_log_add(ctx, state, err); - return false; - } - - ctx.needs_rerender(); - true -} - -fn draw_handle_save(ctx: &mut Context, state: &mut State, path: Option<&PathBuf>) -> bool { - // Don't retry if the upcoming save fails. - state.wants_file_picker = StateFilePicker::None; - draw_handle_save_impl(ctx, state, path) -} - -fn draw_handle_save_impl(ctx: &mut Context, state: &mut State, path: Option<&PathBuf>) -> bool { - let Some(path) = path.or(state.path.as_ref()) else { - return false; - }; - - if let Err(err) = { state.buffer.borrow_mut().write_file(path) } { - error_log_add(ctx, state, err); - return false; - } - - ctx.needs_rerender(); - true -} - -fn draw_dialog_encoding_change(ctx: &mut Context, state: &mut State) { - let reopen = state.wants_encoding_change == StateEncodingChange::Reopen; - let width = (ctx.size().width - 20).max(10); - let height = (ctx.size().height - 10).max(10); - - ctx.modal_begin( - "encode", - if reopen { - loc(LocId::EncodingReopen) - } else { - loc(LocId::EncodingConvert) - }, - ); - { - ctx.scrollarea_begin("scrollarea", Size { width, height }); - ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Black, 0x3f)); - ctx.inherit_focus(); - { - let encodings = icu::get_available_encodings(); - - ctx.list_begin("encodings"); - ctx.inherit_focus(); - for encoding in encodings { - if ctx.list_item( - encoding.as_str() == state.buffer.borrow().encoding(), - Overflow::Clip, - encoding.as_str(), - ) == ListSelection::Activated - { - state.wants_encoding_change = StateEncodingChange::None; - if reopen && state.path.is_some() { - if state.buffer.borrow().is_dirty() { - draw_handle_save_impl(ctx, state, None); - } - draw_handle_load_impl(ctx, state, None, Some(encoding.as_str())); - } else { - state.buffer.borrow_mut().set_encoding(encoding.as_str()); - ctx.needs_rerender(); - } - } - } - ctx.list_end(); - } - ctx.scrollarea_end(); - } - if ctx.modal_end() { - state.wants_encoding_change = StateEncodingChange::None; - } -} - -fn draw_handle_wants_exit(ctx: &mut Context, state: &mut State) { - if !state.buffer.borrow().is_dirty() { - state.exit = true; - return; - } - - match draw_unsaved_changes_dialog(ctx) { - UnsavedChangesDialogResult::None => {} - UnsavedChangesDialogResult::Save => state.wants_file_picker = StateFilePicker::Save, - UnsavedChangesDialogResult::Discard => state.exit = true, - UnsavedChangesDialogResult::Cancel => state.wants_exit = false, - } -} - -enum UnsavedChangesDialogResult { - None, - Save, - Discard, - Cancel, -} - -fn draw_unsaved_changes_dialog(ctx: &mut Context) -> UnsavedChangesDialogResult { - let mut result = UnsavedChangesDialogResult::None; - - ctx.modal_begin("unsaved-changes", loc(LocId::UnsavedChangesDialogTitle)); - ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); - ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); - { - ctx.label( - "description", - Overflow::Clip, - loc(LocId::UnsavedChangesDialogDescription), - ); - ctx.attr_padding(Rect::three(1, 2, 1)); - - ctx.table_begin("choices"); - ctx.inherit_focus(); - ctx.attr_padding(Rect::three(0, 2, 1)); - ctx.attr_position(Position::Center); - ctx.table_set_cell_gap(Size { - width: 2, - height: 0, - }); - { - ctx.table_next_row(); - ctx.inherit_focus(); - - if ctx.button("yes", Overflow::Clip, loc(LocId::UnsavedChangesDialogYes)) { - result = UnsavedChangesDialogResult::Save; - } - ctx.inherit_focus(); - if ctx.button("no", Overflow::Clip, loc(LocId::UnsavedChangesDialogNo)) { - result = UnsavedChangesDialogResult::Discard; - } - if ctx.button( - "cancel", - Overflow::Clip, - loc(LocId::UnsavedChangesDialogCancel), - ) { - result = UnsavedChangesDialogResult::Cancel; - } - - // TODO: This should highlight the corresponding letter in the label. - if ctx.consume_shortcut(vk::S) { - result = UnsavedChangesDialogResult::Save; - } else if ctx.consume_shortcut(vk::N) { - result = UnsavedChangesDialogResult::Discard; - } - } - ctx.table_end(); - } - - if ctx.modal_end() { - result = UnsavedChangesDialogResult::Cancel; - } - - result -} - -fn draw_dialog_about(ctx: &mut Context, state: &mut State) { - ctx.modal_begin("about", loc(LocId::AboutDialogTitle)); - { - ctx.block_begin("content"); - ctx.attr_padding(Rect::three(1, 2, 1)); - { - ctx.label("description", Overflow::TruncateTail, "Microsoft Edit"); - ctx.attr_position(Position::Center); - - ctx.label( - "version", - Overflow::TruncateHead, - &format!( - "{}{}", - loc(LocId::AboutDialogVersion), - env!("CARGO_PKG_VERSION") - ), - ); - ctx.attr_position(Position::Center); - - ctx.label( - "copyright", - Overflow::TruncateTail, - "Copyright (c) Microsoft Corp 2025", - ); - ctx.attr_position(Position::Center); - } - ctx.block_end(); - } - if ctx.modal_end() { - state.wants_about = false; - } -} - -fn draw_error_log(ctx: &mut Context, state: &mut State) { - ctx.modal_begin("errors", "Error"); - ctx.attr_background_rgba(ctx.indexed(IndexedColor::Red)); - ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); - { - ctx.block_begin("content"); - ctx.attr_padding(Rect::two(1, 2)); - { - let off = state.error_log_index + state.error_log.len() - state.error_log_count; - - for i in 0..state.error_log_count { - let idx = (off + i) % state.error_log.len(); - let msg = &state.error_log[idx][..]; - - if !msg.is_empty() { - ctx.next_block_id_mixin(i as u64); - ctx.label("error", Overflow::TruncateTail, msg); - } - } - } - ctx.block_end(); - - if ctx.button("ok", Overflow::Clip, "Ok") { - state.error_log_count = 0; - } - ctx.attr_padding(Rect::three(1, 2, 1)); - ctx.attr_position(Position::Center); - ctx.inherit_focus(); - } - if ctx.modal_end() { - state.error_log_count = 0; - } -} - -fn error_log_add(ctx: &mut Context, state: &mut State, err: apperr::Error) { - let msg = err.message(); - if !msg.is_empty() { - state.error_log[state.error_log_index] = msg; - state.error_log_index = (state.error_log_index + 1) % state.error_log.len(); - state.error_log_count = cmp::min(state.error_log_count + 1, state.error_log.len()); - ctx.needs_rerender(); - } -} - -fn file_open(path: &Path) -> apperr::Result { - File::open(path).map_err(apperr::Error::from) -} - -fn get_filename_from_path(path: &Path) -> String { - path.file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned() -} - -fn set_vt_modes() -> RestoreModes { - // 1049: Alternative Screen Buffer - // I put the ASB switch in the beginning, just in case the terminal performs - // some additional state tracking beyond the modes we enable/disable. - // 1002: Cell Motion Mouse Tracking - // 1006: SGR Mouse Mode - // 2004: Bracketed Paste Mode - sys::write_stdout("\x1b[?1049h\x1b[?1002;1006;2004h"); - RestoreModes -} - -#[cold] -fn write_terminal_title(output: &mut String, state: &mut State) { - output.push_str("\x1b]0;"); - if !state.filename.is_empty() { - output.push_str(&sanitize_control_chars(&state.filename)); - output.push_str(" - "); - } - output.push_str("edit\x1b\\"); - - state.wants_term_title_update = false; -} - -#[cold] -fn write_osc_clipboard(output: &mut String, state: &mut State, tui: &Tui) { - let clipboard = tui.get_clipboard(); - - if (1..128 * 1024).contains(&clipboard.len()) { - output.push_str("\x1b]52;c;"); - output.push_str(&base64::encode(clipboard)); - output.push_str("\x1b\\"); - } - - state.osc_clipboard_generation = tui.get_clipboard_generation(); -} - -struct RestoreModes; - -impl Drop for RestoreModes { - fn drop(&mut self) { - // Same as in the beginning but in the reverse order. - // It also includes DECSCUSR 0 to reset the cursor style and DECTCEM to show the cursor. - sys::write_stdout("\x1b[0 q\x1b[?25h\x1b]0;\x07\x1b[?1002;1006;2004l\x1b[?1049l"); - } -} - -fn query_color_palette(tui: &mut Tui, vt_parser: &mut vt::Parser) { - let mut indexed_colors = framebuffer::DEFAULT_THEME; - - sys::write_stdout(concat!( - // OSC 4 color table requests for indices 0 through 15 (base colors). - "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?\x07", - "\x1b]4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07", - // OSC 10 and 11 queries for the current foreground and background colors. - "\x1b]10;?\x07\x1b]11;?\x07", - // CSI c reports the terminal capabilities. - // It also helps us to detect the end of the responses, because not all - // terminals support the OSC queries, but all of them support CSI c. - "\x1b[c", - )); - - let mut done = false; - let mut osc_buffer = String::new(); - - while !done { - let Some(input) = sys::read_stdin(vt_parser.read_timeout()) else { - break; - }; - - let mut vt_stream = vt_parser.parse(&input); - while let Some(token) = vt_stream.next() { - match token { - Token::Csi(state) if state.final_byte == 'c' => done = true, - Token::Osc { mut data, partial } => { - if partial { - osc_buffer.push_str(data); - continue; - } - if !osc_buffer.is_empty() { - osc_buffer.push_str(data); - data = &osc_buffer; - } - - let mut splits = data.split_terminator(';'); - - let color = match splits.next().unwrap_or("") { - // The response is `4;;rgb://`. - "4" => match splits.next().unwrap_or("").parse::() { - Ok(val) if val < 16 => &mut indexed_colors[val], - _ => continue, - }, - // The response is `10;rgb://`. - "10" => &mut indexed_colors[IndexedColor::Foreground as usize], - // The response is `11;rgb://`. - "11" => &mut indexed_colors[IndexedColor::Background as usize], - _ => continue, - }; - - let color_param = splits.next().unwrap_or(""); - if !color_param.starts_with("rgb:") { - continue; - } - - let mut iter = color_param[4..].split_terminator('/'); - let rgb_parts = [(); 3].map(|_| iter.next().unwrap_or("0")); - let mut rgb = 0; - - for part in rgb_parts { - if part.len() == 2 || part.len() == 4 { - let Ok(mut val) = usize::from_str_radix(part, 16) else { - continue; - }; - if part.len() == 4 { - val = (val * 0xff + 0x80) / 0xffff; - } - rgb = (rgb >> 8) | ((val as u32) << 16); - } - } - - *color = rgb | 0xff000000; - osc_buffer.clear(); - } - _ => {} - } - } - } - - tui.setup_indexed_colors(indexed_colors); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_last_numbers() { - assert_eq!(parse_filename_goto("123"), None); - assert_eq!(parse_filename_goto("abc"), None); - assert_eq!(parse_filename_goto(":123"), None); - assert_eq!( - parse_filename_goto("abc:123"), - Some((3, Point { x: 0, y: 122 })) - ); - assert_eq!( - parse_filename_goto("45:123"), - Some((2, Point { x: 0, y: 122 })) - ); - assert_eq!( - parse_filename_goto(":45:123"), - Some((3, Point { x: 0, y: 122 })) - ); - assert_eq!( - parse_filename_goto("abc:45:123"), - Some((3, Point { x: 122, y: 44 })) - ); - assert_eq!( - parse_filename_goto("abc:def:123"), - Some((7, Point { x: 0, y: 122 })) - ); - assert_eq!( - parse_filename_goto("1:2:3"), - Some((1, Point { x: 2, y: 1 })) - ); - assert_eq!(parse_filename_goto("::3"), Some((1, Point { x: 0, y: 2 }))); - assert_eq!(parse_filename_goto("1::3"), Some((2, Point { x: 0, y: 2 }))); - assert_eq!(parse_filename_goto(""), None); - assert_eq!(parse_filename_goto(":"), None); - assert_eq!(parse_filename_goto("::"), None); - assert_eq!(parse_filename_goto("a:1"), Some((1, Point { x: 0, y: 0 }))); - assert_eq!(parse_filename_goto("1:a"), None); - assert_eq!( - parse_filename_goto("file.txt:10"), - Some((8, Point { x: 0, y: 9 })) - ); - assert_eq!( - parse_filename_goto("file.txt:10:5"), - Some((8, Point { x: 4, y: 9 })) - ); - } -} diff --git a/src/sys/unix.rs b/src/sys/unix.rs index a9be74f..0235d0a 100644 --- a/src/sys/unix.rs +++ b/src/sys/unix.rs @@ -495,19 +495,18 @@ pub fn io_error_to_apperr(err: std::io::Error) -> apperr::Error { errno_to_apperr(err.raw_os_error().unwrap_or(0)) } -pub fn apperr_format(code: u32) -> String { - let mut result = format!("Error {code}"); +pub fn apperr_format(f: &mut std::fmt::Formatter<'_>, code: u32) -> std::fmt::Result { + write!(f, "Error {code}")?; unsafe { let ptr = libc::strerror(code as i32); if !ptr.is_null() { let msg = CStr::from_ptr(ptr).to_string_lossy(); - result.push_str(": "); - result.push_str(&msg); + write!(f, ": {msg}")?; } } - result + Ok(()) } pub fn apperr_is_not_found(err: apperr::Error) -> bool { diff --git a/src/sys/windows.rs b/src/sys/windows.rs index c01cbf3..ff4a3b4 100644 --- a/src/sys/windows.rs +++ b/src/sys/windows.rs @@ -401,7 +401,7 @@ pub fn open_stdin_if_redirected() -> Option { } } -pub fn canonicalize>(path: P) -> apperr::Result { +pub fn canonicalize(path: &Path) -> std::io::Result { let mut path = fs::canonicalize(path)?; let path = path.as_mut_os_string(); let mut path = mem::take(path).into_encoded_bytes(); @@ -428,9 +428,11 @@ pub unsafe fn virtual_reserve(size: usize) -> apperr::Result> { unsafe { let mut base = null_mut(); + // In debug builds, we use fixed addresses to aid in debugging. + // Makes it possible to immediately tell which address space a pointer belongs to. if cfg!(debug_assertions) { - static mut S_BASE_GEN: usize = 0x0000100000000000; - S_BASE_GEN += 0x0000100000000000; + static mut S_BASE_GEN: usize = 0x0000100000000000; // 16 TiB + S_BASE_GEN += 0x0000001000000000; // 64 GiB base = S_BASE_GEN as *mut _; } @@ -579,7 +581,7 @@ pub fn io_error_to_apperr(err: std::io::Error) -> apperr::Error { gle_to_apperr(err.raw_os_error().unwrap_or(0) as u32) } -pub fn apperr_format(code: u32) -> String { +pub fn apperr_format(f: &mut std::fmt::Formatter<'_>, code: u32) -> std::fmt::Result { unsafe { let mut ptr: *mut u8 = null_mut(); let len = Debug::FormatMessageA( @@ -594,18 +596,17 @@ pub fn apperr_format(code: u32) -> String { null_mut(), ); - let mut result = format!("Error {code:#08x}"); + write!(f, "Error {code:#08x}")?; if len > 0 { let msg = helpers::str_from_raw_parts(ptr, len as usize); let msg = msg.trim_ascii(); let msg = msg.replace(['\r', '\n'], " "); - result.push_str(": "); - result.push_str(&msg); + write!(f, ": {msg}")?; Foundation::LocalFree(ptr as *mut _); } - result + Ok(()) } } diff --git a/src/tui.rs b/src/tui.rs index dca8738..bc7fae8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,6 +6,7 @@ use crate::helpers::{CoordType, Point, Rect, Size, hash, hash_str, opt_ptr_eq, w use crate::input::{InputKeyMod, kbmod, vk}; use crate::ucd::Document; use crate::{apperr, helpers, input, ucd}; +use std::arch::breakpoint; use std::fmt::Write as _; use std::iter; use std::mem; @@ -33,10 +34,17 @@ enum TextBufferPayload<'a> { Textarea(RcTextBuffer), } +pub struct ModifierTranslations { + pub ctrl: &'static str, + pub alt: &'static str, + pub shift: &'static str, +} + pub struct Tui { framebuffer: Framebuffer, read_timeout: time::Duration, + modifier_translations: ModifierTranslations, floater_default_bg: u32, floater_default_fg: u32, modal_default_bg: u32, @@ -87,6 +95,11 @@ impl Tui { framebuffer: Framebuffer::new(), read_timeout: time::Duration::MAX, + modifier_translations: ModifierTranslations { + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", + }, floater_default_bg: 0, floater_default_fg: 0, modal_default_bg: 0, @@ -134,11 +147,14 @@ impl Tui { self.size } - /// Sets up indexed colors for the TUI context. pub fn setup_indexed_colors(&mut self, colors: [u32; INDEXED_COLORS_COUNT]) { self.framebuffer.set_indexed_colors(colors); } + pub fn setup_modifier_translations(&mut self, translations: ModifierTranslations) { + self.modifier_translations = translations; + } + pub fn set_floater_default_bg(&mut self, color: u32) { self.floater_default_bg = color; } @@ -489,7 +505,9 @@ impl Tui { // If the focus has changed, the new node may need to be re-rendered. // Same, every time we encounter a previously unknown node via `get_prev_node`, // because that means it likely failed to get crucial information such as the layout size. - debug_assert!(self.settling_have < 20); + if cfg!(debug_assertions) && self.settling_have == 15 { + breakpoint(); + } self.settling_want = (self.settling_have + 1).min(20); } @@ -608,12 +626,14 @@ impl Tui { match &mut node.content { NodeContent::Modal(title) => { - self.framebuffer.replace_text( - node.outer.top, - node.outer.left + 2, - node.outer.right - 1, - title, - ); + if !title.is_empty() { + self.framebuffer.replace_text( + node.outer.top, + node.outer.left + 2, + node.outer.right - 1, + title, + ); + } } NodeContent::Text(content) => { if !inner_clipped.is_empty() { @@ -1130,7 +1150,7 @@ impl<'a> Context<'a, '_> { pub fn needs_rerender(&mut self) { // If this hits, the call stack is responsible is trying to deadlock you. - debug_assert!(self.tui.settling_have < 100); + debug_assert!(self.tui.settling_have < 15); self.needs_settling = true; } @@ -1379,7 +1399,12 @@ impl<'a> Context<'a, '_> { self.focus_on_first_present(); let mut last_node = self.tree.last_node.borrow_mut(); - last_node.content = NodeContent::Modal(arena_format!(self.arena(), " {} ", title)); + let title = if title.is_empty() { + ArenaString::new_in(self.arena()) + } else { + arena_format!(self.arena(), " {} ", title) + }; + last_node.content = NodeContent::Modal(title); self.last_modal = Some(self.tree.last_node); } @@ -2649,13 +2674,16 @@ impl<'a> Context<'a, '_> { if shortcut_letter.is_ascii_uppercase() { let mut shortcut_text = self.arena().new_string(); if shortcut.modifiers_contains(kbmod::CTRL) { - shortcut_text.push_str("Ctrl+"); + shortcut_text.push_str(self.tui.modifier_translations.ctrl); + shortcut_text.push('+'); } if shortcut.modifiers_contains(kbmod::ALT) { - shortcut_text.push_str("Alt+"); + shortcut_text.push_str(self.tui.modifier_translations.alt); + shortcut_text.push('+'); } if shortcut.modifiers_contains(kbmod::SHIFT) { - shortcut_text.push_str("Shift+"); + shortcut_text.push_str(self.tui.modifier_translations.shift); + shortcut_text.push('+'); } shortcut_text.push(shortcut_letter); @@ -2707,6 +2735,7 @@ impl<'a> Tree<'a> { let mut r = root.borrow_mut(); r.id = ROOT_ID; r.classname = "root"; + r.attributes.focusable = true; r.attributes.focus_well = true; } Self { @@ -3227,9 +3256,11 @@ impl Node<'_> { total_height += total_gap_height; // Assign the total width/height to the table. - self.intrinsic_size.width = total_width; - self.intrinsic_size.height = total_height; - self.intrinsic_size_set = true; + if !self.intrinsic_size_set { + self.intrinsic_size.width = total_width; + self.intrinsic_size.height = total_height; + self.intrinsic_size_set = true; + } } _ => { let mut max_width = 0;