diff --git a/README.md b/README.md index 682f42e..eb66baf 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Usage: serie [OPTIONS] Options: -n, --max-count Maximum number of commits to render - -p, --protocol Image protocol to render graph [default: auto] [possible values: auto, iterm, kitty] + -p, --protocol Image protocol to render graph [default: auto] [possible values: auto, iterm, kitty, kitty-unicode] -o, --order Commit ordering algorithm [default: chrono] [possible values: chrono, topo] -g, --graph-width Commit graph image cell width [default: auto] [possible values: auto, double, single] -s, --graph-style Commit graph image edge style [default: rounded] [possible values: rounded, angular] @@ -116,6 +116,7 @@ These image protocols are supported: - [Inline Images Protocol (iTerm2)](https://iterm2.com/documentation-images.html) - [Terminal graphics protocol (kitty)](https://sw.kovidgoyal.net/kitty/graphics-protocol/) + - Supports both the existing graphics protocol mode and [the Unicode placeholder](https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders) mode. For more information, see [Compatibility](https://lusingander.github.io/serie/getting-started/compatibility.html). diff --git a/config.schema.json b/config.schema.json index 49e8fe3..f37fc50 100644 --- a/config.schema.json +++ b/config.schema.json @@ -18,7 +18,8 @@ "enum": [ "auto", "iterm", - "kitty" + "kitty", + "kitty-unicode" ], "default": "auto" }, @@ -682,4 +683,4 @@ ] } } -} \ No newline at end of file +} diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 79828a6..f69380f 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -114,6 +114,7 @@ The protocol type for rendering images of commit graphs. - `auto` - `iterm` - `kitty` + - `kitty-unicode` The value specified in the command line argument takes precedence. diff --git a/docs/src/getting-started/command-line-options.md b/docs/src/getting-started/command-line-options.md index 21a0f25..806306e 100644 --- a/docs/src/getting-started/command-line-options.md +++ b/docs/src/getting-started/command-line-options.md @@ -11,7 +11,7 @@ It behaves similarly to the `--max-count` option of `git log`. A protocol type for rendering images of commit graphs. -_Possible values:_ `auto`, `iterm`, `kitty` +_Possible values:_ `auto`, `iterm`, `kitty`, `kitty-unicode` By default `auto` will guess the best supported protocol for the current terminal (if listed in [Supported terminal emulators](./compatibility.md#supported-terminal-emulators)). diff --git a/docs/src/getting-started/compatibility.md b/docs/src/getting-started/compatibility.md index 54352cb..7a05dc7 100644 --- a/docs/src/getting-started/compatibility.md +++ b/docs/src/getting-started/compatibility.md @@ -6,6 +6,7 @@ These image protocols are supported: - [Inline Images Protocol (iTerm2)](https://iterm2.com/documentation-images.html) - [Terminal graphics protocol (kitty)](https://sw.kovidgoyal.net/kitty/graphics-protocol/) + - Supports both the existing graphics protocol mode and [the Unicode placeholder](https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders) mode. The terminals on which each has been confirmed to work are listed below. @@ -22,10 +23,12 @@ The terminals on which each has been confirmed to work are listed below. ### Terminal graphics protocol -| Terminal emulator | Note | -| ----------------------------------------- | ---- | -| [kitty](https://sw.kovidgoyal.net/kitty/) | | -| [Ghostty](https://ghostty.org) | | +| Terminal emulator | Unicode placeholder | Note | +| ----------------------------------------- | ------------------- | ---- | +| [kitty](https://sw.kovidgoyal.net/kitty/) | ○ | | +| [Ghostty](https://ghostty.org) | ○ | | + +Rendering using Unicode Placeholder is available by explicitly specifying `kitty-unicode` as `protocol` option or config. ## Unsupported environments diff --git a/src/app.rs b/src/app.rs index 3004fe9..44f1f3c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,7 @@ -use std::rc::Rc; +use std::{ + io::{self, Write}, + rc::Rc, +}; use ratatui::{ crossterm::event::{KeyCode, KeyEvent}, @@ -149,6 +152,8 @@ impl App<'_> { terminal.clear()?; loop { + self.prepare_render(terminal)?; + self.flush_pending_graph_uploads()?; terminal.draw(|f| self.render(f))?; match self.ec.recv() { AppEvent::Key(key) => { @@ -214,6 +219,7 @@ impl App<'_> { let _ = (w, h); } AppEvent::Quit => { + self.cleanup_graph_images()?; return Ok(Ret::Quit); } AppEvent::OpenDetail => { @@ -259,6 +265,7 @@ impl App<'_> { self.copy_to_clipboard(name, value); } AppEvent::Refresh(context) => { + self.cleanup_graph_images()?; let request = RefreshRequest { context }; return Ok(Ret::Refresh(request)); } @@ -284,14 +291,40 @@ impl App<'_> { } } + fn prepare_render(&mut self, terminal: &mut DefaultTerminal) -> Result<(), std::io::Error> { + let area: Rect = terminal.size()?.into(); + let [view_area, _] = split_app_areas(area); + self.update_state(view_area); + self.view.update_layout(view_area); + self.view.prepare_graph_uploads(); + Ok(()) + } + + fn flush_pending_graph_uploads(&mut self) -> Result<(), std::io::Error> { + let uploads = self.view.drain_pending_graph_uploads(); + if uploads.is_empty() { + return Ok(()); + } + + let mut stdout = io::stdout().lock(); + for upload in uploads { + stdout.write_all(upload.as_bytes())?; + } + stdout.flush() + } + + fn cleanup_graph_images(&self) -> Result<(), std::io::Error> { + let image_ids = self.view.graph_image_ids_sorted(); + self.ctx.image_protocol.delete_images(&image_ids) + } + fn render(&mut self, f: &mut Frame) { let base = Block::default() .fg(self.ctx.color_theme.fg) .bg(self.ctx.color_theme.bg); f.render_widget(base, f.area()); - let [view_area, status_line_area] = - Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).areas(f.area()); + let [view_area, status_line_area] = split_app_areas(f.area()); self.update_state(view_area); @@ -363,6 +396,10 @@ impl App<'_> { } } +fn split_app_areas(area: Rect) -> [Rect; 2] { + Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).areas(area) +} + impl App<'_> { fn update_state(&mut self, view_area: Rect) { self.app_status.view_area = view_area; diff --git a/src/config.rs b/src/config.rs index f9e8cd4..205852a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -503,7 +503,7 @@ mod tests { fn test_config_complete_toml() { let toml = r##" [core.option] - protocol = "kitty" + protocol = "kitty-unicode" order = "topo" graph_width = "single" graph_style = "angular" @@ -543,7 +543,7 @@ mod tests { let expected = Config { core: CoreConfig { option: CoreOptionConfig { - protocol: Some(ImageProtocolType::Kitty), + protocol: Some(ImageProtocolType::KittyUnicode), order: Some(CommitOrderType::Topo), graph_width: Some(GraphWidthType::Single), graph_style: Some(GraphStyle::Angular), diff --git a/src/graph/image.rs b/src/graph/image.rs index 5001696..310a95f 100644 --- a/src/graph/image.rs +++ b/src/graph/image.rs @@ -1,6 +1,9 @@ use std::{ fmt::{self, Debug, Formatter}, + hash::{Hash, Hasher}, io::Cursor, + process, + time::{SystemTime, UNIX_EPOCH}, }; use rustc_hash::{FxHashMap, FxHashSet}; @@ -12,7 +15,7 @@ use crate::{ geometry::{bounding_box_u32, Point}, Edge, EdgeType, Graph, }, - protocol::ImageProtocol, + protocol::{ImageProtocol, PreparedImage}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -23,7 +26,9 @@ pub enum GraphStyle { #[derive(Debug)] pub struct GraphImageManager<'a> { - encoded_image_map: FxHashMap, + prepared_image_map: FxHashMap, + image_ids: FxHashSet, + pending_uploads: Vec, graph: &'a Graph<'a>, cell_width_type: CellWidthType, @@ -31,6 +36,7 @@ pub struct GraphImageManager<'a> { image_params: ImageParams, drawing_pixels: DrawingPixels, image_protocol: ImageProtocol, + session_nonce: u32, } impl<'a> GraphImageManager<'a> { @@ -45,24 +51,36 @@ impl<'a> GraphImageManager<'a> { let drawing_pixels = DrawingPixels::new(&image_params); GraphImageManager { - encoded_image_map: FxHashMap::default(), + prepared_image_map: FxHashMap::default(), + image_ids: FxHashSet::default(), + pending_uploads: Vec::default(), graph, cell_width_type, graph_style, image_params, drawing_pixels, image_protocol, + session_nonce: create_session_nonce(), } } - pub fn encoded_image(&self, commit_hash: &CommitHash) -> &str { - self.encoded_image_map.get(commit_hash).unwrap() + pub fn prepared_image(&self, commit_hash: &CommitHash) -> &PreparedImage { + self.prepared_image_map.get(commit_hash).unwrap() } - pub fn load_encoded_image(&mut self, commit_hash: &CommitHash) { - if self.encoded_image_map.contains_key(commit_hash) { + pub fn image_ids(&self) -> &FxHashSet { + &self.image_ids + } + + pub fn drain_pending_uploads(&mut self) -> Vec { + std::mem::take(&mut self.pending_uploads) + } + + pub fn ensure_uploaded(&mut self, commit_hash: &CommitHash) { + if self.prepared_image_map.contains_key(commit_hash) { return; } + let image_id = graph_image_id(self.session_nonce, commit_hash); let graph_row_image = build_single_graph_row_image( self.graph, &self.image_params, @@ -70,8 +88,13 @@ impl<'a> GraphImageManager<'a> { self.graph_style, commit_hash, ); - let image = graph_row_image.encode(self.cell_width_type, self.image_protocol); - self.encoded_image_map.insert(commit_hash.clone(), image); + let mut image = + graph_row_image.prepare(self.cell_width_type, self.image_protocol, image_id); + if let Some(upload_data) = image.take_upload_data() { + self.pending_uploads.push(upload_data); + } + self.prepared_image_map.insert(commit_hash.clone(), image); + self.image_ids.insert(image_id); } } @@ -97,15 +120,38 @@ impl Debug for GraphRowImage { } impl GraphRowImage { - fn encode(&self, cell_width_type: CellWidthType, image_protocol: ImageProtocol) -> String { + fn prepare( + &self, + cell_width_type: CellWidthType, + image_protocol: ImageProtocol, + image_id: u32, + ) -> PreparedImage { let image_cell_width = match cell_width_type { CellWidthType::Double => self.cell_count * 2, CellWidthType::Single => self.cell_count, }; - image_protocol.encode(&self.bytes, image_cell_width) + image_protocol.prepare_image(&self.bytes, image_cell_width, image_id) } } +fn create_session_nonce() -> u32 { + let mut hasher = rustc_hash::FxHasher::default(); + process::id().hash(&mut hasher); + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .hash(&mut hasher); + hasher.finish() as u32 +} + +fn graph_image_id(session_nonce: u32, commit_hash: &CommitHash) -> u32 { + let mut hasher = rustc_hash::FxHasher::default(); + session_nonce.hash(&mut hasher); + commit_hash.hash(&mut hasher); + hasher.finish() as u32 +} + #[derive(Debug)] pub struct ImageParams { width: u16, diff --git a/src/lib.rs b/src/lib.rs index f877280..ac9a8bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,11 +49,12 @@ struct Args { } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "kebab-case")] pub enum ImageProtocolType { Auto, Iterm, Kitty, + KittyUnicode, } impl From> for protocol::ImageProtocol { @@ -62,6 +63,7 @@ impl From> for protocol::ImageProtocol { Some(ImageProtocolType::Auto) => protocol::auto_detect(), Some(ImageProtocolType::Iterm) => protocol::ImageProtocol::Iterm2, Some(ImageProtocolType::Kitty) => protocol::ImageProtocol::Kitty, + Some(ImageProtocolType::KittyUnicode) => protocol::ImageProtocol::KittyUnicode, None => protocol::auto_detect(), } } diff --git a/src/protocol.rs b/src/protocol.rs index 0ec7ac8..7ce4ce4 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,6 +1,10 @@ -use std::env; +use std::{ + env, + io::{self, Write}, +}; use base64::Engine; +use ratatui::style::{Color, Style}; // By default assume the Iterm2 is the best protocol to use for all terminals *unless* an env // variable is set that suggests the terminal is probably Kitty. @@ -22,13 +26,77 @@ pub fn auto_detect() -> ImageProtocol { pub enum ImageProtocol { Iterm2, Kitty, + KittyUnicode, +} + +#[derive(Debug, Clone)] +pub struct PreparedImageCell { + symbol: String, + style: Style, + skip: bool, +} + +impl PreparedImageCell { + pub fn symbol(&self) -> &str { + &self.symbol + } + + pub fn style(&self) -> Style { + self.style + } + + pub fn skip(&self) -> bool { + self.skip + } +} + +#[derive(Debug, Clone)] +pub struct PreparedImage { + cells: Vec, + cell_width: usize, + upload_data: Option, +} + +impl PreparedImage { + pub fn cells(&self) -> &[PreparedImageCell] { + &self.cells + } + + pub fn cell_width(&self) -> usize { + self.cell_width + } + + pub fn take_upload_data(&mut self) -> Option { + self.upload_data.take() + } } impl ImageProtocol { - pub fn encode(&self, bytes: &[u8], cell_width: usize) -> String { - match self { + pub fn prepare_image(&self, bytes: &[u8], cell_width: usize, image_id: u32) -> PreparedImage { + let symbol = match self { ImageProtocol::Iterm2 => iterm2_encode(bytes, cell_width, 1), ImageProtocol::Kitty => kitty_encode(bytes, cell_width, 1), + ImageProtocol::KittyUnicode => { + return kitty_unicode_prepare(bytes, cell_width, image_id); + } + }; + let mut cells = Vec::with_capacity(cell_width); + cells.push(PreparedImageCell { + symbol, + style: Style::default(), + skip: false, + }); + for _ in 1..cell_width { + cells.push(PreparedImageCell { + symbol: String::new(), + style: Style::default(), + skip: true, + }); + } + PreparedImage { + cells, + cell_width, + upload_data: None, } } @@ -36,6 +104,7 @@ impl ImageProtocol { match self { ImageProtocol::Iterm2 => {} ImageProtocol::Kitty => kitty_clear_line(y), + ImageProtocol::KittyUnicode => {} } } @@ -43,10 +112,319 @@ impl ImageProtocol { match self { ImageProtocol::Iterm2 => {} ImageProtocol::Kitty => kitty_clear(), + ImageProtocol::KittyUnicode => {} + } + } + + pub fn delete_images(&self, image_ids: &[u32]) -> Result<(), std::io::Error> { + match self { + ImageProtocol::Iterm2 | ImageProtocol::Kitty => Ok(()), + ImageProtocol::KittyUnicode => kitty_unicode_delete_images(image_ids), } } } +const KITTY_PLACEHOLDER: char = '\u{10EEEE}'; +static ROW_COLUMN_DIACRITICS: &[char] = &[ + '\u{0305}', + '\u{030D}', + '\u{030E}', + '\u{0310}', + '\u{0312}', + '\u{033D}', + '\u{033E}', + '\u{033F}', + '\u{0346}', + '\u{034A}', + '\u{034B}', + '\u{034C}', + '\u{0350}', + '\u{0351}', + '\u{0352}', + '\u{0357}', + '\u{035B}', + '\u{0363}', + '\u{0364}', + '\u{0365}', + '\u{0366}', + '\u{0367}', + '\u{0368}', + '\u{0369}', + '\u{036A}', + '\u{036B}', + '\u{036C}', + '\u{036D}', + '\u{036E}', + '\u{036F}', + '\u{0483}', + '\u{0484}', + '\u{0485}', + '\u{0486}', + '\u{0487}', + '\u{0592}', + '\u{0593}', + '\u{0594}', + '\u{0595}', + '\u{0597}', + '\u{0598}', + '\u{0599}', + '\u{059C}', + '\u{059D}', + '\u{059E}', + '\u{059F}', + '\u{05A0}', + '\u{05A1}', + '\u{05A8}', + '\u{05A9}', + '\u{05AB}', + '\u{05AC}', + '\u{05AF}', + '\u{05C4}', + '\u{0610}', + '\u{0611}', + '\u{0612}', + '\u{0613}', + '\u{0614}', + '\u{0615}', + '\u{0616}', + '\u{0617}', + '\u{0657}', + '\u{0658}', + '\u{0659}', + '\u{065A}', + '\u{065B}', + '\u{065D}', + '\u{065E}', + '\u{06D6}', + '\u{06D7}', + '\u{06D8}', + '\u{06D9}', + '\u{06DA}', + '\u{06DB}', + '\u{06DC}', + '\u{06DF}', + '\u{06E0}', + '\u{06E1}', + '\u{06E2}', + '\u{06E4}', + '\u{06E7}', + '\u{06E8}', + '\u{06EB}', + '\u{06EC}', + '\u{0730}', + '\u{0732}', + '\u{0733}', + '\u{0735}', + '\u{0736}', + '\u{073A}', + '\u{073D}', + '\u{073F}', + '\u{0740}', + '\u{0741}', + '\u{0743}', + '\u{0745}', + '\u{0747}', + '\u{0749}', + '\u{074A}', + '\u{07EB}', + '\u{07EC}', + '\u{07ED}', + '\u{07EE}', + '\u{07EF}', + '\u{07F0}', + '\u{07F1}', + '\u{07F3}', + '\u{0816}', + '\u{0817}', + '\u{0818}', + '\u{0819}', + '\u{081B}', + '\u{081C}', + '\u{081D}', + '\u{081E}', + '\u{081F}', + '\u{0820}', + '\u{0821}', + '\u{0822}', + '\u{0823}', + '\u{0825}', + '\u{0826}', + '\u{0827}', + '\u{0829}', + '\u{082A}', + '\u{082B}', + '\u{082C}', + '\u{082D}', + '\u{0951}', + '\u{0953}', + '\u{0954}', + '\u{0F82}', + '\u{0F83}', + '\u{0F86}', + '\u{0F87}', + '\u{135D}', + '\u{135E}', + '\u{135F}', + '\u{17DD}', + '\u{193A}', + '\u{1A17}', + '\u{1A75}', + '\u{1A76}', + '\u{1A77}', + '\u{1A78}', + '\u{1A79}', + '\u{1A7A}', + '\u{1A7B}', + '\u{1A7C}', + '\u{1B6B}', + '\u{1B6D}', + '\u{1B6E}', + '\u{1B6F}', + '\u{1B70}', + '\u{1B71}', + '\u{1B72}', + '\u{1B73}', + '\u{1CD0}', + '\u{1CD1}', + '\u{1CD2}', + '\u{1CDA}', + '\u{1CDB}', + '\u{1CE0}', + '\u{1DC0}', + '\u{1DC1}', + '\u{1DC3}', + '\u{1DC4}', + '\u{1DC5}', + '\u{1DC6}', + '\u{1DC7}', + '\u{1DC8}', + '\u{1DC9}', + '\u{1DCB}', + '\u{1DCC}', + '\u{1DD1}', + '\u{1DD2}', + '\u{1DD3}', + '\u{1DD4}', + '\u{1DD5}', + '\u{1DD6}', + '\u{1DD7}', + '\u{1DD8}', + '\u{1DD9}', + '\u{1DDA}', + '\u{1DDB}', + '\u{1DDC}', + '\u{1DDD}', + '\u{1DDE}', + '\u{1DDF}', + '\u{1DE0}', + '\u{1DE1}', + '\u{1DE2}', + '\u{1DE3}', + '\u{1DE4}', + '\u{1DE5}', + '\u{1DE6}', + '\u{1DFE}', + '\u{20D0}', + '\u{20D1}', + '\u{20D4}', + '\u{20D5}', + '\u{20D6}', + '\u{20D7}', + '\u{20DB}', + '\u{20DC}', + '\u{20E1}', + '\u{20E7}', + '\u{20E9}', + '\u{20F0}', + '\u{2CEF}', + '\u{2CF0}', + '\u{2CF1}', + '\u{2DE0}', + '\u{2DE1}', + '\u{2DE2}', + '\u{2DE3}', + '\u{2DE4}', + '\u{2DE5}', + '\u{2DE6}', + '\u{2DE7}', + '\u{2DE8}', + '\u{2DE9}', + '\u{2DEA}', + '\u{2DEB}', + '\u{2DEC}', + '\u{2DED}', + '\u{2DEE}', + '\u{2DEF}', + '\u{2DF0}', + '\u{2DF1}', + '\u{2DF2}', + '\u{2DF3}', + '\u{2DF4}', + '\u{2DF5}', + '\u{2DF6}', + '\u{2DF7}', + '\u{2DF8}', + '\u{2DF9}', + '\u{2DFA}', + '\u{2DFB}', + '\u{2DFC}', + '\u{2DFD}', + '\u{2DFE}', + '\u{2DFF}', + '\u{A66F}', + '\u{A67C}', + '\u{A67D}', + '\u{A6F0}', + '\u{A6F1}', + '\u{A8E0}', + '\u{A8E1}', + '\u{A8E2}', + '\u{A8E3}', + '\u{A8E4}', + '\u{A8E5}', + '\u{A8E6}', + '\u{A8E7}', + '\u{A8E8}', + '\u{A8E9}', + '\u{A8EA}', + '\u{A8EB}', + '\u{A8EC}', + '\u{A8ED}', + '\u{A8EE}', + '\u{A8EF}', + '\u{A8F0}', + '\u{A8F1}', + '\u{AAB0}', + '\u{AAB2}', + '\u{AAB3}', + '\u{AAB7}', + '\u{AAB8}', + '\u{AABE}', + '\u{AABF}', + '\u{AAC1}', + '\u{FE20}', + '\u{FE21}', + '\u{FE22}', + '\u{FE23}', + '\u{FE24}', + '\u{FE25}', + '\u{FE26}', + '\u{10A0F}', + '\u{10A38}', + '\u{1D185}', + '\u{1D186}', + '\u{1D187}', + '\u{1D188}', + '\u{1D189}', + '\u{1D1AA}', + '\u{1D1AB}', + '\u{1D1AC}', + '\u{1D1AD}', + '\u{1D242}', + '\u{1D243}', + '\u{1D244}', +]; + fn to_base64_str(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } @@ -90,6 +468,77 @@ fn kitty_encode(bytes: &[u8], cell_width: usize, cell_height: usize) -> String { s } +fn kitty_unicode_prepare(bytes: &[u8], cell_width: usize, image_id: u32) -> PreparedImage { + let mut cells = Vec::with_capacity(cell_width); + let upload_symbol = kitty_unicode_encode(bytes, cell_width, 1, image_id); + let foreground = Color::Rgb( + ((image_id >> 16) & 0xff) as u8, + ((image_id >> 8) & 0xff) as u8, + (image_id & 0xff) as u8, + ); + let image_id_msb = ((image_id >> 24) & 0xff) as usize; + + for column in 0..cell_width { + let symbol = [ + KITTY_PLACEHOLDER, + row_column_diacritic(0), + row_column_diacritic(column), + row_column_diacritic(image_id_msb), + ] + .into_iter() + .collect(); + + cells.push(PreparedImageCell { + symbol, + style: Style::default().fg(foreground), + skip: false, + }); + } + + PreparedImage { + cells, + cell_width, + upload_data: Some(upload_symbol), + } +} + +fn kitty_unicode_encode( + bytes: &[u8], + cell_width: usize, + cell_height: usize, + image_id: u32, +) -> String { + let base64_str = to_base64_str(bytes); + let chunk_size = 4096; + + let mut s = String::new(); + + let chunks = base64_str.as_bytes().chunks(chunk_size); + let total_chunks = chunks.len(); + + for (i, chunk) in chunks.enumerate() { + s.push_str("\x1b_G"); + if i == 0 { + s.push_str(&format!( + "a=T,f=100,U=1,q=2,i={image_id},c={cell_width},r={cell_height}," + )); + } + if i < total_chunks - 1 { + s.push_str("m=1;"); + } else { + s.push_str("m=0;"); + } + s.push_str(std::str::from_utf8(chunk).unwrap()); + s.push_str("\x1b\\"); + } + + s +} + +fn row_column_diacritic(index: usize) -> char { + ROW_COLUMN_DIACRITICS[index] +} + fn kitty_clear_line(y: u16) { let y = y + 1; // 1-based print!("\x1b_Ga=d,d=Y,y={y};\x1b\\"); @@ -98,3 +547,15 @@ fn kitty_clear_line(y: u16) { fn kitty_clear() { print!("\x1b_Ga=d,d=A;\x1b\\"); } + +fn kitty_unicode_delete_images(image_ids: &[u32]) -> Result<(), io::Error> { + if image_ids.is_empty() { + return Ok(()); + } + + let mut stdout = io::stdout().lock(); + for image_id in image_ids { + write!(stdout, "\x1b_Ga=d,d=I,i={image_id}\x1b\\")?; + } + stdout.flush() +} diff --git a/src/view/detail.rs b/src/view/detail.rs index 0696cdf..c8c0d97 100644 --- a/src/view/detail.rs +++ b/src/view/detail.rs @@ -123,9 +123,7 @@ impl<'a> DetailView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { - let detail_height = (area.height - 1).min(self.ctx.ui_config.detail.height); - let [list_area, detail_area] = - Layout::vertical([Constraint::Min(0), Constraint::Length(detail_height)]).areas(area); + let [list_area, detail_area] = self.split_areas(area); let commit_list = CommitList::new(self.ctx.clone()); f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); @@ -134,6 +132,16 @@ impl<'a> DetailView<'a> { CommitDetail::new(&self.commit, &self.changes, &self.refs, self.ctx.clone()); f.render_stateful_widget(commit_detail, detail_area, &mut self.commit_detail_state); } + + pub fn update_layout(&mut self, area: Rect) { + let [list_area, _] = self.split_areas(area); + self.as_mut_list_state() + .update_height(list_area.height as usize); + } + + pub fn prepare_graph_uploads(&mut self) { + self.as_mut_list_state().ensure_visible_graph_uploaded(); + } } impl<'a> DetailView<'a> { @@ -149,6 +157,19 @@ impl<'a> DetailView<'a> { self.commit_list_state.as_ref().unwrap() } + pub fn drain_pending_graph_uploads(&mut self) -> Vec { + self.as_mut_list_state().drain_pending_graph_uploads() + } + + pub fn graph_image_ids_sorted(&self) -> Vec { + self.as_list_state().graph_image_ids_sorted() + } + + fn split_areas(&self, area: Rect) -> [Rect; 2] { + let detail_height = (area.height - 1).min(self.ctx.ui_config.detail.height); + Layout::vertical([Constraint::Min(0), Constraint::Length(detail_height)]).areas(area) + } + pub fn select_older_commit(&mut self, repository: &Repository) { self.update_selected_commit(repository, |state| state.select_next()); } diff --git a/src/view/help.rs b/src/view/help.rs index 83e34bc..588810d 100644 --- a/src/view/help.rs +++ b/src/view/help.rs @@ -150,6 +150,10 @@ impl<'a> HelpView<'a> { std::mem::take(&mut self.before) } + pub fn graph_image_ids_sorted(&self) -> Vec { + self.before.graph_image_ids_sorted() + } + fn scroll_down(&mut self) { self.offset = self.offset.saturating_add(1); } diff --git a/src/view/list.rs b/src/view/list.rs index bbd93cf..62fa3bd 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -176,6 +176,14 @@ impl<'a> ListView<'a> { let commit_list = CommitList::new(self.ctx.clone()); f.render_stateful_widget(commit_list, area, self.as_mut_list_state()); } + + pub fn update_layout(&mut self, area: Rect) { + self.as_mut_list_state().update_height(area.height as usize); + } + + pub fn prepare_graph_uploads(&mut self) { + self.as_mut_list_state().ensure_visible_graph_uploaded(); + } } impl<'a> ListView<'a> { @@ -191,6 +199,14 @@ impl<'a> ListView<'a> { self.commit_list_state.as_ref().unwrap() } + pub fn drain_pending_graph_uploads(&mut self) -> Vec { + self.as_mut_list_state().drain_pending_graph_uploads() + } + + pub fn graph_image_ids_sorted(&self) -> Vec { + self.as_list_state().graph_image_ids_sorted() + } + fn update_search_query(&self) { if let SearchState::Searching { .. } = self.as_list_state().search_state() { let list_state = self.as_list_state(); diff --git a/src/view/refs.rs b/src/view/refs.rs index b65ea4c..f1d56a0 100644 --- a/src/view/refs.rs +++ b/src/view/refs.rs @@ -97,12 +97,7 @@ impl<'a> RefsView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { - let graph_width = self.as_list_state().graph_area_cell_width() + 1; // graph area + marker - let refs_width = - (area.width.saturating_sub(graph_width)).min(self.ctx.ui_config.refs.width); - - let [list_area, refs_area] = - Layout::horizontal([Constraint::Min(0), Constraint::Length(refs_width)]).areas(area); + let [list_area, refs_area] = self.split_areas(area); let commit_list = CommitList::new(self.ctx.clone()); f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); @@ -110,6 +105,16 @@ impl<'a> RefsView<'a> { let ref_list = RefList::new(&self.refs, self.ctx.clone()); f.render_stateful_widget(ref_list, refs_area, &mut self.ref_list_state); } + + pub fn update_layout(&mut self, area: Rect) { + let [list_area, _] = self.split_areas(area); + self.as_mut_list_state() + .update_height(list_area.height as usize); + } + + pub fn prepare_graph_uploads(&mut self) { + self.as_mut_list_state().ensure_visible_graph_uploaded(); + } } impl<'a> RefsView<'a> { @@ -125,6 +130,21 @@ impl<'a> RefsView<'a> { self.commit_list_state.as_ref().unwrap() } + pub fn drain_pending_graph_uploads(&mut self) -> Vec { + self.as_mut_list_state().drain_pending_graph_uploads() + } + + pub fn graph_image_ids_sorted(&self) -> Vec { + self.as_list_state().graph_image_ids_sorted() + } + + fn split_areas(&self, area: Rect) -> [Rect; 2] { + let graph_width = self.as_list_state().graph_area_cell_width() + 1; // graph area + marker + let refs_width = + (area.width.saturating_sub(graph_width)).min(self.ctx.ui_config.refs.width); + Layout::horizontal([Constraint::Min(0), Constraint::Length(refs_width)]).areas(area) + } + fn update_commit_list_selected(&mut self) { if let Some(selected) = self.ref_list_state.selected_ref_name() { self.as_mut_list_state().select_ref(&selected) diff --git a/src/view/user_command.rs b/src/view/user_command.rs index 8d40218..4258be4 100644 --- a/src/view/user_command.rs +++ b/src/view/user_command.rs @@ -132,10 +132,7 @@ impl<'a> UserCommandView<'a> { } pub fn render(&mut self, f: &mut Frame, area: Rect) { - let user_command_height = (area.height - 1).min(self.ctx.ui_config.user_command.height); - let [list_area, user_command_area] = - Layout::vertical([Constraint::Min(0), Constraint::Length(user_command_height)]) - .areas(area); + let [list_area, user_command_area] = self.split_areas(area); let commit_list = CommitList::new(self.ctx.clone()); f.render_stateful_widget(commit_list, list_area, self.as_mut_list_state()); @@ -148,6 +145,16 @@ impl<'a> UserCommandView<'a> { &mut self.commit_user_command_state, ); } + + pub fn update_layout(&mut self, area: Rect) { + let [list_area, _] = self.split_areas(area); + self.as_mut_list_state() + .update_height(list_area.height as usize); + } + + pub fn prepare_graph_uploads(&mut self) { + self.as_mut_list_state().ensure_visible_graph_uploaded(); + } } impl<'a> UserCommandView<'a> { @@ -163,6 +170,19 @@ impl<'a> UserCommandView<'a> { self.commit_list_state.as_ref().unwrap() } + pub fn drain_pending_graph_uploads(&mut self) -> Vec { + self.as_mut_list_state().drain_pending_graph_uploads() + } + + pub fn graph_image_ids_sorted(&self) -> Vec { + self.as_list_state().graph_image_ids_sorted() + } + + fn split_areas(&self, area: Rect) -> [Rect; 2] { + let user_command_height = (area.height - 1).min(self.ctx.ui_config.user_command.height); + Layout::vertical([Constraint::Min(0), Constraint::Length(user_command_height)]).areas(area) + } + pub fn select_older_commit( &mut self, repository: &Repository, diff --git a/src/view/views.rs b/src/view/views.rs index a1e2b59..c8e4220 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -47,6 +47,50 @@ impl<'a> View<'a> { } } + pub fn update_layout(&mut self, area: Rect) { + match self { + View::Default => {} + View::List(view) => view.update_layout(area), + View::Detail(view) => view.update_layout(area), + View::UserCommand(view) => view.update_layout(area), + View::Refs(view) => view.update_layout(area), + View::Help(_) => {} + } + } + + pub fn prepare_graph_uploads(&mut self) { + match self { + View::Default => {} + View::List(view) => view.prepare_graph_uploads(), + View::Detail(view) => view.prepare_graph_uploads(), + View::UserCommand(view) => view.prepare_graph_uploads(), + View::Refs(view) => view.prepare_graph_uploads(), + View::Help(_) => {} + } + } + + pub fn drain_pending_graph_uploads(&mut self) -> Vec { + match self { + View::Default => Vec::new(), + View::List(view) => view.drain_pending_graph_uploads(), + View::Detail(view) => view.drain_pending_graph_uploads(), + View::UserCommand(view) => view.drain_pending_graph_uploads(), + View::Refs(view) => view.drain_pending_graph_uploads(), + View::Help(_) => Vec::new(), + } + } + + pub fn graph_image_ids_sorted(&self) -> Vec { + match self { + View::Default => Vec::new(), + View::List(view) => view.graph_image_ids_sorted(), + View::Detail(view) => view.graph_image_ids_sorted(), + View::UserCommand(view) => view.graph_image_ids_sorted(), + View::Refs(view) => view.graph_image_ids_sorted(), + View::Help(view) => view.graph_image_ids_sorted(), + } + } + pub fn of_list( commit_list_state: CommitListState<'a>, ctx: Rc, diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 34758c4..d9fb7b2 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -20,6 +20,7 @@ use crate::{ config::UserListColumnType, git::{Commit, CommitHash, Head, Ref}, graph::GraphImageManager, + protocol::PreparedImage, }; static FUZZY_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default().respect_case()); @@ -231,6 +232,47 @@ impl<'a> CommitListState<'a> { self.graph_cell_width + 1 // right pad } + pub fn update_height(&mut self, height: usize) { + self.height = height; + + if self.total > self.height && self.total - self.height < self.offset { + let diff = self.offset - (self.total - self.height); + self.selected += diff; + self.offset -= diff; + } + if self.selected >= self.height { + let diff = self.selected - self.height + 1; + self.selected -= diff; + self.offset += diff; + } + } + + pub fn ensure_visible_graph_uploaded(&mut self) { + self.commits + .iter() + .skip(self.offset) + .take(self.height) + .for_each(|commit_info| { + self.graph_image_manager + .ensure_uploaded(&commit_info.commit.commit_hash); + }); + } + + pub fn drain_pending_graph_uploads(&mut self) -> Vec { + self.graph_image_manager.drain_pending_uploads() + } + + pub fn graph_image_ids_sorted(&self) -> Vec { + let mut image_ids: Vec = self + .graph_image_manager + .image_ids() + .iter() + .copied() + .collect(); + image_ids.sort_unstable(); + image_ids + } + pub fn select_next(&mut self) { if self.selected < (self.total - 1).min(self.height - 1) { self.selected += 1; @@ -647,9 +689,9 @@ impl<'a> CommitListState<'a> { } } - fn encoded_image(&self, commit_info: &'a CommitInfo) -> &str { + fn prepared_image(&self, commit_info: &'a CommitInfo) -> &PreparedImage { self.graph_image_manager - .encoded_image(&commit_info.commit.commit_hash) + .prepared_image(&commit_info.commit.commit_hash) } } @@ -710,29 +752,7 @@ impl<'a> StatefulWidget for CommitList<'a> { impl CommitList<'_> { fn update_state(&self, area: Rect, state: &mut CommitListState) { - state.height = area.height as usize; - - if state.total > state.height && state.total - state.height < state.offset { - let diff = state.offset - (state.total - state.height); - state.selected += diff; - state.offset -= diff; - } - if state.selected >= state.height { - let diff = state.selected - state.height + 1; - state.selected -= diff; - state.offset += diff; - } - - state - .commits - .iter() - .skip(state.offset) - .take(state.height) - .for_each(|commit_info| { - state - .graph_image_manager - .load_encoded_image(&commit_info.commit.commit_hash); - }); + state.update_height(area.height as usize); } fn render_graph(&self, buf: &mut Buffer, area: Rect, state: &CommitListState) { @@ -741,12 +761,19 @@ impl CommitList<'_> { } self.rendering_commit_info_iter(state) .for_each(|(i, commit_info)| { - buf[(area.left(), area.top() + i as u16)] - .set_symbol(state.encoded_image(commit_info)); - - // width - 1 for right pad - for w in 1..area.width - 1 { - buf[(area.left() + w, area.top() + i as u16)].set_skip(true); + let prepared_image = state.prepared_image(commit_info); + let max_graph_width = area.width.saturating_sub(1) as usize; + let y = area.top() + i as u16; + for (x, image_cell) in prepared_image + .cells() + .iter() + .take(max_graph_width) + .enumerate() + { + let cell = &mut buf[(area.left() + x as u16, y)]; + cell.set_symbol(image_cell.symbol()); + cell.set_style(image_cell.style()); + cell.set_skip(image_cell.skip()); } }); }