diff --git a/src/bin/edit/draw_editor.rs b/src/bin/edit/draw_editor.rs index f0a5896c5ede..b5c1fdf6166a 100644 --- a/src/bin/edit/draw_editor.rs +++ b/src/bin/edit/draw_editor.rs @@ -63,7 +63,7 @@ fn draw_search(ctx: &mut Context, state: &mut State) { focus = StateSearchKind::Search; // If the selection is empty, focus the search input field. - // Otherwise, focus the replace input field, if it exists. + // 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; @@ -291,8 +291,9 @@ pub fn draw_goto_menu(ctx: &mut Context, state: &mut State) { let mut done = false; if let Some(doc) = state.documents.active_mut() { - ctx.modal_begin("goto", loc(LocId::FileGoto)); + ctx.modal_begin("goto-menu", loc(LocId::FileGoto)); { + ctx.scope_begin("goto", 1); if ctx.editline("goto-line", &mut state.goto_target) { state.goto_invalid = false; } @@ -304,7 +305,7 @@ pub fn draw_goto_menu(ctx: &mut Context, state: &mut State) { ctx.attr_intrinsic_size(Size { width: 24, height: 1 }); ctx.steal_focus(); - if ctx.consume_shortcut(vk::RETURN) { + if ctx.consume_action("submit", SmolString::new("submit").unwrap()) { match validate_goto_point(&state.goto_target) { Ok(point) => { let mut buf = doc.buffer.borrow_mut(); @@ -316,6 +317,7 @@ pub fn draw_goto_menu(ctx: &mut Context, state: &mut State) { } ctx.needs_rerender(); } + ctx.scope_end(); } done |= ctx.modal_end(); } else { diff --git a/src/bin/edit/main.rs b/src/bin/edit/main.rs index e01764330628..4a6fb17fd07b 100644 --- a/src/bin/edit/main.rs +++ b/src/bin/edit/main.rs @@ -77,7 +77,7 @@ fn run() -> apperr::Result<()> { // 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 + // `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()?; @@ -85,6 +85,8 @@ fn run() -> apperr::Result<()> { let mut input_parser = input::Parser::new(); let mut tui = Tui::new()?; + tui.declare_scope("goto", &[("back", vk::ESCAPE), ("submit", vk::RETURN)]); + let _restore = setup_terminal(&mut tui, &mut vt_parser); state.menubar_color_bg = oklab_blend( @@ -139,6 +141,8 @@ fn run() -> apperr::Result<()> { let input = input_iter.next(); let more = input.is_some(); let mut ctx = tui.create_context(input); + // ctx.precompute_layout(|ctx| draw(ctx, &mut state)); + ctx.compute_propagation_path(); draw(&mut ctx, &mut state); diff --git a/src/helpers.rs b/src/helpers.rs index b8e30a3c03c3..d3bafa5527a9 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -5,6 +5,8 @@ use std::alloc::Allocator; use std::cmp::Ordering; +use std::fmt::{Debug, Display}; +use std::hash::Hash; use std::io::Read; use std::mem::{self, MaybeUninit}; use std::ops::{Bound, Range, RangeBounds}; @@ -291,3 +293,79 @@ impl AsciiStringHelpers for str { p.len() <= s.len() && s[..p.len()].eq_ignore_ascii_case(p) } } + +pub enum StackStringError { + ContentTooLong, +} + +impl Debug for StackStringError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "desirable content is too long to fit in the string.") + } +} + +#[derive(Clone, Copy)] +pub struct StackString { + length: usize, + data: [u8; N], +} + +impl StackString { + pub fn new(src: &str) -> Result { + let bytes = src.bytes().collect::>(); + if bytes.len() > N { + return Err(StackStringError::ContentTooLong); + } + let mut data = [0u8; N]; + // Horrible mem copy + // TODO: Fix ASAP + unsafe { + let chunk = std::slice::from_raw_parts_mut(data.as_mut_ptr(), bytes.len()); + chunk.copy_from_slice(&bytes); + } + Ok(Self { length: bytes.len(), data }) + } + + pub fn as_str(&self) -> &str { + self.as_ref() + } +} + +impl AsRef for StackString { + fn as_ref(&self) -> &str { + // SAFETY: We constructed the string + unsafe { std::str::from_utf8_unchecked(&self.data[0..self.length]) } + } +} + +impl PartialEq for StackString { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } + + fn ne(&self, other: &Self) -> bool { + self.as_str() != other.as_str() + } +} + +impl Eq for StackString {} + +impl Hash for StackString { + fn hash(&self, state: &mut H) { + self.as_str().hash(state); + } +} + +impl Display for StackString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + ::fmt(self.as_str(), f) + } +} + +impl Debug for StackString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + ::fmt(self.as_str(), f) + } +} + +pub type SmolString = StackString<16>; diff --git a/src/tui.rs b/src/tui.rs index 87d2b4a28c63..325397dae98f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -144,6 +144,7 @@ //! ``` use std::arch::breakpoint; +use std::collections::HashMap; #[cfg(debug_assertions)] use std::collections::HashSet; use std::fmt::Write as _; @@ -169,6 +170,8 @@ type InputKey = input::InputKey; type InputMouseState = input::InputMouseState; type InputText<'input> = input::InputText<'input>; +pub type ActionId = SmolString; + /// Since [`TextBuffer`] creation and management is expensive, /// we cache instances of them for reuse between frames. /// This is used for [`Context::editline()`]. @@ -294,6 +297,11 @@ impl Default for ButtonStyle { } } +struct ActionScope { + name: &'static str, + actions: &'static [(&'static str, InputKey)], +} + /// There's two types of lifetimes the TUI code needs to manage: /// * Across frames /// * Per frame @@ -371,6 +379,10 @@ pub struct Tui { settling_have: i32, settling_want: i32, read_timeout: time::Duration, + + registered_scopes: Vec, + shortcut_overrides: Vec<(&'static str, &'static str, InputKey)>, + last_keyge: Option>>, } impl Tui { @@ -422,6 +434,10 @@ impl Tui { settling_have: 0, settling_want: 0, read_timeout: time::Duration::MAX, + + registered_scopes: Vec::with_capacity(16), + shortcut_overrides: Vec::with_capacity(16), + last_keyge: None }; Self::clean_node_path(&mut tui.mouse_down_node_path); Self::clean_node_path(&mut tui.focused_node_path); @@ -687,6 +703,12 @@ impl Tui { #[cfg(debug_assertions)] seen_ids: HashSet::new(), + + keyge: Default::default(), + visited_action_consumers: Default::default(), + last_propogation_path: Default::default(), + skipped_consumers: Default::default(), + current_boundary: Default::default(), } } @@ -721,6 +743,9 @@ impl Tui { unsafe { self.prev_tree = mem::transmute_copy(&ctx.tree); self.prev_node_map = NodeMap::new(mem::transmute(&self.arena_next), &self.prev_tree); + let mut keyge = HashMap::new(); + mem::swap(&mut ctx.keyge, &mut keyge); + self.last_keyge = Some(keyge); } let mut focus_path_pop_min = 0; @@ -1283,6 +1308,36 @@ impl Tui { self.focused_node_for_scrolling = focused_id; true } + + pub fn declare_scope( + &mut self, + name: &'static str, + actions: &'static [(&'static str, InputKey)], + ) { + self.registered_scopes.push(ActionScope { name, actions }) + } + + /// Returns true if shortcut was updated, false if scope+action does not exist. + pub fn reassign_shortcut(&mut self, scope: &str, name: &str, value: InputKey) -> bool { + // All of this is just to not require the caller to keep the values, since we already have this exact string. + let Some(s) = self.registered_scopes.iter().filter(|s| s.name == scope).next() else { + return false; + }; + let Some(action_stored) = s.actions.iter().filter(|a| a.0 == name).next() else { + return false; + }; + let scope_stored = s.name; + + self.shortcut_overrides.push((scope_stored, action_stored.0, value)); + true + } +} + +#[derive(Clone, Copy)] +struct ActionRequest { + scope: &'static str, + name: &'static str, + id: SmolString, } /// Context is a temporary object that is created for each frame. @@ -1308,6 +1363,13 @@ pub struct Context<'a, 'input> { #[cfg(debug_assertions)] seen_ids: HashSet, + + keyge: HashMap>, + visited_action_consumers: HashSet, + + last_propogation_path: Vec, + skipped_consumers: HashSet, + current_boundary: Option<&'a NodeCell<'a>>, } impl<'a> Drop for Context<'a, '_> { @@ -3304,6 +3366,160 @@ impl<'a> Context<'a, '_> { } self.attr_padding(Rect { left: 2, top: 0, right: 2, bottom: 0 }); } + + pub fn scope_begin(&mut self, name: &str, key: u64) { + let classname = "asb"; + let parent = self.tree.current_node; + let stored_scope = self.static_scope_name(name).expect("Invalidn scope name provided"); + + let mut id = hash_str(parent.borrow().id, classname); + if self.next_block_id_mixin != 0 { + id = hash(id, &self.next_block_id_mixin.to_ne_bytes()); + self.next_block_id_mixin = 0; + } + + // If this hits, you have tried to create a block with the same ID as a previous one + // somewhere up this call stack. Change the classname, or use next_block_id_mixin(). + // TODO: HashMap + #[cfg(debug_assertions)] + if !self.seen_ids.insert(id) { + panic!("Duplicate node ID: {id:x}"); + } + + let node = Tree::alloc_node(self.arena()); + { + let mut n = node.borrow_mut(); + n.id = id; + n.classname = classname; + n.content = NodeContent::ActionScopeBoundary(ActionScopeBoundaryContent { + scope: stored_scope, + key, + }) + } + + self.tree.push_child(node); + self.current_boundary = Some(node); + } + + pub fn scope_end(&mut self) { + self.tree.pop_stack(); + self.current_boundary = + self.current_boundary.and_then(|node| node.borrow().action_scope_boundary()) + } + + pub fn consume_action(&mut self, name: &str, id: ActionId) -> bool { + let current_boundary = + self.current_boundary.expect("`consume_action` called outside of scope."); + let current_boundary_id = current_boundary.borrow().id; + let stored_scope = + if let NodeContent::ActionScopeBoundary(ref con) = current_boundary.borrow().content { + con.scope + } else { + panic!("`current_boundary` unexpectedly contains non-boundary node") + }; + let stored_name = + self.static_action_name(stored_scope, name).expect("invalid action name passed"); + + if !self.visited_action_consumers.contains(&id) { + self.visited_action_consumers.insert(id); + let scope_action_requests; + if let Some(v) = self.keyge.get_mut(¤t_boundary_id) { + scope_action_requests = v + } else { + self.keyge.insert(current_boundary_id, HashMap::new()); + scope_action_requests = self.keyge.get_mut(¤t_boundary_id).unwrap(); + }; + scope_action_requests + .insert(stored_name, ActionRequest { scope: stored_scope, name: stored_name, id }); + } + + if self.skipped_consumers.contains(&id) { + panic!( + "Action id {id} cannot be evaluated: It was skipped because one {}", + "of the parents requested the shortcut before children did." + ) + } + + if !self.last_propogation_path.contains(&id) { + return false; + } + + loop { + let last = self.last_propogation_path.pop().expect("unreachable"); + if last == id { + break; + } + self.skipped_consumers.insert(last); + } + self.set_input_consumed(); + self.needs_rerender(); + true + } + + fn propagation_path(&self) -> Vec { + let Some(focused_node_id) = self.tui.focused_node_path.iter().last() else { + return vec![]; + }; + let Some(input) = self.input_keyboard else { + return vec![]; + }; + let Some(ref keyge) = self.tui.last_keyge else { + return vec![]; + }; + let mut path = vec![]; + let mut current = self.tui.prev_node_map.get(*focused_node_id).expect("unreachable"); + loop { + { + let cur = current.borrow(); + match cur.content { + NodeContent::ActionScopeBoundary(ref con) => { + let scope = + self.tui.registered_scopes.iter().find(|x| x.name == con.scope).expect( + "unreachable: scope was validated when was inserted into node tree.", + ); + let mut actions = scope.actions.to_vec(); + for (s, name, key) in self.tui.shortcut_overrides.iter() { + if *s != scope.name { + continue; + } + actions.iter_mut().find(|x| x.0 == *name).map(|x| x.1 = *key); + } + let Some(action) = actions.iter().find(|x| x.1 == input).map(|x| x.0) + else { + break; + }; + let Some(request) = keyge + .get(&cur.id) + .and_then(|x| x.iter().find(|x| *x.0 == action).map(|x| *x.1)) + else { + break; + }; + path.push(request.id); + } + _ => (), + } + } + let Some(c) = current.borrow().parent else { + break; + }; + current = c; + } + path.reverse(); + path + } + + fn static_scope_name(&self, name: &str) -> Option<&'static str> { + self.tui.registered_scopes.iter().find(|s| s.name == name).map(|s| s.name) + } + + fn static_action_name(&self, scope: &'static str, name: &str) -> Option<&'static str> { + let scope = self.tui.registered_scopes.iter().find(|s| s.name == scope)?; + Some(scope.actions.iter().find(|a| a.0 == name)?.0) + } + + pub fn compute_propagation_path(&mut self) { + self.last_propogation_path = self.propagation_path(); + } } /// See [`Tree::visit_all`]. @@ -3554,7 +3770,7 @@ impl<'a> NodeMap<'a> { } /// Gets a node by its ID. - fn get(&mut self, id: u64) -> Option<&'a NodeCell<'a>> { + fn get(&self, id: u64) -> Option<&'a NodeCell<'a>> { let shift = self.shift; let mask = self.mask; let mut slot = id >> shift; @@ -3646,6 +3862,12 @@ struct ScrollareaContent { thumb_height: CoordType, } +#[derive(Clone)] +struct ActionScopeBoundaryContent { + scope: &'static str, + key: u64, +} + /// NOTE: Must not contain items that require drop(). #[derive(Default)] enum NodeContent<'a> { @@ -3657,6 +3879,7 @@ enum NodeContent<'a> { Text(TextContent<'a>), Textarea(TextareaContent<'a>), Scrollarea(ScrollareaContent), + ActionScopeBoundary(ActionScopeBoundaryContent), } /// NOTE: Must not contain items that require drop(). @@ -3729,7 +3952,7 @@ struct Node<'a> { inner_clipped: Rect, // in screen-space, calculated during layout, restricted to the viewport } -impl Node<'_> { +impl<'a> Node<'a> { /// Given an outer rectangle (including padding and borders) of this node, /// this returns the inner rectangle (excluding padding and borders). fn outer_to_inner(&self, mut outer: Rect) -> Rect { @@ -3968,4 +4191,14 @@ impl Node<'_> { } } } + + fn action_scope_boundary(&self) -> Option<&'a NodeCell<'a>> { + if self.parent.is_none() { + return None; + } + if let NodeContent::ActionScopeBoundary(_) = self.parent.unwrap().borrow().content { + return Some(self.parent.unwrap()); + } + return self.parent.and_then(|node| node.borrow().action_scope_boundary()); + } }