Multi-document support lfg

This commit contains a truly marvelous number of other changes,
which however the margin is not large enough to contain.
This commit is contained in:
Leonard Hecker 2025-04-28 18:39:19 +02:00
parent a9d1f0a94b
commit 08ccba35a4
20 changed files with 2188 additions and 1767 deletions

7
.gitignore vendored
View file

@ -1,10 +1,5 @@
*.profraw
*.user
.idea
.vs
CMakeSettings.json
bin
*.profraw
lcov.info
obj
out
target

4
.vscode/launch.json vendored
View file

@ -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"
],
}
]

View file

@ -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

View file

@ -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<io::Error> for Error {

312
src/bin/edit/documents.rs Normal file
View file

@ -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<Document>,
}
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<F: FnMut(&Document) -> 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<Point>) {
fn parse(s: &[u8]) -> Option<CoordType> {
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<Point>) {
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 }))
);
}
}

304
src/bin/edit/draw_editor.rs Normal file
View file

@ -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();
}

View file

@ -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<PathBuf> {
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);
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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",

520
src/bin/edit/main.rs Normal file
View file

@ -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<Self> {
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<bool> {
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;<color>;rgb:<r>/<g>/<b>`.
"4" => match splits.next().unwrap_or("").parse::<usize>() {
Ok(val) if val < 16 => &mut indexed_colors[val],
_ => continue,
},
// The response is `10;rgb:<r>/<g>/<b>`.
"10" => &mut indexed_colors[IndexedColor::Foreground as usize],
// The response is `11;rgb:<r>/<g>/<b>`.
"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);
}

143
src/bin/edit/state.rs Normal file
View file

@ -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<apperr::Error> 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<Vec<DisplayablePathBuf>>,
pub file_picker_overwrite_warning: Option<PathBuf>, // 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;
}
}

View file

@ -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();
}

View file

@ -346,16 +346,18 @@ impl DisplayableCString {
}
}
#[inline(always)]
#[allow(clippy::ptr_eq)]
pub fn opt_ptr<T>(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<T>(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<T: Copy>(dst: &mut Vec<T>, off: usize, src: &[T]) {
pub fn vec_replace<T: Copy>(dst: &mut Vec<T>, 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<u8>) -> 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<T: Clone>(dst: &mut Vec<T>, src: &[T]) {
dst.extend_from_slice(src);
}
pub fn string_from_utf8_lossy_owned(v: Vec<u8>) -> String {
if let Cow::Owned(string) = String::from_utf8_lossy(&v) {
string
} else {
unsafe { String::from_utf8_unchecked(v) }
}
}
pub fn file_read_uninit<T: Read>(
file: &mut T,
buf: &mut [MaybeUninit<u8>],

View file

@ -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}")
}
}

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -401,7 +401,7 @@ pub fn open_stdin_if_redirected() -> Option<File> {
}
}
pub fn canonicalize<P: AsRef<Path>>(path: P) -> apperr::Result<PathBuf> {
pub fn canonicalize(path: &Path) -> std::io::Result<PathBuf> {
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<NonNull<u8>> {
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(())
}
}

View file

@ -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;