mirror of
https://github.com/microsoft/edit.git
synced 2025-07-07 13:25:16 +00:00
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:
parent
a9d1f0a94b
commit
08ccba35a4
20 changed files with 2188 additions and 1767 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -1,10 +1,5 @@
|
|||
*.profraw
|
||||
*.user
|
||||
.idea
|
||||
.vs
|
||||
CMakeSettings.json
|
||||
bin
|
||||
*.profraw
|
||||
lcov.info
|
||||
obj
|
||||
out
|
||||
target
|
||||
|
|
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
@ -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"
|
||||
],
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
312
src/bin/edit/documents.rs
Normal 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
304
src/bin/edit/draw_editor.rs
Normal 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();
|
||||
}
|
258
src/bin/edit/draw_filepicker.rs
Normal file
258
src/bin/edit/draw_filepicker.rs
Normal 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);
|
||||
}
|
157
src/bin/edit/draw_menubar.rs
Normal file
157
src/bin/edit/draw_menubar.rs
Normal 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;
|
||||
}
|
||||
}
|
326
src/bin/edit/draw_statusbar.rs
Normal file
326
src/bin/edit/draw_statusbar.rs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
520
src/bin/edit/main.rs
Normal 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
143
src/bin/edit/state.rs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>],
|
||||
|
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
1678
src/main.rs
1678
src/main.rs
File diff suppressed because it is too large
Load diff
|
@ -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 {
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
63
src/tui.rs
63
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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue