diff --git a/Cargo.toml b/Cargo.toml index 372224ce68..83d6b25901 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ repository = "https://github.com/altsem/gitu" name = "show" harness = false +[[bench]] +name = "ui" +harness = false + [dev-dependencies] pretty_assertions = "1.4.1" temp-dir = "0.1.16" diff --git a/benches/ui.rs b/benches/ui.rs new file mode 100644 index 0000000000..6943c94534 --- /dev/null +++ b/benches/ui.rs @@ -0,0 +1,45 @@ +use std::rc::Rc; +use std::sync::Arc; + +use criterion::{Criterion, criterion_group, criterion_main}; +use git2::Repository; +use gitu::app::App; +use gitu::cli::{Args, Commands}; +use gitu::config; +use gitu::term::TermBackend; +use ratatui::{Terminal, backend::TestBackend, layout::Size}; + +const REFERENCE: &str = "f4de01c0a12794d7b42a77b2138aa64119b90ea5"; + +/// Times a redraw of an already-built screen, leaving repo access, diff +/// parsing and item creation out of the measurement. +fn bench_redraw(c: &mut Criterion, name: &str, size: Size) { + c.bench_function(name, |b| { + let mut term = Terminal::new(TermBackend::Test { + backend: TestBackend::new(size.width, size.height), + events: vec![], + }) + .unwrap(); + + let args = Args { + command: Some(Commands::Show { + reference: REFERENCE.into(), + }), + ..Default::default() + }; + + let config = Arc::new(config::init_config(args.config.clone()).unwrap()); + let repo = Rc::new(Repository::open_from_env().unwrap()); + let mut app = App::create(repo, size, &args, config, false).unwrap(); + + b.iter(|| app.redraw_now(&mut term).unwrap()); + }); +} + +fn ui(c: &mut Criterion) { + // The whole diff at once, so that per-row costs dominate. + bench_redraw(c, "ui/40x1000", Size::new(40, 1000)); +} + +criterion_group!(benches, ui); +criterion_main!(benches); diff --git a/src/app.rs b/src/app.rs index a9d42b39ed..5958ff4c5d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -60,8 +60,8 @@ pub(crate) struct State { inhibit_close_menu: bool, } -pub(crate) struct App { - pub state: State, +pub struct App { + pub(crate) state: State, } impl App { @@ -250,9 +250,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)) - .map_err(Error::Term)?; - + ui::ui(term.backend_mut(), &mut self.state)?; self.state.needs_redraw = false; }; @@ -383,11 +381,11 @@ impl App { self.state.inhibit_close_menu = true; } - pub fn screen_mut(&mut self) -> &mut Screen { + pub(crate) fn screen_mut(&mut self) -> &mut Screen { self.state.screens.last_mut().expect("No screen") } - pub fn screen(&self) -> &Screen { + pub(crate) fn screen(&self) -> &Screen { self.state.screens.last().expect("No screen") } @@ -431,8 +429,7 @@ impl App { let log_entry = self.state.current_cmd_log.push_cmd(&cmd); - term.draw(|frame| ui::ui(frame, &mut self.state)) - .map_err(Error::Term)?; + ui::ui(term.backend_mut(), &mut self.state)?; let mut child = cmd.spawn().map_err(Error::SpawnCmd)?; @@ -562,11 +559,11 @@ impl App { } } - pub fn selected_rev(&self) -> Option { + pub(crate) fn selected_rev(&self) -> Option { self.screen().get_selected_item().data.rev() } - pub fn prompt(&mut self, term: &mut Term, params: &PromptParams) -> Res { + pub(crate) fn prompt(&mut self, term: &mut Term, params: &PromptParams) -> Res { let prompt_text = if let Some(default) = (params.create_default_value)(self) { format!("{} (default {}):", params.prompt, default).into() } else { diff --git a/src/cmd_log.rs b/src/cmd_log.rs index 48a30eb37e..7dad8f9772 100644 --- a/src/cmd_log.rs +++ b/src/cmd_log.rs @@ -1,7 +1,4 @@ -use crate::config::Config; use itertools::Itertools; -use ratatui::text::Text; -use ratatui::text::{Line, Span}; use std::borrow::Cow; use std::iter; use std::process::Command; @@ -52,15 +49,6 @@ impl CmdLog { pub(crate) fn is_empty(&self) -> bool { self.entries.is_empty() } - - pub(crate) fn format_log(&self, config: &Config) -> Text<'static> { - Text::from( - self.entries - .iter() - .flat_map(|cmd| format_log_entry(config, cmd)) - .collect::>(), - ) - } } pub(crate) fn command_args(cmd: &Command) -> Cow<'static, str> { @@ -70,36 +58,6 @@ pub(crate) fn command_args(cmd: &Command) -> Cow<'static, str> { .into() } -pub(crate) fn format_log_entry<'a>( - config: &Config, - log: &Arc>, -) -> Vec> { - match &*log.read().unwrap() { - CmdLogEntry::Cmd { args, out } => [Line::from(vec![ - Span::styled( - if out.is_some() { "$ " } else { "Running: " }, - &config.style.info_msg, - ), - Span::styled(args.to_string(), &config.style.command), - ])] - .into_iter() - .chain(out.iter().flat_map(|out| { - if out.is_empty() { - vec![] - } else { - Text::raw(out.to_string()).lines - } - })) - .collect::>(), - CmdLogEntry::Error(err) => { - vec![Line::styled(format!("! {err}"), &config.style.error_msg)] - } - CmdLogEntry::Info(msg) => { - vec![Line::styled(format!("> {msg}"), &config.style.info_msg)] - } - } -} - pub(crate) enum CmdLogEntry { Cmd { args: Cow<'static, str>, diff --git a/src/items.rs b/src/items.rs index 15860260b7..68774fb5b2 100644 --- a/src/items.rs +++ b/src/items.rs @@ -1,17 +1,11 @@ use crate::Res; -use crate::config::Config; use crate::error::Error; use crate::git::diff::Diff; -use crate::gitu_diff::Status; use crate::highlight; use crate::item_data::ItemData; use crate::item_data::Ref; -use crate::item_data::SectionHeader; use git2::Oid; use git2::Repository; -use ratatui::style::Style; -use ratatui::text::Line; -use ratatui::text::Span; use regex::Regex; use std::hash::DefaultHasher; use std::hash::Hash; @@ -30,188 +24,6 @@ pub(crate) struct Item { pub(crate) data: ItemData, } -impl Item { - pub fn to_line(&'_ self, config: &Config) -> Line<'_> { - match self.data.clone() { - ItemData::Raw(content) => Line::raw(content), - ItemData::AllUnstaged(count) => Line::from(vec![ - Span::styled("Unstaged changes", &config.style.section_header), - Span::raw(format!(" ({count})")), - ]), - ItemData::AllStaged(count) => Line::from(vec![ - Span::styled("Staged changes", &config.style.section_header), - Span::raw(format!(" ({count})")), - ]), - ItemData::AllUntracked(_) => { - Line::styled("Untracked files", &config.style.section_header) - } - ItemData::Reference { kind, prefix } => { - let (reference, style) = match kind { - Ref::Tag(tag) => (tag, &config.style.tag), - Ref::Head(branch) => (branch, &config.style.branch), - Ref::Remote(remote) => (remote, &config.style.remote), - }; - - Line::from(vec![Span::raw(prefix), Span::styled(reference, style)]) - } - ItemData::Commit { - short_id, - associated_references, - summary, - .. - } => Line::from_iter(itertools::intersperse( - iter::once(Span::styled(short_id, &config.style.hash)) - .chain( - associated_references - .into_iter() - .map(|reference| match reference { - Ref::Tag(tag) => Span::styled(tag, &config.style.tag), - Ref::Head(branch) => Span::styled(branch, &config.style.branch), - Ref::Remote(remote) => Span::styled(remote, &config.style.remote), - }), - ) - .chain([Span::raw(summary)]), - Span::raw(" "), - )), - ItemData::Untracked(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 content = format!( - "{:8} {}", - format!("{:?}", file_diff.header.status).to_lowercase(), - match file_diff.header.status { - Status::Renamed | Status::Copied => format!( - "{} -> {}", - &file_diff.header.old_file.fmt(&diff.text), - &file_diff.header.new_file.fmt(&diff.text) - ), - Status::Deleted => file_diff.header.old_file.fmt(&diff.text).to_string(), - Status::Added => file_diff.header.new_file.fmt(&diff.text).to_string(), - Status::Modified => file_diff.header.new_file.fmt(&diff.text).to_string(), - Status::Unmerged => file_diff.header.new_file.fmt(&diff.text).to_string(), - } - ); - - Line::styled(content, &config.style.file_header) - } - ItemData::Hunk { - diff, - file_i, - 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.to_string(), &config.style.hunk_header) - } - ItemData::HunkLine { - diff, - file_i, - hunk_i, - line_range, - line_i, - } => { - let hunk_highlights = - 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_line = &hunk_content[line_range.clone()]; - - let line_highlights = hunk_highlights.get_line_highlights(line_i); - - Line::from_iter(line_highlights.iter().map(|(highlight_range, style)| { - Span::styled( - hunk_line[highlight_range.clone()].replace("\t", " "), - *style, - ) - })) - } - ItemData::Stash { message, id, .. } => Line::from(vec![ - Span::styled(format!("stash@{id}"), &config.style.hash), - Span::raw(format!(" {message}")), - ]), - ItemData::Header(header) => { - let content = match header { - SectionHeader::Remote(remote) => format!("Remote {remote}"), - SectionHeader::Tags => "Tags".to_string(), - SectionHeader::Branches => "Branches".to_string(), - SectionHeader::NoBranch => "No branch".to_string(), - SectionHeader::OnBranch(branch) => format!("On branch {branch}"), - SectionHeader::Rebase(head, onto) => format!("Rebasing {head} onto {onto}"), - SectionHeader::Merge(head) => format!("Merging {head}"), - SectionHeader::Revert(head) => format!("Reverting {head}"), - SectionHeader::CherryPick(head) => format!("Cherry-picking {head}"), - SectionHeader::Stashes => "Stashes".to_string(), - SectionHeader::RecentCommits => "Recent commits".to_string(), - SectionHeader::Commit(oid) => format!("commit {oid}"), - SectionHeader::StashRef(stash_ref) => stash_ref, - SectionHeader::StagedChanges(count) => format!("Staged changes ({count})"), - SectionHeader::UnstagedChanges(count) => format!("Unstaged changes ({count})"), - SectionHeader::UntrackedFiles(count) => format!("Untracked files ({count})"), - SectionHeader::Blame(file, commit) => { - format!("Blame {file} @ {commit}") - } - }; - - Line::styled(content, &config.style.section_header) - } - ItemData::BranchStatus(upstream, ahead, behind) => { - let content = if ahead == 0 && behind == 0 { - format!("Your branch is up to date with '{upstream}'.") - } else if ahead > 0 && behind == 0 { - format!("Your branch is ahead of '{upstream}' by {ahead} commit(s).",) - } else if ahead == 0 && behind > 0 { - format!("Your branch is behind '{upstream}' by {behind} commit(s).",) - } else { - format!( - "Your branch and '{upstream}' have diverged,\nand have {ahead} and {behind} different commits each, respectively." - ) - }; - - Line::raw(content) - } - ItemData::Error(err) => Line::raw(err), - ItemData::BlameHeader { - short_hash, - summary, - .. - } => Line::from(vec![ - Span::styled(format!("{:<8}", short_hash), &config.style.hash), - Span::raw(" "), - Span::raw(summary.clone()), - ]), - ItemData::BlameCodeLine { - blame_file, - line_i, - line_num, - content, - .. - } => { - let mut spans = vec![Span::styled( - format!("{:>4} ", line_num), - &config.style.blame.line_num, - )]; - - for (range, style) in blame_file.highlights.get_line_highlights(line_i) { - if !range.is_empty() && range.end <= content.len() { - spans.push(Span::styled( - content[range.clone()].replace('\t', " "), - *style, - )); - } - } - - Line::from(spans).style(Style::from(&config.style.blame.code_line)) - } - } - } -} - pub(crate) fn create_diff_items( diff: &Rc, depth: usize, diff --git a/src/screen/mod.rs b/src/screen/mod.rs index ebda1c1166..3c749abcb0 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -574,14 +574,10 @@ fn layout_item<'a>(layout: &mut UiTree<'a>, screen: &'a Screen, hide_cursor: boo layout_span(layout, gutter_char); - let item_line = screen.items[line.item_index].to_line(&screen.config); - item_line.spans.into_iter().for_each(|span| { - let style = bg.patch(item_line.style).patch(span.style); - ui::layout_span(layout, (span.content, style)); - }); + let item = &screen.items[line.item_index]; + ui::item::layout_item(layout, item, &screen.config, bg); // Add ellipsis indicator for collapsed sections - let item = &screen.items[line.item_index]; if screen.is_collapsed(item) { layout_span(layout, ("…".into(), bg)); } diff --git a/src/term.rs b/src/term.rs index 4361bf45b1..467d155049 100644 --- a/src/term.rs +++ b/src/term.rs @@ -1,17 +1,23 @@ use crate::{Res, config::Config, error::Error}; use crossterm::{ - ExecutableCommand, cursor, + QueueableCommand, + cursor::{self, MoveTo}, event::{DisableMouseCapture, EnableMouseCapture, Event}, + style::{Attribute, Colors, Print, SetAttribute, SetColors}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{ Terminal, backend::{Backend, CrosstermBackend, TestBackend}, + buffer::Cell, layout::Size, - prelude::{Position, backend::WindowSize, buffer::Cell}, + prelude::{Position, backend::WindowSize}, + style::{Color, Style}, }; use std::io::{self, Stdout, stdout}; use std::time::Duration; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; pub type Term = Terminal; @@ -28,6 +34,93 @@ pub enum TermBackend { }, } +impl TermBackend { + pub(crate) fn queue_move_cursor(&mut self, x: u16, y: u16) -> Res<()> { + match self { + TermBackend::Crossterm(t) => crossterm::queue!(t, MoveTo(x, y)).map_err(Error::Term), + TermBackend::Test { backend, .. } => backend + .set_cursor_position(Position::new(x, y)) + .map_err(Error::Term), + } + } + + pub fn queue_print(&mut self, text: &str, style: &Style) -> Res<()> { + match self { + TermBackend::Crossterm(t) => { + print_crossterm_span(text, style, t).map_err(Error::Term)?; + + Ok(()) + } + TermBackend::Test { backend, .. } => { + print_test_span(text, style, backend).map_err(Error::Term) + } + } + } +} + +const ATTRS: &[(ratatui::style::Modifier, Attribute)] = &[ + (ratatui::style::Modifier::BOLD, Attribute::Bold), + (ratatui::style::Modifier::DIM, Attribute::Dim), + (ratatui::style::Modifier::ITALIC, Attribute::Italic), + (ratatui::style::Modifier::UNDERLINED, Attribute::Underlined), + (ratatui::style::Modifier::SLOW_BLINK, Attribute::SlowBlink), + (ratatui::style::Modifier::RAPID_BLINK, Attribute::RapidBlink), + (ratatui::style::Modifier::REVERSED, Attribute::Reverse), + (ratatui::style::Modifier::HIDDEN, Attribute::Hidden), + (ratatui::style::Modifier::CROSSED_OUT, Attribute::CrossedOut), +]; + +fn print_crossterm_span( + text: &str, + style: &Style, + t: &mut CrosstermBackend, +) -> Result<(), io::Error> { + let fg = style.fg.unwrap_or(Color::Reset); + let bg = style.bg.unwrap_or(Color::Reset); + + crossterm::queue!(t, SetAttribute(Attribute::Reset))?; + + for (modifier, attribute) in ATTRS { + if style.add_modifier.contains(*modifier) { + crossterm::queue!(t, SetAttribute(*attribute))?; + } + } + + crossterm::queue!(t, SetColors(Colors::new(fg.into(), bg.into())))?; + crossterm::queue!(t, Print(text))?; + Ok(()) +} + +fn print_test_span(text: &str, style: &Style, backend: &mut TestBackend) -> io::Result<()> { + let Position { x, y } = backend.get_cursor_position()?; + let width = backend.size()?.width; + + let mut cells = Vec::new(); + let mut cx = x; + for grapheme in text.graphemes(true) { + let grapheme_width = grapheme.width() as u16; + if grapheme_width == 0 || cx >= width { + continue; + } + + let mut cell = Cell::default(); + cell.set_symbol(grapheme).set_style(*style); + cells.push((cx, y, cell)); + cx += 1; + + for _ in 1..grapheme_width { + if cx >= width { + break; + } + cells.push((cx, y, Cell::default())); + cx += 1; + } + } + + backend.draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))?; + backend.set_cursor_position(Position::new(cx, y)) +} + impl Backend for TermBackend { fn draw<'a, I>(&mut self, content: I) -> io::Result<()> where @@ -97,24 +190,14 @@ impl Backend for TermBackend { } impl TermBackend { - pub fn enter_alternate_screen(&mut self) -> Res<()> { - match self { - TermBackend::Crossterm(c) => c - .execute(EnterAlternateScreen) - .map_err(Error::Term) - .map(|_| ()), - TermBackend::Test { .. } => Ok(()), - } - } - pub fn setup_term(&mut self, config: &Config) -> io::Result<()> { match self { TermBackend::Crossterm(crossterm_backend) => { enable_raw_mode()?; - crossterm_backend.execute(EnterAlternateScreen)?; - crossterm_backend.execute(cursor::Hide)?; + crossterm_backend.queue(EnterAlternateScreen)?; + crossterm_backend.queue(cursor::Hide)?; if config.general.mouse_support { - crossterm_backend.execute(EnableMouseCapture)?; + crossterm_backend.queue(EnableMouseCapture)?; } } TermBackend::Test { .. } => {} @@ -127,10 +210,10 @@ impl TermBackend { match self { TermBackend::Crossterm(crossterm_backend) => { if config.general.mouse_support { - crossterm_backend.execute(DisableMouseCapture)?; + crossterm_backend.queue(DisableMouseCapture)?; } - crossterm_backend.execute(cursor::Show)?; - crossterm_backend.execute(LeaveAlternateScreen)?; + crossterm_backend.queue(cursor::Show)?; + crossterm_backend.queue(LeaveAlternateScreen)?; disable_raw_mode()?; } TermBackend::Test { .. } => {} @@ -146,9 +229,9 @@ impl TermBackend { match self { TermBackend::Crossterm(crossterm_backend) => { if config.general.mouse_support { - crossterm_backend.execute(DisableMouseCapture)?; + crossterm_backend.queue(DisableMouseCapture)?; } - crossterm_backend.execute(cursor::Show)?; + crossterm_backend.queue(cursor::Show)?; disable_raw_mode()?; } TermBackend::Test { .. } => {} diff --git a/src/tests/snapshots/gitu__tests__branch__checkout_picker.snap b/src/tests/snapshots/gitu__tests__branch__checkout_picker.snap index 1918f6c19c..62c87e1e2a 100644 --- a/src/tests/snapshots/gitu__tests__branch__checkout_picker.snap +++ b/src/tests/snapshots/gitu__tests__branch__checkout_picker.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 58f7548685b20bd5 +styles_hash: d0729ae36786267b diff --git a/src/tests/snapshots/gitu__tests__branch__delete_picker.snap b/src/tests/snapshots/gitu__tests__branch__delete_picker.snap index 280a39699e..c86d5923bb 100644 --- a/src/tests/snapshots/gitu__tests__branch__delete_picker.snap +++ b/src/tests/snapshots/gitu__tests__branch__delete_picker.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 45fbe6e2f998d7e4 +styles_hash: 6a255f9ee8d30c59 diff --git a/src/tests/snapshots/gitu__tests__branch__rename_picker.snap b/src/tests/snapshots/gitu__tests__branch__rename_picker.snap index 8d1582fec8..14a18c0e0f 100644 --- a/src/tests/snapshots/gitu__tests__branch__rename_picker.snap +++ b/src/tests/snapshots/gitu__tests__branch__rename_picker.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 67c9ab69126a0c9c +styles_hash: 8f7a659399e0489e diff --git a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap index 9e2369e79e..bd64720487 100644 --- a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap +++ b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 23f47fa15ae9f751 +styles_hash: f868f120e27e8b1b diff --git a/src/tests/snapshots/gitu__tests__editor__re_enter_picker_from_menu.snap b/src/tests/snapshots/gitu__tests__editor__re_enter_picker_from_menu.snap index eef5ce06b2..eea969d927 100644 --- a/src/tests/snapshots/gitu__tests__editor__re_enter_picker_from_menu.snap +++ b/src/tests/snapshots/gitu__tests__editor__re_enter_picker_from_menu.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: f7e9b36bbf7e3d91 +styles_hash: 49e995be810c0237 diff --git a/src/tests/snapshots/gitu__tests__merge__merge_picker.snap b/src/tests/snapshots/gitu__tests__merge__merge_picker.snap index 2fb83c7041..aef0a95396 100644 --- a/src/tests/snapshots/gitu__tests__merge__merge_picker.snap +++ b/src/tests/snapshots/gitu__tests__merge__merge_picker.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 6960e24f02662545 +styles_hash: 16f8dd2fc9f8bc2f diff --git a/src/tests/snapshots/gitu__tests__merge__merge_picker_custom_input.snap b/src/tests/snapshots/gitu__tests__merge__merge_picker_custom_input.snap index 59bc7f381f..ac89e1a23f 100644 --- a/src/tests/snapshots/gitu__tests__merge__merge_picker_custom_input.snap +++ b/src/tests/snapshots/gitu__tests__merge__merge_picker_custom_input.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 921dd6ef05db5d3c +styles_hash: 6f95b383fced9e3c diff --git a/src/tests/snapshots/gitu__tests__rebase__rebase_elsewhere_prompt.snap b/src/tests/snapshots/gitu__tests__rebase__rebase_elsewhere_prompt.snap index 3bd6121efb..d835e436a7 100644 --- a/src/tests/snapshots/gitu__tests__rebase__rebase_elsewhere_prompt.snap +++ b/src/tests/snapshots/gitu__tests__rebase__rebase_elsewhere_prompt.snap @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 6679953df170d9fd +styles_hash: 65f4fc0ca3a35c1e diff --git a/src/ui.rs b/src/ui.rs index 9d8340c668..8635218892 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,23 +1,30 @@ use std::borrow::Cow; +use crate::Res; use crate::app::State; +use crate::error::Error; use crate::screen; +use crate::term::TermBackend; use crate::ui::layout::LayoutItem; use itertools::Itertools; use layout::LayoutTree; use layout::OPTS; -use ratatui::Frame; -use ratatui::prelude::*; +use ratatui::backend::Backend; +use ratatui::layout::Size; +use ratatui::style::Style; use tui_prompts::State as _; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +mod cmd_log; +pub(crate) mod item; pub(crate) mod layout; mod menu; pub mod picker; const CARET: &str = "\u{2588}"; const DASHES: &str = "────────────────────────────────────────────────────────────────"; +const BLANKS: &str = " "; #[derive(Debug, Clone)] pub(crate) enum UiItem<'a> { @@ -26,8 +33,8 @@ pub(crate) enum UiItem<'a> { } pub(crate) type UiTree<'a> = LayoutTree>; -pub(crate) fn ui(frame: &mut Frame, state: &mut State) { - let size = frame.area().as_size(); +pub(crate) fn ui(term: &mut TermBackend, state: &mut State) -> Res<()> { + let size = term.size().unwrap(); let mut layout = UiTree::new(); layout.vertical(None, OPTS, |layout| { @@ -38,7 +45,12 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { layout.vertical(None, OPTS, |layout| { menu::layout_menu(layout, state, size.width as usize); - layout_command_log(layout, state, size.width as usize); + cmd_log::layout_cmd_log( + layout, + &state.current_cmd_log, + &state.config, + size.width as usize, + ); layout_prompt(layout, state, size.width as usize); layout_picker(layout, state, size.width as usize); if !state.pending_keys.is_empty() { @@ -53,34 +65,19 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { }); }); - layout.compute([frame.area().width, frame.area().height]); + layout.compute([size.width, size.height]); - for item in layout.iter() { - let LayoutItem { data, pos, size } = item; - let area = Rect::new(pos[0], pos[1], size[0], size[1]); - frame.render_widget(data, area); - } + let mut items = layout.iter().collect::>(); + items.sort_by_key(|item| [item.pos[1], item.pos[0]]); + + clear_blanks(term, size, items)?; + term.flush().map_err(Error::Term)?; layout.clear(); - state.screens.last_mut().unwrap().size = frame.area().as_size(); -} + state.screens.last_mut().unwrap().size = size; -impl<'a> Widget for &UiItem<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - match self { - UiItem::Span(text, style) => buf.set_string(area.x, area.y, text, *style), - UiItem::Style(style) => buf.set_style(area, *style), - } - } -} - -fn layout_command_log<'a>(layout: &mut UiTree<'a>, state: &State, width: usize) { - if !state.current_cmd_log.is_empty() { - let separator_style = Style::from(&state.config.style.separator); - repeat_chars(layout, width, DASHES, separator_style); - layout_text(layout, state.current_cmd_log.format_log(&state.config)); - } + Ok(()) } fn layout_prompt<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { @@ -112,29 +109,17 @@ fn layout_picker<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { } } -pub(crate) fn layout_text<'a>(layout: &mut UiTree<'a>, text: Text<'a>) { - layout.vertical(None, OPTS, |layout| { - for line in text { - layout_line(layout, line); - } - }); -} - -pub(crate) fn layout_line<'a>(layout: &mut UiTree<'a>, line: Line<'a>) { - let line_style = line.style; +/// Lays out `content` as a single row of its own. +pub(crate) fn layout_line<'a>(layout: &mut UiTree<'a>, content: Cow<'a, str>, style: Style) { layout.horizontal(None, OPTS, |layout| { - for span in line { - // Merge line.style with span.style - let merged_style = line_style.patch(span.style); - layout_span(layout, (span.content, merged_style)); - } + layout_span(layout, (content, style)); }); } pub(crate) fn layout_span<'a>(layout: &mut UiTree<'a>, span: (Cow<'a, str>, Style)) { match span.0 { Cow::Borrowed(s) => { - for word in s.split_word_bounds() { + for word in words(s) { layout.leaf_with_size( UiItem::Span(Cow::Borrowed(word), span.1), [UnicodeWidthStr::width(word) as u16, 1], @@ -142,7 +127,7 @@ pub(crate) fn layout_span<'a>(layout: &mut UiTree<'a>, span: (Cow<'a, str>, Styl } } Cow::Owned(s) => { - for word in s.split_word_bounds() { + for word in words(&s) { layout.leaf_with_size( UiItem::Span(Cow::Owned(word.into()), span.1), [UnicodeWidthStr::width(word) as u16, 1], @@ -152,6 +137,14 @@ pub(crate) fn layout_span<'a>(layout: &mut UiTree<'a>, span: (Cow<'a, str>, Styl } } +/// Splits into the words to wrap on, dropping line breaks. A span occupies a +/// single row, so a line break would otherwise be printed as-is and shift the +/// rest of the frame. +fn words(text: &str) -> impl Iterator { + text.split_word_bounds() + .filter(|word| !word.bytes().all(|byte| byte == b'\n' || byte == b'\r')) +} + pub(crate) fn repeat_chars(layout: &mut UiTree, count: usize, chars: &'static str, style: Style) { let grapheme_count = chars.grapheme_indices(true).count(); let full = count / grapheme_count; @@ -175,3 +168,76 @@ pub(crate) fn repeat_chars(layout: &mut UiTree, count: usize, chars: &'static st } }); } + +fn clear_blanks( + term: &mut TermBackend, + size: Size, + items: Vec>>, +) -> Result<(), Error> { + let mut at = [0, 0]; + let mut bg = Style::new(); + let mut bg_end = 0; + for item in items { + let LayoutItem { + data, + pos, + size: item_size, + } = item; + + blank_until(term, &mut at, [0, pos[1]], size.width, bg, bg_end)?; + + match data { + UiItem::Span(text, style) => { + blank_until(term, &mut at, pos, size.width, bg, bg_end)?; + term.queue_move_cursor(pos[0], pos[1])?; + term.queue_print(text, style)?; + + at[0] = pos[0].saturating_add(item_size[0]); + } + UiItem::Style(style) => { + bg = *style; + bg_end = pos[1].saturating_add(item_size[1]); + } + } + } + blank_until(term, &mut at, [0, size.height], size.width, bg, bg_end)?; + Ok(()) +} + +fn blank_until( + term: &mut TermBackend, + at: &mut [u16; 2], + to: [u16; 2], + width: u16, + bg: Style, + bg_end: u16, +) -> Res<()> { + let row_bg = |y| if y < bg_end { bg } else { Style::new() }; + + while at[1] < to[1] { + queue_blanks(term, *at, width.saturating_sub(at[0]), &row_bg(at[1]))?; + *at = [0, at[1] + 1]; + } + + queue_blanks(term, *at, to[0].saturating_sub(at[0]), &row_bg(at[1]))?; + at[0] = at[0].max(to[0]); + + Ok(()) +} + +fn queue_blanks(term: &mut TermBackend, at: [u16; 2], width: u16, style: &Style) -> Res<()> { + if width == 0 { + return Ok(()); + } + + term.queue_move_cursor(at[0], at[1])?; + + let mut left = width as usize; + while left > 0 { + let blanks = left.min(BLANKS.len()); + term.queue_print(&BLANKS[..blanks], style)?; + left -= blanks; + } + + Ok(()) +} diff --git a/src/ui/cmd_log.rs b/src/ui/cmd_log.rs new file mode 100644 index 0000000000..bc8cc48b0a --- /dev/null +++ b/src/ui/cmd_log.rs @@ -0,0 +1,62 @@ +use std::sync::{Arc, RwLock}; + +use ratatui::style::Style; + +use crate::cmd_log::{CmdLog, CmdLogEntry}; +use crate::config::Config; +use crate::ui::layout::OPTS; +use crate::ui::{DASHES, UiTree, layout_line, layout_span, repeat_chars}; + +pub(crate) fn layout_cmd_log<'a>( + layout: &mut UiTree<'a>, + log: &CmdLog, + config: &Config, + width: usize, +) { + if log.is_empty() { + return; + } + + repeat_chars(layout, width, DASHES, Style::from(&config.style.separator)); + + layout.vertical(None, OPTS, |layout| { + for entry in &log.entries { + layout_entry(layout, entry, config); + } + }); +} + +/// The entry is read under a lock, so its spans have to be owned. +fn layout_entry<'a>(layout: &mut UiTree<'a>, entry: &Arc>, config: &Config) { + match &*entry.read().unwrap() { + CmdLogEntry::Cmd { args, out } => { + layout.horizontal(None, OPTS, |layout| { + layout_span( + layout, + ( + if out.is_some() { "$ " } else { "Running: " }.into(), + Style::from(&config.style.info_msg), + ), + ); + layout_span( + layout, + (args.to_string().into(), Style::from(&config.style.command)), + ); + }); + + for line in out.iter().flat_map(|out| out.lines()) { + layout_line(layout, line.to_string().into(), Style::new()); + } + } + CmdLogEntry::Error(err) => layout_line( + layout, + format!("! {err}").into(), + Style::from(&config.style.error_msg), + ), + CmdLogEntry::Info(msg) => layout_line( + layout, + format!("> {msg}").into(), + Style::from(&config.style.info_msg), + ), + } +} diff --git a/src/ui/item.rs b/src/ui/item.rs new file mode 100644 index 0000000000..91d277d4af --- /dev/null +++ b/src/ui/item.rs @@ -0,0 +1,267 @@ +use std::borrow::Cow; + +use ratatui::style::Style; + +use crate::config::Config; +use crate::gitu_diff::Status; +use crate::highlight; +use crate::item_data::{ItemData, Ref, SectionHeader}; +use crate::items::Item; +use crate::ui::{UiTree, layout_span}; + +/// Lays out an [`Item`] as spans in the caller's container, which is expected to +/// be a single row. +/// +/// Every span is patched onto `base`, letting the caller supply a background +/// style such as the selection highlight. +pub(crate) fn layout_item<'a>( + layout: &mut UiTree<'a>, + item: &'a Item, + config: &Config, + base: Style, +) { + let style = &config.style; + + match &item.data { + ItemData::Raw(content) => { + layout_span(layout, (content.as_str().into(), base)); + } + ItemData::AllUnstaged(count) => { + layout_span( + layout, + ( + "Unstaged changes".into(), + base.patch(Style::from(&style.section_header)), + ), + ); + layout_span(layout, (format!(" ({count})").into(), base)); + } + ItemData::AllStaged(count) => { + layout_span( + layout, + ( + "Staged changes".into(), + base.patch(Style::from(&style.section_header)), + ), + ); + layout_span(layout, (format!(" ({count})").into(), base)); + } + ItemData::AllUntracked(_) => { + layout_span( + layout, + ( + "Untracked files".into(), + base.patch(Style::from(&style.section_header)), + ), + ); + } + ItemData::Reference { kind, prefix } => { + layout_span(layout, ((*prefix).into(), base)); + layout_reference(layout, kind, config, base); + } + ItemData::Commit { + short_id, + associated_references, + summary, + .. + } => { + layout_span( + layout, + ( + short_id.as_str().into(), + base.patch(Style::from(&style.hash)), + ), + ); + + for reference in associated_references { + layout_span(layout, (" ".into(), base)); + layout_reference(layout, reference, config, base); + } + + layout_span(layout, (" ".into(), base)); + layout_span(layout, (summary.as_str().into(), base)); + } + ItemData::Untracked(path) => { + layout_span( + layout, + ( + path.to_string_lossy(), + base.patch(Style::from(&style.file_header)), + ), + ); + } + ItemData::Delta { diff, file_i, .. } => { + let file_diff = &diff.file_diffs[*file_i]; + + let content = format!( + "{:8} {}", + format!("{:?}", file_diff.header.status).to_lowercase(), + match file_diff.header.status { + Status::Renamed | Status::Copied => format!( + "{} -> {}", + &file_diff.header.old_file.fmt(&diff.text), + &file_diff.header.new_file.fmt(&diff.text) + ), + Status::Deleted => file_diff.header.old_file.fmt(&diff.text).to_string(), + Status::Added => file_diff.header.new_file.fmt(&diff.text).to_string(), + Status::Modified => file_diff.header.new_file.fmt(&diff.text).to_string(), + Status::Unmerged => file_diff.header.new_file.fmt(&diff.text).to_string(), + } + ); + + layout_span( + layout, + (content.into(), base.patch(Style::from(&style.file_header))), + ); + } + ItemData::Hunk { + diff, + file_i, + hunk_i, + } => { + let hunk = &diff.file_diffs[*file_i].hunks[*hunk_i]; + let content = &diff.text[hunk.header.range.clone()]; + + layout_span( + layout, + (content.into(), base.patch(Style::from(&style.hunk_header))), + ); + } + ItemData::HunkLine { + diff, + file_i, + hunk_i, + line_range, + line_i, + } => { + let hunk_highlights = + highlight::highlight_hunk(item.id, config, diff, *file_i, *hunk_i); + let hunk_line = &diff.hunk_content(*file_i, *hunk_i)[line_range.clone()]; + + for (highlight_range, highlight_style) in hunk_highlights.get_line_highlights(*line_i) { + layout_span( + layout, + ( + hunk_line[highlight_range.clone()] + .replace('\t', " ") + .into(), + base.patch(*highlight_style), + ), + ); + } + } + ItemData::Stash { message, id, .. } => { + layout_span( + layout, + ( + format!("stash@{id}").into(), + base.patch(Style::from(&style.hash)), + ), + ); + layout_span(layout, (format!(" {message}").into(), base)); + } + ItemData::Header(header) => { + let content: Cow = match header { + SectionHeader::Remote(remote) => format!("Remote {remote}").into(), + SectionHeader::Tags => "Tags".into(), + SectionHeader::Branches => "Branches".into(), + SectionHeader::NoBranch => "No branch".into(), + SectionHeader::OnBranch(branch) => format!("On branch {branch}").into(), + SectionHeader::Rebase(head, onto) => format!("Rebasing {head} onto {onto}").into(), + SectionHeader::Merge(head) => format!("Merging {head}").into(), + SectionHeader::Revert(head) => format!("Reverting {head}").into(), + SectionHeader::CherryPick(head) => format!("Cherry-picking {head}").into(), + SectionHeader::Stashes => "Stashes".into(), + SectionHeader::RecentCommits => "Recent commits".into(), + SectionHeader::Commit(oid) => format!("commit {oid}").into(), + SectionHeader::StashRef(stash_ref) => stash_ref.as_str().into(), + SectionHeader::StagedChanges(count) => format!("Staged changes ({count})").into(), + SectionHeader::UnstagedChanges(count) => { + format!("Unstaged changes ({count})").into() + } + SectionHeader::UntrackedFiles(count) => format!("Untracked files ({count})").into(), + SectionHeader::Blame(file, commit) => format!("Blame {file} @ {commit}").into(), + }; + + layout_span( + layout, + (content, base.patch(Style::from(&style.section_header))), + ); + } + ItemData::BranchStatus(upstream, ahead, behind) => { + let content = if *ahead == 0 && *behind == 0 { + format!("Your branch is up to date with '{upstream}'.") + } else if *ahead > 0 && *behind == 0 { + format!("Your branch is ahead of '{upstream}' by {ahead} commit(s).") + } else if *ahead == 0 && *behind > 0 { + format!("Your branch is behind '{upstream}' by {behind} commit(s).") + } else { + format!( + "Your branch and '{upstream}' have diverged,\nand have {ahead} and {behind} different commits each, respectively." + ) + }; + + layout_span(layout, (content.into(), base)); + } + ItemData::Error(err) => { + layout_span(layout, (err.as_str().into(), base)); + } + ItemData::BlameHeader { + short_hash, + summary, + .. + } => { + layout_span( + layout, + ( + format!("{short_hash:<8}").into(), + base.patch(Style::from(&style.hash)), + ), + ); + layout_span(layout, (" ".into(), base)); + layout_span(layout, (summary.as_str().into(), base)); + } + ItemData::BlameCodeLine { + blame_file, + line_i, + line_num, + content, + .. + } => { + let base = base.patch(Style::from(&style.blame.code_line)); + + layout_span( + layout, + ( + format!("{line_num:>4} ").into(), + base.patch(Style::from(&style.blame.line_num)), + ), + ); + + for (range, highlight_style) in blame_file.highlights.get_line_highlights(*line_i) { + if !range.is_empty() && range.end <= content.len() { + layout_span( + layout, + ( + content[range.clone()].replace('\t', " ").into(), + base.patch(*highlight_style), + ), + ); + } + } + } + } +} + +fn layout_reference<'a>(layout: &mut UiTree<'a>, reference: &'a Ref, config: &Config, base: Style) { + let (name, style) = match reference { + Ref::Tag(tag) => (tag, &config.style.tag), + Ref::Head(branch) => (branch, &config.style.branch), + Ref::Remote(remote) => (remote, &config.style.remote), + }; + + layout_span( + layout, + (name.as_str().into(), base.patch(Style::from(style))), + ); +} diff --git a/src/ui/menu.rs b/src/ui/menu.rs index a2cdf3a0a9..53845966c9 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,13 +1,19 @@ -use std::sync::Arc; +use std::borrow::Cow; +use crate::menu::arg::Arg; +use crate::ui::item::layout_item; use crate::ui::layout::OPTS; -use crate::ui::{self, UiTree, repeat_chars}; -use crate::{app::State, ops::Op, ui::layout_line}; +use crate::ui::{self, UiTree, layout_line, layout_span, repeat_chars}; +use crate::{app::State, config::Config, ops::Op}; use itertools::Itertools; -use ratatui::{ - style::Style, - text::{Line, Span}, -}; +use ratatui::style::Style; +use unicode_width::UnicodeWidthStr; + +/// The value column of a keybind table row. +enum MenuValue<'a> { + Text(Cow<'a, str>), + Arg(&'a Arg), +} pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: usize) { let Some(ref pending) = state.pending_menu else { @@ -22,7 +28,7 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: return; } - let config = Arc::clone(&state.config); + let config = &state.config; let item = state.screens.last().unwrap().get_selected_item(); let style = &config.style; @@ -52,7 +58,6 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: }) .partition(|(op, _binds)| matches!(op, Op::OpenMenu(_))); - let line = item.to_line(&config); let separator_style = Style::from(&style.separator); layout.vertical(None, OPTS, |layout| { @@ -64,20 +69,21 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: layout.vertical(None, OPTS, |layout| { layout_line( layout, - Line::styled(format!("{}", pending.menu), &style.menu.heading), + pending.menu.to_string().into(), + Style::from(&style.menu.heading), ); layout_keybinds_table( layout, + config, non_menu_binds .into_iter() .map(|(op, binds)| { ( - Line::styled( - binds.iter().map(|bind| bind.raw.as_str()).join("/"), - &style.menu.key, + binds.iter().map(|bind| bind.raw.as_str()).join("/").into(), + MenuValue::Text( + op.clone().implementation().display(state).into(), ), - Line::raw(op.clone().implementation().display(state)), ) }) .collect(), @@ -88,22 +94,21 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: // Column 2: Submenus if !menu_binds.is_empty() { layout.vertical(None, OPTS, |layout| { - layout_line(layout, Line::styled("Submenu", &style.menu.heading)); + layout_line(layout, "Submenu".into(), Style::from(&style.menu.heading)); layout_keybinds_table( layout, + config, menu_binds .into_iter() .map(|(op, binds)| { let Op::OpenMenu(menu) = op else { unreachable!(); }; + ( - Line::styled( - binds.iter().map(|bind| bind.raw.as_str()).join("/"), - &style.menu.key, - ), - Line::raw(menu.to_string()), + binds.iter().map(|bind| bind.raw.as_str()).join("/").into(), + MenuValue::Text(menu.to_string().into()), ) }) .collect(), @@ -114,16 +119,21 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: // Column 3: Target commands and arguments layout.vertical(None, OPTS, |layout| { if !target_binds.is_empty() { - layout_line(layout, line); + layout.horizontal(None, OPTS, |layout| { + layout_item(layout, item, config, Style::new()); + }); layout_keybinds_table( layout, + config, target_binds .into_iter() .map(|bind| { ( - Line::styled(bind.raw.clone(), &style.menu.key), - Line::raw(bind.op.clone().implementation().display(state)), + bind.raw.as_str().into(), + MenuValue::Text( + bind.op.clone().implementation().display(state).into(), + ), ) }) .collect(), @@ -131,10 +141,11 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: } if !arg_binds.is_empty() { - layout_line(layout, Line::styled("Arguments", &style.menu.heading)); + layout_line(layout, "Arguments".into(), Style::from(&style.menu.heading)); layout_keybinds_table( layout, + config, arg_binds .into_iter() .map(|bind| { @@ -144,22 +155,7 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: let arg = pending.args.get(name.as_str()).unwrap(); - ( - Line::styled(bind.raw.clone(), &style.menu.key), - Line::from(vec![ - Span::raw(arg.display), - Span::raw(" ("), - Span::styled( - arg.get_cli_token().to_string(), - if arg.is_active() { - Style::from(&style.menu.active_arg) - } else { - Style::from(&style.menu.inactive_arg) - }, - ), - Span::raw(")"), - ]), - ) + (bind.raw.as_str().into(), MenuValue::Arg(arg)) }) .collect(), ); @@ -169,16 +165,47 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: }); } -fn layout_keybinds_table<'a>(layout: &mut UiTree<'a>, items: Vec<(Line<'a>, Line<'a>)>) { +fn layout_keybinds_table<'a>( + layout: &mut UiTree<'a>, + config: &Config, + rows: Vec<(Cow<'a, str>, MenuValue<'a>)>, +) { const SPACES: &str = " "; - let max_width = items.iter().fold(0, |acc, (x, _)| acc.max(x.width())) + 1; + let key_style = Style::from(&config.style.menu.key); + let max_width = rows + .iter() + .map(|(key, _)| UnicodeWidthStr::width(key.as_ref())) + .max() + .unwrap_or(0) + + 1; layout.vertical(None, OPTS, |layout| { - for (key, value) in items.iter() { + for (key, value) in rows { + let padding = max_width - UnicodeWidthStr::width(key.as_ref()); + layout.horizontal(None, OPTS, |layout| { - layout_line(layout, key.clone()); - repeat_chars(layout, max_width - key.width(), SPACES, Style::new()); - layout_line(layout, value.clone()); + layout_line(layout, key, key_style); + repeat_chars(layout, padding, SPACES, Style::new()); + + layout.horizontal(None, OPTS, |layout| match value { + MenuValue::Text(text) => layout_span(layout, (text, Style::new())), + MenuValue::Arg(arg) => { + layout_span(layout, (arg.display.into(), Style::new())); + layout_span(layout, (" (".into(), Style::new())); + layout_span( + layout, + ( + arg.get_cli_token().into(), + if arg.is_active() { + Style::from(&config.style.menu.active_arg) + } else { + Style::from(&config.style.menu.inactive_arg) + }, + ), + ); + layout_span(layout, (")".into(), Style::new())); + } + }); }); } }); diff --git a/src/ui/picker.rs b/src/ui/picker.rs index 8ec3239dbd..f7618c0a93 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use ratatui::prelude::*; +use ratatui::style::Style; use tui_prompts::State as _; use unicode_segmentation::UnicodeSegmentation;