From 98340f958b8fcf8fbc3e2d77fe502c08c925d80f Mon Sep 17 00:00:00 2001 From: altsem Date: Mon, 29 Sep 2025 21:31:09 +0200 Subject: [PATCH 1/4] refactor: new LayoutTree module to improve on ui headaches This is another attempt at introducing a ui that is more easy to work with. It is now composable via functions, and simplifies the UI code to just: ```rust layout.clear(); layout.stacked(OPTS, |layout| { screen::layout_screen( layout, frame.area().as_size(), state.screens.last().unwrap(), ); layout.vertical(OPTS.align_end(), |layout| { menu::layout_menu(layout, state); layout_command_log(layout, state); layout_prompt(layout, state); }); }); layout.compute([frame.area().width, frame.area().height]); for span in layout.iter() { let area = Rect { x: span.pos[0], y: span.pos[1], width: span.size[0], height: span.size[1], }; frame.render_widget(span.data, area); } ``` - [ ] The styling, particularly in the `screen`-module needs fixing. - [ ] Spans do wrap to new lines, breaking the editor (it assumes 1 item = 1 line) - [ ] Spans are truncated if too long, we should word/char-wrap instead. Perhaps feeding every single char to "layout" could work. - [ ] There's a lot of allocations going on, one way to amend this could be to look at removing the 'static lifetime from `app.layout_tree`. Since it is only used once per frame and cleared after. - [ ] The tui_prompt dependency should be easier to get rid of now. Eventually, we may get rid of Ratatui as well. --- src/app.rs | 9 +- src/items.rs | 40 +- src/screen/mod.rs | 119 ++-- src/ui.rs | 147 ++-- src/ui/layout/direction.rs | 18 + src/ui/layout/mod.rs | 641 ++++++++++++++++++ src/ui/layout/node.rs | 64 ++ ...gitu__ui__layout__tests__align_bottom.snap | 7 + .../gitu__ui__layout__tests__align_right.snap | 5 + .../gitu__ui__layout__tests__gitu_mockup.snap | 34 + ...tu__ui__layout__tests__horizontal_gap.snap | 5 + ..._ui__layout__tests__horizontal_layout.snap | 5 + ...tu__ui__layout__tests__nested_layouts.snap | 6 + ...yout__tests__out_of_bounds_horizontal.snap | 6 + ...s__out_of_bounds_horizontal_align_end.snap | 6 + ...layout__tests__out_of_bounds_vertical.snap | 6 + ...sts__out_of_bounds_vertical_align_end.snap | 6 + .../gitu__ui__layout__tests__single_text.snap | 6 + .../gitu__ui__layout__tests__stacked.snap | 5 + ...gitu__ui__layout__tests__vertical_gap.snap | 7 + ...u__ui__layout__tests__vertical_layout.snap | 7 + .../layzer__tests__align_bottom.snap | 7 + .../snapshots/layzer__tests__align_right.snap | 5 + .../snapshots/layzer__tests__gitu_mockup.snap | 34 + .../layzer__tests__horizontal_gap.snap | 5 + .../layzer__tests__horizontal_layout.snap | 5 + .../layzer__tests__nested_layouts.snap | 6 + ...yzer__tests__out_of_bounds_horizontal.snap | 6 + ...s__out_of_bounds_horizontal_align_end.snap | 6 + ...layzer__tests__out_of_bounds_vertical.snap | 6 + ...sts__out_of_bounds_vertical_align_end.snap | 6 + .../snapshots/layzer__tests__single_text.snap | 6 + .../snapshots/layzer__tests__stacked.snap | 5 + .../layzer__tests__vertical_gap.snap | 7 + .../layzer__tests__vertical_layout.snap | 7 + src/ui/layout/vec2.rs | 115 ++++ src/ui/menu.rs | 301 ++++---- 37 files changed, 1364 insertions(+), 312 deletions(-) create mode 100644 src/ui/layout/direction.rs create mode 100644 src/ui/layout/mod.rs create mode 100644 src/ui/layout/node.rs create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__gitu_mockup.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_gap.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_layout.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__nested_layouts.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__single_text.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_gap.snap create mode 100644 src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_layout.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__align_bottom.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__align_right.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__nested_layouts.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__single_text.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__stacked.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__vertical_gap.snap create mode 100644 src/ui/layout/snapshots/layzer__tests__vertical_layout.snap create mode 100644 src/ui/layout/vec2.rs diff --git a/src/app.rs b/src/app.rs index ceb6f061d5..6780e7d24c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,7 +20,9 @@ use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; use git2::Repository; use ratatui::layout::Size; +use ratatui::text::Span; use tui_prompts::State as _; +use ui::layout::LayoutTree; use crate::cli; use crate::cmd_log::CmdLog; @@ -59,6 +61,7 @@ pub(crate) struct State { pub(crate) struct App { pub state: State, + layout_tree: LayoutTree>, } impl App { @@ -107,6 +110,7 @@ impl App { file_watcher: None, needs_redraw: true, }, + layout_tree: LayoutTree::new(), }; app.state.file_watcher = app.init_file_watcher()?; @@ -214,7 +218,7 @@ impl App { pub fn redraw_now(&mut self, term: &mut Term) -> Res<()> { if self.state.screens.last_mut().is_some() { - term.draw(|frame| ui::ui(frame, &mut self.state)) + term.draw(|frame| ui::ui(frame, &mut self.state, &mut self.layout_tree)) .map_err(Error::Term)?; self.state.needs_redraw = false; @@ -383,7 +387,8 @@ impl App { cmd.stderr(Stdio::piped()); let log_entry = self.state.current_cmd_log.push_cmd(&cmd); - term.draw(|frame| ui::ui(frame, &mut self.state)) + + term.draw(|frame| ui::ui(frame, &mut self.state, &mut self.layout_tree)) .map_err(Error::Term)?; let mut child = cmd.spawn().map_err(Error::SpawnCmd)?; diff --git a/src/items.rs b/src/items.rs index d578117d0f..970abd36a4 100644 --- a/src/items.rs +++ b/src/items.rs @@ -31,8 +31,8 @@ pub(crate) struct Item { } impl Item { - pub fn to_line<'a>(&'a self, config: Rc) -> Line<'a> { - match &self.data { + pub fn to_line(&self, config: Rc) -> Line<'static> { + match self.data.clone() { ItemData::Raw(content) => Line::raw(content), ItemData::AllUnstaged(count) => Line::from(vec![ Span::styled("Unstaged changes", &config.style.section_header), @@ -52,7 +52,7 @@ impl Item { RefKind::Remote(remote) => (remote, &config.style.remote), }; - Line::from(vec![Span::raw(*prefix), Span::styled(reference, style)]) + Line::from(vec![Span::raw(prefix), Span::styled(reference, style)]) } ItemData::Commit { short_id, @@ -63,7 +63,7 @@ impl Item { iter::once(Span::styled(short_id, &config.style.hash)) .chain( associated_references - .iter() + .into_iter() .map(|reference| match reference { RefKind::Tag(tag) => Span::styled(tag, &config.style.tag), RefKind::Branch(branch) => { @@ -77,9 +77,12 @@ impl Item { .chain([Span::raw(summary)]), Span::raw(" "), )), - ItemData::File(path) => Line::styled(path.to_string_lossy(), &config.style.file_header), + ItemData::File(path) => Line::styled( + path.to_string_lossy().into_owned(), + &config.style.file_header, + ), ItemData::Delta { diff, file_i } => { - let file_diff = &diff.file_diffs[*file_i]; + let file_diff = &diff.file_diffs[file_i]; let content = format!( "{:8} {}", @@ -87,10 +90,10 @@ impl Item { match file_diff.header.status { Status::Renamed => format!( "{} -> {}", - &Rc::clone(diff).text[file_diff.header.old_file.clone()], - &Rc::clone(diff).text[file_diff.header.new_file.clone()] + &Rc::clone(&diff).text[file_diff.header.old_file.clone()], + &Rc::clone(&diff).text[file_diff.header.new_file.clone()] ), - _ => Rc::clone(diff).text[file_diff.header.new_file.clone()].to_string(), + _ => Rc::clone(&diff).text[file_diff.header.new_file.clone()].to_string(), } ); @@ -101,12 +104,11 @@ impl Item { file_i, hunk_i, } => { - let file_diff = &diff.file_diffs[*file_i]; - let hunk = &file_diff.hunks[*hunk_i]; - + let file_diff = &diff.file_diffs[file_i]; + let hunk = &file_diff.hunks[hunk_i]; let content = &diff.text[hunk.header.range.clone()]; - Line::styled(content, &config.style.hunk_header) + Line::styled(content.to_string(), &config.style.hunk_header) } ItemData::HunkLine { diff, @@ -116,12 +118,12 @@ impl Item { line_i, } => { let hunk_highlights = - highlight::highlight_hunk(self.id, &config, &Rc::clone(diff), *file_i, *hunk_i); + highlight::highlight_hunk(self.id, &config, &Rc::clone(&diff), file_i, hunk_i); - let hunk_content = &diff.hunk_content(*file_i, *hunk_i); + let hunk_content = &diff.hunk_content(file_i, hunk_i); let hunk_line = &hunk_content[line_range.clone()]; - let line_highlights = hunk_highlights.get_line_highlights(*line_i); + let line_highlights = hunk_highlights.get_line_highlights(line_i); Line::from_iter(line_highlights.iter().map(|(highlight_range, style)| { Span::styled( @@ -155,11 +157,11 @@ impl Item { Line::styled(content, &config.style.section_header) } ItemData::BranchStatus(upstream, ahead, behind) => { - let content = if *ahead == 0 && *behind == 0 { + let content = if ahead == 0 && behind == 0 { format!("Your branch is up to date with '{upstream}'.") - } else if *ahead > 0 && *behind == 0 { + } else if ahead > 0 && behind == 0 { format!("Your branch is ahead of '{upstream}' by {ahead} commit(s).",) - } else if *ahead == 0 && *behind > 0 { + } else if ahead == 0 && behind > 0 { format!("Your branch is behind '{upstream}' by {behind} commit(s).",) } else { format!( diff --git a/src/screen/mod.rs b/src/screen/mod.rs index 0cbb440389..a8c84b5d63 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -1,10 +1,6 @@ -use crate::item_data::ItemData; -use ratatui::{ - buffer::Buffer, - layout::{Rect, Size}, - text::Line, - widgets::Widget, -}; +use crate::ui::layout::{LayoutTree, OPTS}; +use crate::{item_data::ItemData, ui}; +use ratatui::{layout::Size, prelude::Span, style::Style, text::Line}; use crate::{Res, config::Config, items::hash}; @@ -338,7 +334,7 @@ impl Screen { self.nav_filter(target_line_i, NavMode::IncludeHunkLines) } - fn line_views(&self, area: Size) -> impl Iterator> { + fn line_views(&self, area: Size) -> impl Iterator + '_ { let scan_start = self.scroll.min(self.cursor); let scan_end = (self.scroll + area.height as usize).min(self.line_index.len()); let scan_highlight_range = scan_start..(scan_end); @@ -357,7 +353,6 @@ impl Screen { Some(LineView { item_index: *item_index, - item, display, highlighted: highlight_depth.is_some(), }) @@ -366,56 +361,80 @@ impl Screen { } } -struct LineView<'a> { +struct LineView { item_index: usize, - item: &'a Item, - display: Line<'a>, + display: Line<'static>, highlighted: bool, } -impl Widget for &Screen { - fn render(self, area: Rect, buf: &mut Buffer) { - let style = &self.config.style; +pub(crate) fn layout_screen(layout: &mut LayoutTree, size: Size, screen: &Screen) { + let style = &screen.config.style; - for (line_index, line) in self.line_views(area.as_size()).enumerate() { - let line_area = Rect { - x: 0, - y: line_index as u16, - width: buf.area.width, - height: 1, + layout.vertical(OPTS, |layout| { + for line in screen.line_views(size) { + let selected_line = screen.line_index[screen.cursor] == line.item_index; + let area_highlight = area_selection_highlgiht(style, &line); + let line_highlight = line_selection_highlight(style, &line, selected_line); + let gutter_char = if line.highlighted { + gutter_char(style, selected_line, area_highlight, line_highlight) + } else { + Span::raw(" ") }; - let indented_line_area = Rect { x: 1, ..line_area }; - - if line.highlighted { - buf.set_style(line_area, &style.selection_area); - - if self.line_index[self.cursor] == line.item_index { - buf.set_style(line_area, &style.selection_line); - } else { - buf[(0, line_index as u16)] - .set_char(style.selection_bar.symbol) - .set_style(&style.selection_bar); - } - } - - let line_width = line.display.width(); - - line.display.render(indented_line_area, buf); - let overflow = line_width > line_area.width as usize; + let line_spans = std::iter::once(gutter_char) + .chain( + line.display + .spans + .into_iter() + .map(|span| span.patch_style(area_highlight).patch_style(line_highlight)), + ) + .collect::>(); + + ui::layout_line(layout, line_spans); + + // TODO Do something about this + // if screen.is_collapsed(line.item) && line_width > 0 || overflow { + // let line_end = (indented_line_area.x + line_width).min(size.width - 1); + // buf[(line_end, line_index as u16)].set_char('…'); + // } + } + }); +} - let line_width = line_width as u16; +fn gutter_char( + style: &crate::config::StyleConfig, + selected_line: bool, + area_highlight: Style, + line_highlight: Style, +) -> Span<'static> { + if selected_line { + Span::styled( + style.cursor.symbol.to_string(), + Style::from(&style.cursor) + .patch(area_highlight) + .patch(line_highlight), + ) + } else { + Span::styled(style.selection_bar.symbol.to_string(), area_highlight) + } +} - if self.is_collapsed(line.item) && line_width > 0 || overflow { - let line_end = (indented_line_area.x + line_width).min(area.width - 1); - buf[(line_end, line_index as u16)].set_char('…'); - } +fn line_selection_highlight( + style: &crate::config::StyleConfig, + line: &LineView, + selected_line: bool, +) -> Style { + if line.highlighted && selected_line { + Style::from(&style.selection_line) + } else { + Style::new() + } +} - if self.line_index[self.cursor] == line.item_index { - buf[(0, line_index as u16)] - .set_char(style.cursor.symbol) - .set_style(&style.cursor); - } - } +fn area_selection_highlgiht(style: &crate::config::StyleConfig, line: &LineView) -> Style { + if line.highlighted { + Style::from(&style.selection_area) + } else { + Style::new() } } diff --git a/src/ui.rs b/src/ui.rs index 570c64e778..8ba5fcf776 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,106 +1,93 @@ use crate::app::State; +use crate::screen; +use layout::LayoutTree; +use layout::OPTS; use ratatui::Frame; use ratatui::prelude::*; use ratatui::style::Stylize; -use ratatui::widgets::*; -use std::rc::Rc; use tui_prompts::State as _; -use tui_prompts::TextPrompt; +pub(crate) mod layout; mod menu; -pub(crate) struct SizedWidget { - height: u16, - widget: W, -} +const CARET: &str = "\u{2588}"; -impl Widget for SizedWidget { - fn render(self, area: Rect, buf: &mut Buffer) { - self.widget.render(area, buf); - } -} +pub(crate) fn ui(frame: &mut Frame, state: &mut State, layout: &mut LayoutTree) { + layout.clear(); + + layout.stacked(OPTS, |layout| { + screen::layout_screen( + layout, + frame.area().as_size(), + state.screens.last().unwrap(), + ); + + layout.vertical(OPTS.align_end(), |layout| { + menu::layout_menu(layout, state); + layout_command_log(layout, state); + layout_prompt(layout, state); + }); + }); -impl StatefulWidget for SizedWidget { - type State = W::State; + layout.compute([frame.area().width, frame.area().height]); - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - self.widget.render(area, buf, state); + for span in layout.iter() { + let area = Rect { + x: span.pos[0], + y: span.pos[1], + width: span.size[0], + height: span.size[1], + }; + + frame.render_widget(span.data, area); } + + state.screens.last_mut().unwrap().size = frame.area().as_size(); } -pub(crate) fn ui(frame: &mut Frame, state: &mut State) { - let maybe_log = if !state.current_cmd_log.is_empty() { - let text: Text = state.current_cmd_log.format_log(&state.config); +fn layout_command_log(layout: &mut LayoutTree>, state: &mut State) { + if !state.current_cmd_log.is_empty() { + layout_text(layout, state.current_cmd_log.format_log(&state.config)); + } +} - Some(SizedWidget { - widget: Paragraph::new(text.clone()).block(popup_block()), - height: 1 + text.lines.len() as u16, - }) - } else { - None +fn layout_prompt(layout: &mut LayoutTree, state: &mut State) { + let Some(ref prompt_data) = state.prompt.data else { + return; }; - let maybe_prompt = state.prompt.data.as_ref().map(|prompt_data| SizedWidget { - height: 2, - widget: TextPrompt::new(prompt_data.prompt_text.clone()).with_block(popup_block()), - }); + let prompt_symbol = state.prompt.state.status().symbol(); - let maybe_menu = state.pending_menu.as_ref().and_then(|menu| { - if menu.is_hidden { - None - } else { - Some(menu::MenuWidget::new( - Rc::clone(&state.config), - menu, - state.screens.last().unwrap().get_selected_item(), - state, - )) - } - }); + layout.horizontal(OPTS, |layout| { + let line = Line::from(vec![ + prompt_symbol, + " ".into(), + Span::raw(prompt_data.prompt_text.to_string()), + " › ".cyan().dim(), + Span::raw(state.prompt.state.value().to_string()), + Span::raw(CARET), + ]); - let layout = Layout::new( - Direction::Vertical, - [ - Constraint::Min(1), - widget_height(&maybe_prompt), - widget_height(&maybe_menu), - widget_height(&maybe_log), - ], - ) - .split(frame.area()); - - frame.render_widget(state.screens.last().unwrap(), layout[0]); - - maybe_render(maybe_menu, frame, layout[2]); - maybe_render(maybe_log, frame, layout[3]); - - if let Some(prompt) = maybe_prompt { - frame.render_stateful_widget(prompt, layout[1], &mut state.prompt.state); - let (cx, cy) = state.prompt.state.cursor(); - frame.set_cursor_position((cx, cy)); - } - - state.screens.last_mut().unwrap().size = layout[0].as_size(); + layout_line(layout, line); + }); } -fn popup_block() -> Block<'static> { - Block::new() - .borders(Borders::TOP) - .border_style(Style::new().dim()) - .border_type(ratatui::widgets::BorderType::Plain) +pub(crate) fn layout_text<'a>(layout: &mut LayoutTree>, text: Text<'a>) { + layout.vertical(OPTS, |layout| { + for line in text { + layout_line(layout, line); + } + }); } -fn widget_height(maybe_prompt: &Option>) -> Constraint { - Constraint::Length( - maybe_prompt - .as_ref() - .map(|widget| widget.height) - .unwrap_or(0), - ) +pub(crate) fn layout_line<'a>(layout: &mut LayoutTree>, line: Line<'a>) { + layout.horizontal(OPTS, |layout| { + for span in line { + layout_span(layout, span); + } + }); } -fn maybe_render(maybe_menu: Option>, frame: &mut Frame, area: Rect) { - if let Some(menu) = maybe_menu { - frame.render_widget(menu, area); - } +pub(crate) fn layout_span<'a>(layout: &mut LayoutTree>, span: Span<'a>) { + layout.leaf_with_size(span.clone(), [span.width() as u16, 1]); } diff --git a/src/ui/layout/direction.rs b/src/ui/layout/direction.rs new file mode 100644 index 0000000000..8ad90ca276 --- /dev/null +++ b/src/ui/layout/direction.rs @@ -0,0 +1,18 @@ +use super::vec2::Vec2; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Direction { + Horizontal, + Vertical, + Stacked, +} + +impl Direction { + pub(crate) fn axis(&self) -> Vec2 { + match self { + Direction::Horizontal => Vec2(1, 0), + Direction::Vertical => Vec2(0, 1), + Direction::Stacked => Vec2(0, 0), + } + } +} diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs new file mode 100644 index 0000000000..deb397e4a3 --- /dev/null +++ b/src/ui/layout/mod.rs @@ -0,0 +1,641 @@ +mod direction; +mod node; +mod vec2; + +use unicode_segmentation::UnicodeSegmentation; + +use direction::Direction; +use node::{Align, *}; +use vec2::Vec2; + +pub use node::OPTS; + +const ROOT_INDEX: usize = usize::MAX; + +#[derive(Debug)] +pub struct LayoutTree { + data: Vec>, + index: TreeIndex, +} + +#[derive(Debug)] +pub(crate) struct TreeIndex { + parents: Vec, + current_parent: usize, +} + +impl TreeIndex { + pub(crate) fn new() -> Self { + TreeIndex { + parents: Vec::new(), + current_parent: ROOT_INDEX, + } + } + + pub(crate) fn iter_roots(&self) -> impl Iterator { + self.parents + .first() + .map(|_node| 0) + .into_iter() + .chain(self.iter_siblings_after(0)) + } + + pub(crate) fn iter(&self) -> impl Iterator { + 0..self.parents.len() + } + + pub(crate) fn iter_siblings_after(&self, index: usize) -> impl Iterator { + let start = index + 1; + let parent_index = self.parents[index]; + + self.parents[start..] + .iter() + .take_while(move |&&parent| parent >= parent_index) + .enumerate() + .filter(move |&(_i, &parent)| parent == parent_index) + .map(move |(i, _depth)| start + i) + } + + pub(crate) fn iter_children(&self, index: usize) -> impl Iterator { + let start = index + 1; + + self.parents[start..] + .iter() + .take_while(move |&&parent| parent >= index) + .enumerate() + .filter(move |&(_i, &parent)| parent == index) + .map(move |(i, _depth)| start + i) + } + + pub(crate) fn iter_all_children(&self, index: usize) -> impl Iterator { + let start = index + 1; + + self.parents[start..] + .iter() + .take_while(move |&&parent| parent >= index) + .enumerate() + .map(move |(i, _depth)| start + i) + } +} + +impl LayoutTree { + pub fn new() -> Self { + LayoutTree { + data: Vec::new(), + index: TreeIndex::new(), + } + } + + pub fn clear(&mut self) { + self.data.clear(); + self.index.parents.clear(); + self.index.current_parent = ROOT_INDEX; + } + + pub(crate) fn add(&mut self, data: Node) { + self.data.push(data); + self.index.parents.push(self.index.current_parent); + } + + pub(crate) fn add_with_children(&mut self, data: Node, insert_fn: F) { + self.add(data); + let our_parent = self.index.current_parent; + self.index.current_parent = self.index.parents.len() - 1; + + insert_fn(self); + + self.index.current_parent = our_parent; + } +} + +impl Default for LayoutTree { + fn default() -> Self { + Self::new() + } +} + +impl LayoutTree<&'static str> { + /// Add a text leaf, calculating size based on string length + pub fn text(&mut self, text: &'static str) -> &mut Self { + let width = text.graphemes(true).count(); + self.leaf_with_size(text, [width as u16, 1]); + self + } +} + +impl LayoutTree { + pub fn horizontal)>( + &mut self, + opts: Opts, + layout_fn: F, + ) -> &mut Self { + self.nest( + Opts { + dir: Direction::Horizontal, + ..opts + }, + layout_fn, + ) + } + + pub fn vertical)>( + &mut self, + opts: Opts, + layout_fn: F, + ) -> &mut Self { + self.nest( + Opts { + dir: Direction::Vertical, + ..opts + }, + layout_fn, + ) + } + + pub fn stacked)>( + &mut self, + opts: Opts, + layout_fn: F, + ) -> &mut Self { + self.nest( + Opts { + dir: Direction::Stacked, + ..opts + }, + layout_fn, + ) + } + + pub fn leaf(&mut self, data: T) -> &mut Self { + self.leaf_with_size(data, [1, 1]) + } + + pub fn leaf_with_size(&mut self, data: T, size: [u16; 2]) -> &mut Self { + self.add(node::Node { + data: Some(data), + opts: OPTS, + size: size.into(), + pos: None, + }); + + self + } + + fn nest(&mut self, options: Opts, layout_fn: F) -> &mut Self + where + F: FnOnce(&mut LayoutTree), + { + self.add_with_children( + node::Node { + data: None, + opts: options, + size: Vec2(0, 0), + pos: None, + }, + layout_fn, + ); + + self + } + + pub fn compute(&mut self, total_size: [u16; 2]) { + let Some(root) = self.index.iter_roots().next() else { + panic!("no root"); + }; + + self.compute_subtree(root, Vec2(0, 0), total_size.into()); + } + + fn compute_subtree(&mut self, parent: usize, start: Vec2, total_size: Vec2) { + let Opts { + dir, gap, align, .. + } = self.data[parent].opts; + + let mut current_child = self.index.iter_children(parent).next(); + let (mut cursor, mut children_size) = (start, Vec2(0, 0)); + + while let Some(child) = current_child { + self.compute_subtree(child, cursor, total_size); + + let child_data = &mut self.data[child]; + + if (cursor + child_data.size).fits(total_size) { + child_data.pos = Some(cursor); + } else { + // Child doesn't fit where cursor currently is + let next_line = start * dir.axis() + (cursor + child_data.size) * dir.axis().flip(); + + if (next_line + child_data.size).fits(total_size) { + // Fits next line + cursor = next_line; + child_data.pos = Some(cursor); + } else if (cursor + Vec2(1, 1)).fits(total_size) { + // We can fit at least one cell where the cursor currently is + child_data.pos = Some(cursor); + child_data.size = child_data.size.min(total_size - cursor); + } else if (next_line + Vec2(1, 1)).fits(total_size) { + // We can fit at least one cell on the next line + child_data.pos = Some(cursor); + child_data.size = child_data.size.min(total_size - cursor); + } else { + // There's absolutely no room left + child_data.pos = None; + child_data.size = Vec2(0, 0); + } + } + + children_size = children_size.max(cursor - start + child_data.size); + cursor += dir.axis() * (Vec2(gap, gap) + child_data.size); + + current_child = self.index.iter_siblings_after(child).next(); + } + + if align == Align::End { + let remaining_size = total_size - start - children_size; + let shift = remaining_size * dir.axis(); + + for child in self.index.iter_all_children(parent) { + if let Some(ref mut pos) = self.data[child].pos { + *pos += shift; + } + } + + children_size += shift; + } + + let parent_data = &mut self.data[parent]; + parent_data.size = parent_data.size.max(children_size); + } + + pub fn iter(&self) -> impl Iterator> { + self.index.iter().filter_map(|index| { + let Node { + data: Some(data), + opts: _, + size, + pos, + } = &self.data[index] + else { + return None; + }; + + Some(LayoutItem { + data: data.clone(), + pos: (*pos)?.into(), + size: (*size).into(), + }) + }) + } +} + +#[derive(Debug)] +pub struct LayoutItem { + pub data: T, + pub pos: [u16; 2], + pub size: [u16; 2], +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Render the layout to a string for testing-purposes, does not support unicode + fn render_to_string(layout: LayoutTree<&'static str>) -> String { + let Some(root) = layout.index.iter_roots().next() else { + panic!("no root"); + }; + let node = &layout.data[root]; + let mut grid = vec![vec![' '; node.size.0 as usize]; node.size.1 as usize]; + + // Fill the grid with positioned content + for LayoutItem { data, pos, size } in layout.iter() { + let content_str = data.to_string(); + if pos[1] < node.size.1 { + for (char_idx, ch) in content_str[..size[0] as usize].chars().enumerate() { + let x = pos[0] + char_idx as u16; + grid[pos[1] as usize][x as usize] = ch; + } + } + } + + grid.into_iter() + .map(|row| row.into_iter().collect::().trim_end().to_string()) + .collect::>() + .join("\n") + } + + #[test] + fn single_text() { + let mut layout = LayoutTree::new(); + + layout.vertical(OPTS, |layout| { + layout.text("Hello"); + layout.text("lol"); + }); + + layout.compute([5, 2]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn horizontal_layout() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS, |layout| { + layout.text("A"); + layout.text("BB"); + layout.text("CCC"); + }); + + layout.compute([6, 1]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn vertical_layout() { + let mut layout = LayoutTree::new(); + + layout.vertical(OPTS, |layout| { + layout.text("First"); + layout.text("Second"); + layout.text("Third"); + }); + + layout.compute([6, 3]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn nested_layouts() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS, |layout| { + // 0 + layout.vertical(OPTS, |layout| { + // 1 + layout.text("A"); // 2 + layout.text("B"); // 3 + }); + layout.vertical(OPTS, |layout| { + // 4 + layout.text("C"); // 5 + layout.text("D"); // 6 + }); + }); + + layout.compute([2, 2]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn clear_layout() { + let mut layout = LayoutTree::new(); + + layout.text("Test"); + + layout.clear(); + assert_eq!(layout.iter().count(), 0); + } + + #[test] + fn out_of_bounds_horizontal() { + let mut layout = LayoutTree::new(); + + layout.vertical(OPTS, |layout| { + layout.horizontal(OPTS, |layout| { + layout.text("12345"); + layout.text("The very start of this will be visible (a T)"); + }); + layout.horizontal(OPTS, |layout| { + layout.text("123456"); + layout.text("This is completely outside of the layout and ignored"); + }); + }); + + layout.compute([6, 4]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn out_of_bounds_vertical() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS, |layout| { + layout.vertical(OPTS, |layout| { + layout.text("1"); + layout.text("2"); + }); + layout.vertical(OPTS, |layout| { + layout.text("1"); + layout.text("2"); + layout.text("X"); + }); + }); + + layout.compute([2, 2]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn out_of_bounds_horizontal_align_end() { + let mut layout = LayoutTree::new(); + + layout.vertical(OPTS, |layout| { + layout.horizontal(OPTS.align_end(), |layout| { + layout.text("12345"); + layout.text("The very start of this will be visible (a T)"); + }); + layout.horizontal(OPTS.align_end(), |layout| { + layout.text("123456"); + layout.text("This is completely outside of the layout and ignored"); + }); + }); + + layout.compute([6, 4]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn out_of_bounds_vertical_align_end() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS, |layout| { + layout.vertical(OPTS.align_end(), |layout| { + layout.text("1"); + layout.text("2"); + layout.text("X"); + }); + }); + + layout.compute([1, 2]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn unicode_text_width() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS, |layout| { + layout.text("café").text("naïve"); + }); + + layout.compute([10, 1]); + let items: Vec<_> = layout.iter().collect(); + assert_eq!(items[0].size, [4, 1]); // café has 4 graphemes + } + + #[test] + fn horizontal_gap() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS.gap(2), |layout| { + layout.text("one"); + layout.text("two"); + }); + + layout.compute([8, 1]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn vertical_gap() { + let mut layout = LayoutTree::new(); + + layout.vertical(OPTS.gap(1), |layout| { + layout.text("one"); + layout.text("two"); + }); + + layout.compute([3, 3]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn stacked() { + let mut layout = LayoutTree::new(); + + layout.stacked(OPTS, |layout| { + layout.text("This is under. (leftovers here)"); + layout.text("This is on top"); + }); + + layout.compute([31, 2]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn align_bottom() { + let mut layout = LayoutTree::new(); + + layout.stacked(OPTS, |layout| { + layout.vertical(OPTS, |layout| { + layout.text("Stack 1"); + }); + + layout.vertical(OPTS.align_end(), |layout| { + layout.text("Stack 2, bottom aligned"); + }); + }); + + layout.compute([30, 3]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn align_right() { + let mut layout = LayoutTree::new(); + + layout.horizontal(OPTS.align_end().gap(1), |layout| { + layout.text("Aligned"); + layout.text("to"); + layout.text("the"); + layout.text("right"); + }); + + layout.compute([40, 1]); + insta::assert_snapshot!(render_to_string(layout)); + } + + #[test] + fn gitu_mockup() { + let mut layout = LayoutTree::new(); + + layout.stacked(OPTS, |layout| { + layout.vertical(OPTS.gap(1), |layout| { + layout.vertical(OPTS, |layout| { + layout.text("On branch master"); + layout.vertical(OPTS, |layout| { + layout.text("Your branch is up to date with 'origin/master'"); + }); + }); + + layout.vertical(OPTS, |layout| { + layout.text("Recent commits"); + layout.vertical(OPTS, |layout| { + layout.text("b3492a8 master origin/master chore: update dependencies"); + layout.text("013844c refactor: appease linter"); + layout.text("5536ea3 feat: Show the diff on the stash detail screen"); + }); + }); + }); + + layout.vertical(OPTS.align_end(), |layout| { + layout.text("───────────────────────────────────────────────────────────────"); + + layout.horizontal(OPTS.gap(2), |layout| { + layout.vertical(OPTS, |layout| { + layout.text("Help"); + layout.text("Y Show Refs"); + layout.text(" Toggle section"); + layout.text("k/ Up "); + layout.text("j/ Down"); + layout.text("/ Up line"); + layout.text("/ Down line"); + layout.text("/ Prev section"); + layout.text("/ Next section"); + layout.text("/ Parent section"); + layout.text(" Half page up"); + layout.text(" Half page down"); + layout.text("g Refresh"); + layout.text("q/ Quit/Close"); + }); + layout.vertical(OPTS, |layout| { + layout.text("Submenu"); + layout.text("b Branch"); + layout.text("c Commit"); + layout.text("f Fetch"); + layout.text("h/? Help"); + layout.text("l Log"); + layout.text("M Remote"); + layout.text("F Pull"); + layout.text("P Push"); + layout.text("r Rebase"); + layout.text("X Reset"); + layout.text("V Revert"); + layout.text("z Stash"); + layout.text(""); + }); + layout.vertical(OPTS, |layout| { + layout.text("@@ -271,7 +271,7"); + layout.text("s Stage"); + layout.text("u Unstage"); + layout.text(" Show"); + layout.text("K Discard"); + layout.text(""); + layout.text(""); + layout.text(""); + layout.text(""); + layout.text(""); + layout.text(""); + layout.text(""); + layout.text(""); + layout.text(""); + }); + }); + }); + }); + + layout.compute([80, 30]); + insta::assert_snapshot!(render_to_string(layout)); + } +} diff --git a/src/ui/layout/node.rs b/src/ui/layout/node.rs new file mode 100644 index 0000000000..c431918760 --- /dev/null +++ b/src/ui/layout/node.rs @@ -0,0 +1,64 @@ +use super::vec2::Vec2; + +use super::direction::Direction; + +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum Align { + Start, + End, +} + +pub const OPTS: Opts = Opts { + dir: Direction::Horizontal, + gap: 0, + align: Align::Start, +}; + +#[derive(Debug, Copy, Clone)] +pub struct Opts { + /// Layout direction for children of this node. + pub(crate) dir: Direction, + /// Aligns elements towards the start/end (depending on direction). + /// Has no effect for Direction::Stacked. + pub(crate) align: Align, + /// The space between each direct child of this node. + pub(crate) gap: u16, +} + +impl Default for Opts { + fn default() -> Self { + OPTS + } +} + +impl Opts { + pub fn gap(self, gap: u16) -> Self { + Self { gap, ..self } + } + + pub fn align_start(self) -> Opts { + Self { + align: Align::Start, + ..self + } + } + + pub fn align_end(self) -> Opts { + Self { + align: Align::End, + ..self + } + } +} + +#[derive(Debug)] +pub(crate) struct Node { + pub(crate) data: Option, + /// layout options + pub(crate) opts: Opts, + /// space actually occupied by this node, updated as nodes are added + pub(crate) size: Vec2, + /// Offset from parent's top-left corner, updated as nodes are added. + /// This will remain `None` if there's no valid position for the element. + pub(crate) pos: Option, +} diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap new file mode 100644 index 0000000000..ad05c79146 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__align_bottom.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +Stack 1 + +Stack 2, bottom aligned diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap new file mode 100644 index 0000000000..be6d930fa4 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__align_right.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- + Aligned to the right diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__gitu_mockup.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__gitu_mockup.snap new file mode 100644 index 0000000000..2082ebce09 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__gitu_mockup.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +On branch master +Your branch is up to date with 'origin/master' + +Recent commits +b3492a8 master origin/master chore: update dependencies +013844c refactor: appease linter +5536ea3 feat: Show the diff on the stash detail screen + + + + + + + + +───────────────────── +Help Submenu @@ -271,7 +271,7 +Y Show Refs b Branch s Stage + Toggle section c Commit u Unstage +k/ Up f Fetch Show +j/ Down h/? Help K Discard +/ Up line l Log +/ Down line M Remote +/ Prev section F Pull +/ Next section P Push +/ Parent section r Rebase + Half page up X Reset + Half page down V Revert +g Refresh z Stash +q/ Quit/Close diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_gap.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_gap.snap new file mode 100644 index 0000000000..1d0a14bb7f --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_gap.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +one two diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_layout.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_layout.snap new file mode 100644 index 0000000000..e7a68144fd --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__horizontal_layout.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +ABBCCC diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__nested_layouts.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__nested_layouts.snap new file mode 100644 index 0000000000..dd3a098c13 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__nested_layouts.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +AC +BD diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal.snap new file mode 100644 index 0000000000..2b5b1ebf1f --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +12345T +123456 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap new file mode 100644 index 0000000000..2b5b1ebf1f --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_horizontal_align_end.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +12345T +123456 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical.snap new file mode 100644 index 0000000000..1664321387 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +11 +22 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap new file mode 100644 index 0000000000..57ef6dbc11 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__out_of_bounds_vertical_align_end.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +1 +2 diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__single_text.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__single_text.snap new file mode 100644 index 0000000000..012b79d553 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__single_text.snap @@ -0,0 +1,6 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +Hello +lol diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap new file mode 100644 index 0000000000..1d60b94344 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__stacked.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +This is on top (leftovers here) diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_gap.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_gap.snap new file mode 100644 index 0000000000..06745729b5 --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_gap.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +one + +two diff --git a/src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_layout.snap b/src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_layout.snap new file mode 100644 index 0000000000..e8849ef2ff --- /dev/null +++ b/src/ui/layout/snapshots/gitu__ui__layout__tests__vertical_layout.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/layout/mod.rs +expression: render_to_string(layout) +--- +First +Second +Third diff --git a/src/ui/layout/snapshots/layzer__tests__align_bottom.snap b/src/ui/layout/snapshots/layzer__tests__align_bottom.snap new file mode 100644 index 0000000000..edb54814c5 --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__align_bottom.snap @@ -0,0 +1,7 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +Stack 1 + +Stack 2, bottom aligned diff --git a/src/ui/layout/snapshots/layzer__tests__align_right.snap b/src/ui/layout/snapshots/layzer__tests__align_right.snap new file mode 100644 index 0000000000..2c6933403e --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__align_right.snap @@ -0,0 +1,5 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- + Aligned to the right diff --git a/src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap b/src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap new file mode 100644 index 0000000000..261d0c16e2 --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__gitu_mockup.snap @@ -0,0 +1,34 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +On branch master +Your branch is up to date with 'origin/master' + +Recent commits +b3492a8 master origin/master chore: update dependencies +013844c refactor: appease linter +5536ea3 feat: Show the diff on the stash detail screen + + + + + + + + +─────────────────────────────────────────────────────────────── +Help Submenu @@ -271,7 +271,7 +Y Show Refs b Branch s Stage + Toggle section c Commit u Unstage +k/ Up f Fetch Show +j/ Down h/? Help K Discard +/ Up line l Log +/ Down line M Remote +/ Prev section F Pull +/ Next section P Push +/ Parent section r Rebase + Half page up X Reset + Half page down V Revert +g Refresh z Stash +q/ Quit/Close diff --git a/src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap b/src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap new file mode 100644 index 0000000000..b59f3563aa --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__horizontal_gap.snap @@ -0,0 +1,5 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- +one two diff --git a/src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap b/src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap new file mode 100644 index 0000000000..044ce64164 --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__horizontal_layout.snap @@ -0,0 +1,5 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- +ABBCCC diff --git a/src/ui/layout/snapshots/layzer__tests__nested_layouts.snap b/src/ui/layout/snapshots/layzer__tests__nested_layouts.snap new file mode 100644 index 0000000000..3be4426c4d --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__nested_layouts.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- +AC +BD diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap new file mode 100644 index 0000000000..3041a245e8 --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +12345T +123456 diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap new file mode 100644 index 0000000000..3041a245e8 --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_horizontal_align_end.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +12345T +123456 diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap new file mode 100644 index 0000000000..0965fe7deb --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +11 +22 diff --git a/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap new file mode 100644 index 0000000000..c087e04691 --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__out_of_bounds_vertical_align_end.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +1 +2 diff --git a/src/ui/layout/snapshots/layzer__tests__single_text.snap b/src/ui/layout/snapshots/layzer__tests__single_text.snap new file mode 100644 index 0000000000..078f18babb --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__single_text.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: render_to_string(layout) +--- +Hello +lol diff --git a/src/ui/layout/snapshots/layzer__tests__stacked.snap b/src/ui/layout/snapshots/layzer__tests__stacked.snap new file mode 100644 index 0000000000..d824f46d8a --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__stacked.snap @@ -0,0 +1,5 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- +This is on top (leftovers here) diff --git a/src/ui/layout/snapshots/layzer__tests__vertical_gap.snap b/src/ui/layout/snapshots/layzer__tests__vertical_gap.snap new file mode 100644 index 0000000000..8d59ebc8aa --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__vertical_gap.snap @@ -0,0 +1,7 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- +one + +two diff --git a/src/ui/layout/snapshots/layzer__tests__vertical_layout.snap b/src/ui/layout/snapshots/layzer__tests__vertical_layout.snap new file mode 100644 index 0000000000..916de116de --- /dev/null +++ b/src/ui/layout/snapshots/layzer__tests__vertical_layout.snap @@ -0,0 +1,7 @@ +--- +source: src/lib.rs +expression: layout.render_to_string() +--- +First +Second +Third diff --git a/src/ui/layout/vec2.rs b/src/ui/layout/vec2.rs new file mode 100644 index 0000000000..63c5dab8d9 --- /dev/null +++ b/src/ui/layout/vec2.rs @@ -0,0 +1,115 @@ +use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Rem, RemAssign, Sub, SubAssign}; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct Vec2(pub(crate) u16, pub(crate) u16); + +impl std::fmt::Debug for Vec2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("Vec2({}, {})", self.0, self.1)) + } +} + +impl From<[u16; 2]> for Vec2 { + fn from([x, y]: [u16; 2]) -> Self { + Self(x, y) + } +} + +impl From for [u16; 2] { + fn from(Vec2(x, y): Vec2) -> Self { + [x, y] + } +} + +impl Add for Vec2 { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0, self.1 + rhs.1) + } +} + +impl Sub for Vec2 { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0 - rhs.0, self.1 - rhs.1) + } +} + +impl Mul for Vec2 { + type Output = Self; + + fn mul(self, rhs: Self) -> Self::Output { + Self(self.0 * rhs.0, self.1 * rhs.1) + } +} + +impl Div for Vec2 { + type Output = Self; + + fn div(self, rhs: Self) -> Self::Output { + Self(self.0 / rhs.0, self.1 / rhs.1) + } +} + +impl Rem for Vec2 { + type Output = Self; + + fn rem(self, rhs: Self) -> Self::Output { + Self(self.0 % rhs.0, self.1 % rhs.1) + } +} + +impl AddAssign for Vec2 { + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + self.1 += rhs.1; + } +} + +impl SubAssign for Vec2 { + fn sub_assign(&mut self, rhs: Self) { + self.0 -= rhs.0; + self.1 -= rhs.1; + } +} + +impl MulAssign for Vec2 { + fn mul_assign(&mut self, rhs: Self) { + self.0 *= rhs.0; + self.1 *= rhs.1; + } +} + +impl DivAssign for Vec2 { + fn div_assign(&mut self, rhs: Self) { + self.0 /= rhs.0; + self.1 /= rhs.1; + } +} + +impl RemAssign for Vec2 { + fn rem_assign(&mut self, rhs: Self) { + self.0 %= rhs.0; + self.1 %= rhs.1; + } +} + +impl Vec2 { + pub(crate) fn max(self, rhs: Self) -> Self { + Self(self.0.max(rhs.0), self.1.max(rhs.1)) + } + + pub(crate) fn min(self, rhs: Self) -> Self { + Self(self.0.min(rhs.0), self.1.min(rhs.1)) + } + + pub(crate) fn fits(&self, other: Self) -> bool { + self.0 <= other.0 && self.1 <= other.1 + } + + pub(crate) fn flip(&self) -> Self { + Self(self.1, self.0) + } +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 2fbc6e2392..db8745f551 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,174 +1,153 @@ use std::rc::Rc; -use super::SizedWidget; -use crate::{app::State, config::Config, items::Item, menu::PendingMenu, ops::Op}; +use crate::ui::layout::{LayoutTree, OPTS}; +use crate::{app::State, ops::Op, ui::layout_line}; use itertools::Itertools; use ratatui::{ - buffer::Buffer, - layout::{Constraint, Rect}, style::Style, text::{Line, Span}, - widgets::{Row, Table, Widget}, }; -pub(crate) struct MenuWidget<'a> { - table: Table<'a>, -} - -impl<'a> MenuWidget<'a> { - pub fn new( - config: Rc, - pending: &'a PendingMenu, - item: &'a Item, - state: &'a State, - ) -> SizedWidget { - let style = &config.style; - - let arg_binds = config.bindings.arg_list(pending).collect::>(); - - let non_target_binds = config - .bindings - .list(&pending.menu) - .filter(|keybind| !keybind.op.clone().implementation().is_target_op()) - .collect::>(); - - let mut pending_binds_column = vec![]; - pending_binds_column.push(Line::styled(format!("{}", pending.menu), &style.command)); - for (op, binds) in non_target_binds - .iter() - .chunk_by(|bind| &bind.op) - .into_iter() - .filter(|(op, _binds)| !matches!(op, Op::OpenMenu(_))) - { - pending_binds_column.push(Line::from(vec![ - Span::styled( - binds.into_iter().map(|bind| &bind.raw).join("/"), - &style.hotkey, - ), - Span::styled( - format!(" {}", op.clone().implementation().display(state)), - Style::new(), - ), - ])); - } - - let menus = non_target_binds - .iter() - .filter(|bind| matches!(bind.op, Op::OpenMenu(_))) - .collect::>(); - - let mut menu_binds_column = vec![]; - if !menus.is_empty() { - menu_binds_column.push(Line::styled("Submenu", &style.command)); - } - for (op, binds) in menus.iter().chunk_by(|bind| &bind.op).into_iter() { - let Op::OpenMenu(menu) = op else { - unreachable!(); - }; - - menu_binds_column.push(Line::from(vec![ - Span::styled( - binds.into_iter().map(|bind| &bind.raw).join("/"), - &style.hotkey, - ), - Span::styled(format!(" {menu}"), Style::new()), - ])); - } - - let mut right_column = vec![]; - let target_binds = config - .bindings - .list(&pending.menu) - .filter(|keybind| keybind.op.clone().implementation().is_target_op()) - .filter(|keybind| { - keybind - .op - .clone() - .implementation() - .get_action(&item.data) - .is_some() - }) - .collect::>(); - - if !target_binds.is_empty() { - right_column.push(item.to_line(Rc::clone(&config))); - } - - for bind in target_binds { - right_column.push(Line::from(vec![ - Span::styled(bind.raw.clone(), &style.hotkey), - Span::styled( - format!(" {}", bind.op.clone().implementation().display(state)), - Style::new(), - ), - ])); - } - - if !arg_binds.is_empty() { - right_column.push(Line::styled("Arguments", &style.command)); - } - - for bind in arg_binds { - let Op::ToggleArg(name) = &bind.op else { - unreachable!(); - }; - - let arg = pending.args.get(name.as_str()).unwrap(); - - right_column.push(Line::from(vec![ - Span::styled(bind.raw.clone(), &style.hotkey), - Span::raw(" "), - Span::raw(arg.display), - Span::raw(" ("), - Span::styled( - arg.get_cli_token().to_string(), - if arg.is_active() { - Style::from(&style.active_arg) - } else { - Style::new() - }, - ), - Span::raw(")"), - ])); - } - - let widths = [ - col_width(&pending_binds_column), - col_width(&menu_binds_column), - Constraint::Fill(1), - ]; - - let columns = [pending_binds_column, menu_binds_column, right_column]; - - let max_rows = columns.iter().map(Vec::len).max().unwrap_or(0); - let rows = (0..(max_rows)).map(|i| { - Row::new( - columns - .iter() - .map(|col| col.get(i).cloned().unwrap_or(Line::raw(""))), - ) - }); +pub(crate) fn layout_menu(layout: &mut LayoutTree, state: &State) { + let Some(ref pending) = state.pending_menu else { + return; + }; - let (lines, table) = (rows.len(), Table::new(rows, widths).column_spacing(3)); - - SizedWidget { - height: 1 + lines as u16, - widget: MenuWidget { - table: table.block(super::popup_block()), - }, - } + if pending.is_hidden { + return; } -} -fn col_width(column: &[Line<'_>]) -> Constraint { - Constraint::Length(column.iter().map(|line| line.width()).max().unwrap_or(0) as u16) -} + let config = Rc::clone(&state.config); + let item = state.screens.last().unwrap().get_selected_item(); + let style = &config.style; + + let arg_binds = config.bindings.arg_list(pending).collect::>(); + + let non_target_binds = config + .bindings + .list(&pending.menu) + .filter(|keybind| !keybind.op.clone().implementation().is_target_op()) + .collect::>(); + + let menus = non_target_binds + .iter() + .filter(|bind| matches!(bind.op, Op::OpenMenu(_))) + .collect::>(); + + let target_binds = config + .bindings + .list(&pending.menu) + .filter(|keybind| keybind.op.clone().implementation().is_target_op()) + .filter(|keybind| { + keybind + .op + .clone() + .implementation() + .get_action(&item.data) + .is_some() + }) + .collect::>(); + + let line = item.to_line(Rc::clone(&config)); + + layout.horizontal(OPTS.gap(3), |layout| { + layout.vertical(OPTS, |layout| { + layout_line( + layout, + Line::styled(format!("{}", pending.menu), &style.command), + ); + + for (op, binds) in non_target_binds + .iter() + .chunk_by(|bind| &bind.op) + .into_iter() + .filter(|(op, _binds)| !matches!(op, Op::OpenMenu(_))) + { + super::layout_line( + layout, + Line::from(vec![ + Span::styled( + binds.into_iter().map(|bind| &bind.raw).join("/"), + &style.hotkey, + ), + Span::styled( + format!(" {}", op.clone().implementation().display(state)), + Style::new(), + ), + ]), + ); + } + }); -impl Widget for MenuWidget<'_> { - fn render(self, area: Rect, buf: &mut Buffer) - where - Self: Sized, - { - Widget::render(self.table, area, buf) - } + layout.vertical(OPTS, |layout| { + super::layout_line(layout, Line::styled("Submenu", &style.command)); + + for (op, binds) in menus.iter().chunk_by(|bind| &bind.op).into_iter() { + let Op::OpenMenu(menu) = op else { + unreachable!(); + }; + + super::layout_line( + layout, + Line::from(vec![ + Span::styled( + binds.into_iter().map(|bind| &bind.raw).join("/"), + &style.hotkey, + ), + Span::styled(format!(" {menu}"), Style::new()), + ]), + ); + } + }); + + layout.vertical(OPTS, |layout| { + if !target_binds.is_empty() { + super::layout_line(layout, line); + } + + for bind in target_binds { + super::layout_line( + layout, + Line::from(vec![ + Span::styled(bind.raw.clone(), &style.hotkey), + Span::styled( + format!(" {}", bind.op.clone().implementation().display(state)), + Style::new(), + ), + ]), + ); + } + + if !arg_binds.is_empty() { + super::layout_line(layout, Line::styled("Arguments", &style.command)); + } + + for bind in arg_binds { + let Op::ToggleArg(name) = &bind.op else { + unreachable!(); + }; + + let arg = pending.args.get(name.as_str()).unwrap(); + + super::layout_line( + layout, + Line::from(vec![ + Span::styled(bind.raw.clone(), &style.hotkey), + Span::raw(" "), + Span::raw(arg.display), + Span::raw(" ("), + Span::styled( + arg.get_cli_token().to_string(), + if arg.is_active() { + Style::from(&style.active_arg) + } else { + Style::new() + }, + ), + Span::raw(")"), + ]), + ); + } + }); + }); } From 63ec907822ba1a42fcb1834b4ca354d90fe6aed6 Mon Sep 17 00:00:00 2001 From: altsem Date: Tue, 30 Sep 2025 19:20:13 +0200 Subject: [PATCH 2/4] refactor(ui): less allocs --- src/app.rs | 8 ++--- src/items.rs | 2 +- src/screen/mod.rs | 79 ++++++++++++++++++++++++++--------------------- src/ui.rs | 61 +++++++++++++++++++----------------- src/ui/menu.rs | 3 +- 5 files changed, 81 insertions(+), 72 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6780e7d24c..9667bf77eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,9 +20,7 @@ use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; use git2::Repository; use ratatui::layout::Size; -use ratatui::text::Span; use tui_prompts::State as _; -use ui::layout::LayoutTree; use crate::cli; use crate::cmd_log::CmdLog; @@ -61,7 +59,6 @@ pub(crate) struct State { pub(crate) struct App { pub state: State, - layout_tree: LayoutTree>, } impl App { @@ -110,7 +107,6 @@ impl App { file_watcher: None, needs_redraw: true, }, - layout_tree: LayoutTree::new(), }; app.state.file_watcher = app.init_file_watcher()?; @@ -218,7 +214,7 @@ impl App { pub fn redraw_now(&mut self, term: &mut Term) -> Res<()> { if self.state.screens.last_mut().is_some() { - term.draw(|frame| ui::ui(frame, &mut self.state, &mut self.layout_tree)) + term.draw(|frame| ui::ui(frame, &mut self.state)) .map_err(Error::Term)?; self.state.needs_redraw = false; @@ -388,7 +384,7 @@ impl App { let log_entry = self.state.current_cmd_log.push_cmd(&cmd); - term.draw(|frame| ui::ui(frame, &mut self.state, &mut self.layout_tree)) + term.draw(|frame| ui::ui(frame, &mut self.state)) .map_err(Error::Term)?; let mut child = cmd.spawn().map_err(Error::SpawnCmd)?; diff --git a/src/items.rs b/src/items.rs index 970abd36a4..fa72bb3568 100644 --- a/src/items.rs +++ b/src/items.rs @@ -31,7 +31,7 @@ pub(crate) struct Item { } impl Item { - pub fn to_line(&self, config: Rc) -> Line<'static> { + pub fn to_line(&'_ self, config: Rc) -> Line<'_> { match self.data.clone() { ItemData::Raw(content) => Line::raw(content), ItemData::AllUnstaged(count) => Line::from(vec![ diff --git a/src/screen/mod.rs b/src/screen/mod.rs index a8c84b5d63..f67a499938 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -1,10 +1,12 @@ use crate::ui::layout::{LayoutTree, OPTS}; +use crate::ui::layout_span; use crate::{item_data::ItemData, ui}; -use ratatui::{layout::Size, prelude::Span, style::Style, text::Line}; +use ratatui::{layout::Size, style::Style, text::Line}; use crate::{Res, config::Config, items::hash}; use super::Item; +use std::borrow::Cow; use std::{collections::HashSet, rc::Rc}; pub(crate) mod log; @@ -334,7 +336,7 @@ impl Screen { self.nav_filter(target_line_i, NavMode::IncludeHunkLines) } - fn line_views(&self, area: Size) -> impl Iterator + '_ { + fn line_views(&'_ self, area: Size) -> impl Iterator> { let scan_start = self.scroll.min(self.cursor); let scan_end = (self.scroll + area.height as usize).min(self.line_index.len()); let scan_highlight_range = scan_start..(scan_end); @@ -361,61 +363,66 @@ impl Screen { } } -struct LineView { +struct LineView<'a> { item_index: usize, - display: Line<'static>, + display: Line<'a>, highlighted: bool, } -pub(crate) fn layout_screen(layout: &mut LayoutTree, size: Size, screen: &Screen) { +pub(crate) fn layout_screen<'a>( + layout: &mut LayoutTree<(Cow<'a, str>, Style)>, + size: Size, + screen: &'a Screen, +) { let style = &screen.config.style; layout.vertical(OPTS, |layout| { for line in screen.line_views(size) { - let selected_line = screen.line_index[screen.cursor] == line.item_index; - let area_highlight = area_selection_highlgiht(style, &line); - let line_highlight = line_selection_highlight(style, &line, selected_line); - let gutter_char = if line.highlighted { - gutter_char(style, selected_line, area_highlight, line_highlight) - } else { - Span::raw(" ") - }; - - let line_spans = std::iter::once(gutter_char) - .chain( - line.display - .spans - .into_iter() - .map(|span| span.patch_style(area_highlight).patch_style(line_highlight)), - ) - .collect::>(); - - ui::layout_line(layout, line_spans); - - // TODO Do something about this - // if screen.is_collapsed(line.item) && line_width > 0 || overflow { - // let line_end = (indented_line_area.x + line_width).min(size.width - 1); - // buf[(line_end, line_index as u16)].set_char('…'); - // } + layout.horizontal(OPTS, |layout| { + let selected_line = screen.line_index[screen.cursor] == line.item_index; + let area_highlight = area_selection_highlgiht(style, &line); + let line_highlight = line_selection_highlight(style, &line, selected_line); + let gutter_char = if line.highlighted { + gutter_char(style, selected_line, area_highlight, line_highlight) + } else { + (" ".into(), Style::new()) + }; + + layout_span(layout, gutter_char); + + line.display.spans.into_iter().for_each(|span| { + let style = span.style.patch(area_highlight).patch(line_highlight); + ui::layout_span(layout, (span.content, style)); + }); + + // TODO Do something about this + // if screen.is_collapsed(line.item) && line_width > 0 || overflow { + // let line_end = (indented_line_area.x + line_width).min(size.width - 1); + // buf[(line_end, line_index as u16)].set_char('…'); + // } + }); } }); } -fn gutter_char( - style: &crate::config::StyleConfig, +fn gutter_char<'a>( + style: &'a crate::config::StyleConfig, selected_line: bool, area_highlight: Style, line_highlight: Style, -) -> Span<'static> { +) -> (Cow<'a, str>, Style) { if selected_line { - Span::styled( - style.cursor.symbol.to_string(), + ( + style.cursor.symbol.to_string().into(), Style::from(&style.cursor) .patch(area_highlight) .patch(line_highlight), ) } else { - Span::styled(style.selection_bar.symbol.to_string(), area_highlight) + ( + style.selection_bar.symbol.to_string().into(), + area_highlight, + ) } } diff --git a/src/ui.rs b/src/ui.rs index 8ba5fcf776..6576ff29f3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,19 +1,23 @@ +use std::borrow::Cow; + use crate::app::State; use crate::screen; +use crate::ui::layout::LayoutItem; use layout::LayoutTree; use layout::OPTS; use ratatui::Frame; use ratatui::prelude::*; use ratatui::style::Stylize; use tui_prompts::State as _; +use unicode_segmentation::UnicodeSegmentation; pub(crate) mod layout; mod menu; const CARET: &str = "\u{2588}"; -pub(crate) fn ui(frame: &mut Frame, state: &mut State, layout: &mut LayoutTree) { - layout.clear(); +pub(crate) fn ui(frame: &mut Frame, state: &mut State) { + let mut layout = LayoutTree::new(); layout.stacked(OPTS, |layout| { screen::layout_screen( @@ -31,27 +35,25 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State, layout: &mut LayoutTree>, state: &mut State) { +fn layout_command_log<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &State) { if !state.current_cmd_log.is_empty() { layout_text(layout, state.current_cmd_log.format_log(&state.config)); } } -fn layout_prompt(layout: &mut LayoutTree, state: &mut State) { +fn layout_prompt<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &'a State) { let Some(ref prompt_data) = state.prompt.data else { return; }; @@ -59,20 +61,19 @@ fn layout_prompt(layout: &mut LayoutTree, state: &mut State) { let prompt_symbol = state.prompt.state.status().symbol(); layout.horizontal(OPTS, |layout| { - let line = Line::from(vec![ - prompt_symbol, - " ".into(), - Span::raw(prompt_data.prompt_text.to_string()), - " › ".cyan().dim(), - Span::raw(state.prompt.state.value().to_string()), - Span::raw(CARET), - ]); - - layout_line(layout, line); + layout_span(layout, (prompt_symbol.content, prompt_symbol.style)); + layout_span(layout, (" ".into(), Style::new())); + layout_span( + layout, + (prompt_data.prompt_text.as_ref().into(), Style::new()), + ); + layout_span(layout, (" › ".into(), Style::new().cyan().dim())); + layout_span(layout, (state.prompt.state.value().into(), Style::new())); + layout_span(layout, (CARET.into(), Style::new())); }); } -pub(crate) fn layout_text<'a>(layout: &mut LayoutTree>, text: Text<'a>) { +pub(crate) fn layout_text<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, text: Text<'a>) { layout.vertical(OPTS, |layout| { for line in text { layout_line(layout, line); @@ -80,14 +81,18 @@ pub(crate) fn layout_text<'a>(layout: &mut LayoutTree>, text: Text<'a>) }); } -pub(crate) fn layout_line<'a>(layout: &mut LayoutTree>, line: Line<'a>) { +pub(crate) fn layout_line<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, line: Line<'a>) { layout.horizontal(OPTS, |layout| { for span in line { - layout_span(layout, span); + layout_span(layout, (span.content, span.style)); } }); } -pub(crate) fn layout_span<'a>(layout: &mut LayoutTree>, span: Span<'a>) { - layout.leaf_with_size(span.clone(), [span.width() as u16, 1]); +pub(crate) fn layout_span<'a>( + layout: &mut LayoutTree<(Cow<'a, str>, Style)>, + span: (Cow<'a, str>, Style), +) { + let width = span.0.graphemes(true).count() as u16; + layout.leaf_with_size(span, [width, 1]); } diff --git a/src/ui/menu.rs b/src/ui/menu.rs index db8745f551..b41eb6adad 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::rc::Rc; use crate::ui::layout::{LayoutTree, OPTS}; @@ -8,7 +9,7 @@ use ratatui::{ text::{Line, Span}, }; -pub(crate) fn layout_menu(layout: &mut LayoutTree, state: &State) { +pub(crate) fn layout_menu<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &'a State) { let Some(ref pending) = state.pending_menu else { return; }; From 5d35b442a1372fc63f2efc279faa03e80b6e08a4 Mon Sep 17 00:00:00 2001 From: altsem Date: Tue, 30 Sep 2025 23:07:11 +0200 Subject: [PATCH 3/4] get rid of frequent clone() in LayoutTree --- Cargo.toml | 4 ++++ src/ui.rs | 14 +++++++++++++- src/ui/layout/mod.rs | 4 ++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 052ec852b4..8e3425fa3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ insta = "1.43.2" unicode-width = "0.2.0" temp-env = "0.3.6" +[profile.profiling] +inherits = "dev" +opt-level = 1 + [profile.release] strip = true diff --git a/src/ui.rs b/src/ui.rs index 6576ff29f3..709e61dd63 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -39,7 +39,7 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { let LayoutItem { data, pos, size } = item; let area = Rect::new(pos[0], pos[1], size[0], size[1]); let (text, style) = data; - frame.render_widget(Span::styled(text, style), area); + frame.render_widget(SpanRef(text, *style), area); } layout.clear(); @@ -47,6 +47,18 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { state.screens.last_mut().unwrap().size = frame.area().as_size(); } +struct SpanRef<'a>(&'a Cow<'a, str>, Style); + +impl<'a> Widget for SpanRef<'a> { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + let SpanRef(text, style) = self; + buf.set_string(area.x, area.y, text, style); + } +} + fn layout_command_log<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &State) { if !state.current_cmd_log.is_empty() { layout_text(layout, state.current_cmd_log.format_log(&state.config)); diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs index deb397e4a3..b4b2ab97b5 100644 --- a/src/ui/layout/mod.rs +++ b/src/ui/layout/mod.rs @@ -267,7 +267,7 @@ impl LayoutTree { parent_data.size = parent_data.size.max(children_size); } - pub fn iter(&self) -> impl Iterator> { + pub fn iter(&self) -> impl Iterator> { self.index.iter().filter_map(|index| { let Node { data: Some(data), @@ -280,7 +280,7 @@ impl LayoutTree { }; Some(LayoutItem { - data: data.clone(), + data, pos: (*pos)?.into(), size: (*size).into(), }) From 4e0bb8fc9928065c632b627f4c1e6851e4205724 Mon Sep 17 00:00:00 2001 From: altsem Date: Tue, 30 Sep 2025 23:53:37 +0200 Subject: [PATCH 4/4] refactor: extract type of LayoutTree --- src/ui.rs | 22 +++++++++------------- src/ui/menu.rs | 6 +++--- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 709e61dd63..3991c61328 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,8 +16,10 @@ mod menu; const CARET: &str = "\u{2588}"; +type UiTree<'a> = LayoutTree<(Cow<'a, str>, Style)>; + pub(crate) fn ui(frame: &mut Frame, state: &mut State) { - let mut layout = LayoutTree::new(); + let mut layout = UiTree::new(); layout.stacked(OPTS, |layout| { screen::layout_screen( @@ -50,22 +52,19 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { struct SpanRef<'a>(&'a Cow<'a, str>, Style); impl<'a> Widget for SpanRef<'a> { - fn render(self, area: Rect, buf: &mut Buffer) - where - Self: Sized, - { + fn render(self, area: Rect, buf: &mut Buffer) { let SpanRef(text, style) = self; buf.set_string(area.x, area.y, text, style); } } -fn layout_command_log<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &State) { +fn layout_command_log<'a>(layout: &mut UiTree<'a>, state: &State) { if !state.current_cmd_log.is_empty() { layout_text(layout, state.current_cmd_log.format_log(&state.config)); } } -fn layout_prompt<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &'a State) { +fn layout_prompt<'a>(layout: &mut UiTree<'a>, state: &'a State) { let Some(ref prompt_data) = state.prompt.data else { return; }; @@ -85,7 +84,7 @@ fn layout_prompt<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &'a }); } -pub(crate) fn layout_text<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, text: Text<'a>) { +pub(crate) fn layout_text<'a>(layout: &mut UiTree<'a>, text: Text<'a>) { layout.vertical(OPTS, |layout| { for line in text { layout_line(layout, line); @@ -93,7 +92,7 @@ pub(crate) fn layout_text<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, te }); } -pub(crate) fn layout_line<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, line: Line<'a>) { +pub(crate) fn layout_line<'a>(layout: &mut UiTree<'a>, line: Line<'a>) { layout.horizontal(OPTS, |layout| { for span in line { layout_span(layout, (span.content, span.style)); @@ -101,10 +100,7 @@ pub(crate) fn layout_line<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, li }); } -pub(crate) fn layout_span<'a>( - layout: &mut LayoutTree<(Cow<'a, str>, Style)>, - span: (Cow<'a, str>, Style), -) { +pub(crate) fn layout_span<'a>(layout: &mut UiTree<'a>, span: (Cow<'a, str>, Style)) { let width = span.0.graphemes(true).count() as u16; layout.leaf_with_size(span, [width, 1]); } diff --git a/src/ui/menu.rs b/src/ui/menu.rs index b41eb6adad..41d8857e79 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,7 +1,7 @@ -use std::borrow::Cow; use std::rc::Rc; -use crate::ui::layout::{LayoutTree, OPTS}; +use crate::ui::UiTree; +use crate::ui::layout::OPTS; use crate::{app::State, ops::Op, ui::layout_line}; use itertools::Itertools; use ratatui::{ @@ -9,7 +9,7 @@ use ratatui::{ text::{Line, Span}, }; -pub(crate) fn layout_menu<'a>(layout: &mut LayoutTree<(Cow<'a, str>, Style)>, state: &'a State) { +pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State) { let Some(ref pending) = state.pending_menu else { return; };