From 1618264cb701917e90353b70bc6edd534bfdecd8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 20 Feb 2025 21:24:07 +0100 Subject: [PATCH 01/10] Fix newlines causing bad rendering artifacts The unicode-width crate has started to consider newlines to have a width of 1 instead of 0. --- src/widthdb.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/widthdb.rs b/src/widthdb.rs index 200765f..9815190 100644 --- a/src/widthdb.rs +++ b/src/widthdb.rs @@ -47,9 +47,13 @@ impl WidthDb { if grapheme == "\t" { return self.tab_width_at_column(col); } + if grapheme.chars().any(|c| c.is_ascii_control()) { + return 0; // See measure_widths function + } if !self.active { return grapheme.width() as u8; } + if let Some(width) = self.known.get(grapheme) { *width } else { @@ -101,7 +105,7 @@ impl WidthDb { return Ok(()); } for grapheme in self.requested.drain() { - if grapheme.chars().any(|c|c.is_ascii_control()){ + if grapheme.chars().any(|c| c.is_ascii_control()) { // ASCII control characters like the escape character or the // bell character tend to be interpreted specially by terminals. // This may break width measurements. To avoid this, we just From 77a02116a64d300264277bfade5553a1a8d9f01d Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 20 Feb 2025 22:08:10 +0100 Subject: [PATCH 02/10] Fix grapheme width estimation I'm pretty sure it still breaks in lots of terminal emulators, but it works far better than what recent versions of the unicode_width crate were doing. --- CHANGELOG.md | 3 +++ src/widthdb.rs | 30 +++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13edb2a..e18e88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Procedure when bumping the version number: ## Unreleased +### Fixed +- Rendering glitches, mainly related to emoji + ## v0.3.0 - 2024-11-06 ### Added diff --git a/src/widthdb.rs b/src/widthdb.rs index 9815190..53f20ec 100644 --- a/src/widthdb.rs +++ b/src/widthdb.rs @@ -6,7 +6,7 @@ use crossterm::style::Print; use crossterm::terminal::{Clear, ClearType}; use crossterm::QueueableCommand; use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; +use unicode_width::UnicodeWidthChar; use crate::wrap; @@ -36,6 +36,26 @@ impl WidthDb { self.tab_width - (col % self.tab_width as usize) as u8 } + /// Estimate what our terminal emulator thinks the width of a grapheme is. + /// + /// Different terminal emulators are all broken in different ways, so this + /// method will never be able to give a correct solution. For that, the only + /// possible method is actually measuring. + /// + /// Instead, it implements a character-wise width calculation. The hope is + /// that dumb terminal emulators do something roughly like this, and smart + /// terminal emulators try to emulate dumb ones for compatibility. In + /// practice, this counting approach seems to be fairly robust. + fn grapheme_width_estimate(grapheme: &str) -> u8 { + grapheme + .chars() + .filter(|c| !c.is_ascii_control()) + .flat_map(|c| c.width()) + .sum::() + .try_into() + .unwrap_or(u8::MAX) + } + /// Determine the width of a grapheme. /// /// If the grapheme is a tab, the column is used to determine its width. @@ -47,18 +67,14 @@ impl WidthDb { if grapheme == "\t" { return self.tab_width_at_column(col); } - if grapheme.chars().any(|c| c.is_ascii_control()) { - return 0; // See measure_widths function - } if !self.active { - return grapheme.width() as u8; + return Self::grapheme_width_estimate(grapheme); } - if let Some(width) = self.known.get(grapheme) { *width } else { self.requested.insert(grapheme.to_string()); - grapheme.width() as u8 + Self::grapheme_width_estimate(grapheme) } } From be7eff0979e0e95d070e7c9cea42c328ffd04cc4 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 21 Feb 2025 00:36:39 +0100 Subject: [PATCH 03/10] Bump version to 0.3.1 --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e18e88a..d84e1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Procedure when bumping the version number: ## Unreleased +## v0.3.1 - 2025-02-21 + ### Fixed - Rendering glitches, mainly related to emoji diff --git a/Cargo.toml b/Cargo.toml index 4083c28..5adf243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toss" -version = "0.3.0" +version = "0.3.1" edition = "2021" [dependencies] From 423dd100c1360decffc5107ea4757d751ac0f4db Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 23 Feb 2025 17:19:59 +0100 Subject: [PATCH 04/10] Add unicode-based grapheme width estimation method --- CHANGELOG.md | 3 ++ src/terminal.rs | 20 ++++++++++-- src/widthdb.rs | 85 ++++++++++++++++++++++++++++++------------------- 3 files changed, 73 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84e1fe..6292746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Procedure when bumping the version number: ## Unreleased +### Added +- Unicode-based grapheme width estimation method + ## v0.3.1 - 2025-02-21 ### Fixed diff --git a/src/terminal.rs b/src/terminal.rs index 439ce4e..c26b0fc 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -16,7 +16,7 @@ use crossterm::terminal::{ use crossterm::{ExecutableCommand, QueueableCommand}; use crate::buffer::Buffer; -use crate::{AsyncWidget, Frame, Size, Widget, WidthDb}; +use crate::{AsyncWidget, Frame, Size, Widget, WidthDb, WidthEstimationMethod}; /// Wrapper that manages terminal output. /// @@ -112,11 +112,25 @@ impl Terminal { self.frame.widthdb.tab_width } + /// Set the grapheme width estimation method. + /// + /// For more details, see [`WidthEstimationMethod`]. + pub fn set_width_estimation_method(&mut self, method: WidthEstimationMethod) { + self.frame.widthdb.estimate = method; + } + + /// The grapheme width estimation method. + /// + /// For more details, see [`WidthEstimationMethod`]. + pub fn width_estimation_method(&mut self) -> WidthEstimationMethod { + self.frame.widthdb.estimate + } + /// Enable or disable grapheme width measurements. /// /// For more details, see [`Self::measuring`]. pub fn set_measuring(&mut self, active: bool) { - self.frame.widthdb.active = active; + self.frame.widthdb.measure = active; } /// Whether grapheme widths should be measured or estimated. @@ -135,7 +149,7 @@ impl Terminal { /// Standard Annex #11. This usually works fine, but may break on some emoji /// or other less commonly used character sequences. pub fn measuring(&self) -> bool { - self.frame.widthdb.active + self.frame.widthdb.measure } /// Whether any unmeasured graphemes were seen since the last call to diff --git a/src/widthdb.rs b/src/widthdb.rs index 53f20ec..bb21ef6 100644 --- a/src/widthdb.rs +++ b/src/widthdb.rs @@ -6,14 +6,31 @@ use crossterm::style::Print; use crossterm::terminal::{Clear, ClearType}; use crossterm::QueueableCommand; use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthChar; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::wrap; +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum WidthEstimationMethod { + /// Estimate the width of a grapheme using legacy methods. + /// + /// Different terminal emulators all use different approaches to determine + /// grapheme widths, so this method will never be able to give a fully + /// correct solution. For that, the only possible approach is measuring the + /// actual grapheme width. + #[default] + Legacy, + + /// Estimate the width of a grapheme using the unicode standard in a + /// best-effort manner. + Unicode, +} + /// Measures and stores the with (in terminal coordinates) of graphemes. #[derive(Debug)] pub struct WidthDb { - pub(crate) active: bool, + pub(crate) estimate: WidthEstimationMethod, + pub(crate) measure: bool, pub(crate) tab_width: u8, known: HashMap, requested: HashSet, @@ -22,7 +39,8 @@ pub struct WidthDb { impl Default for WidthDb { fn default() -> Self { Self { - active: false, + estimate: WidthEstimationMethod::default(), + measure: false, tab_width: 8, known: Default::default(), requested: Default::default(), @@ -36,26 +54,6 @@ impl WidthDb { self.tab_width - (col % self.tab_width as usize) as u8 } - /// Estimate what our terminal emulator thinks the width of a grapheme is. - /// - /// Different terminal emulators are all broken in different ways, so this - /// method will never be able to give a correct solution. For that, the only - /// possible method is actually measuring. - /// - /// Instead, it implements a character-wise width calculation. The hope is - /// that dumb terminal emulators do something roughly like this, and smart - /// terminal emulators try to emulate dumb ones for compatibility. In - /// practice, this counting approach seems to be fairly robust. - fn grapheme_width_estimate(grapheme: &str) -> u8 { - grapheme - .chars() - .filter(|c| !c.is_ascii_control()) - .flat_map(|c| c.width()) - .sum::() - .try_into() - .unwrap_or(u8::MAX) - } - /// Determine the width of a grapheme. /// /// If the grapheme is a tab, the column is used to determine its width. @@ -67,14 +65,37 @@ impl WidthDb { if grapheme == "\t" { return self.tab_width_at_column(col); } - if !self.active { - return Self::grapheme_width_estimate(grapheme); - } - if let Some(width) = self.known.get(grapheme) { - *width - } else { + + if self.measure { + if let Some(width) = self.known.get(grapheme) { + return *width; + } self.requested.insert(grapheme.to_string()); - Self::grapheme_width_estimate(grapheme) + } + + match self.estimate { + // A character-wise width calculation is a simple and obvious + // approach to compute character widths. The idea is that dumb + // terminal emulators tend to do something roughly like this, and + // smart terminal emulators try to emulate dumb ones for + // compatibility. In practice, this approach seems to be fairly + // robust. + WidthEstimationMethod::Legacy => grapheme + .chars() + .filter(|c| !c.is_ascii_control()) + .flat_map(|c| c.width()) + .sum::() + .try_into() + .unwrap_or(u8::MAX), + + // The unicode width crate considers newlines to have a width of 1 + // while the rendering code expects it to have a width of 0. + WidthEstimationMethod::Unicode => grapheme + .split('\n') + .map(|s| s.width()) + .sum::() + .try_into() + .unwrap_or(u8::MAX), } } @@ -107,7 +128,7 @@ impl WidthDb { /// Whether any new graphemes have been seen since the last time /// [`Self::measure_widths`] was called. pub(crate) fn measuring_required(&self) -> bool { - self.active && !self.requested.is_empty() + self.measure && !self.requested.is_empty() } /// Measure the width of all new graphemes that have been seen since the @@ -117,7 +138,7 @@ impl WidthDb { /// the terminal. After it finishes, the terminal's contents should be /// assumed to be garbage and a full redraw should be performed. pub(crate) fn measure_widths(&mut self, out: &mut impl Write) -> io::Result<()> { - if !self.active { + if !self.measure { return Ok(()); } for grapheme in self.requested.drain() { From d28ce90ec7590778e6035a7b00b1d85064f03dbf Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 23 Feb 2025 23:31:25 +0100 Subject: [PATCH 05/10] Bump version to 0.3.2 --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6292746..0305201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Procedure when bumping the version number: ## Unreleased +## v0.3.2 - 2025-02-23 + ### Added - Unicode-based grapheme width estimation method diff --git a/Cargo.toml b/Cargo.toml index 5adf243..af2b654 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toss" -version = "0.3.1" +version = "0.3.2" edition = "2021" [dependencies] From 712c1537adbef0db607d39c2320a4d8d0b2a5b36 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 28 Feb 2025 14:29:53 +0100 Subject: [PATCH 06/10] Fix incorrect width estimation of ascii control characters --- CHANGELOG.md | 3 +++ src/widthdb.rs | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0305201..b70bde6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Procedure when bumping the version number: ## Unreleased +### Fixed +- Rendering glitches in unicode-based with estimation + ## v0.3.2 - 2025-02-23 ### Added diff --git a/src/widthdb.rs b/src/widthdb.rs index bb21ef6..fe5a26e 100644 --- a/src/widthdb.rs +++ b/src/widthdb.rs @@ -88,10 +88,10 @@ impl WidthDb { .try_into() .unwrap_or(u8::MAX), - // The unicode width crate considers newlines to have a width of 1 - // while the rendering code expects it to have a width of 0. + // The unicode width crate considers control chars to have a width + // of 1 even though they usually have a width of 0 when displayed. WidthEstimationMethod::Unicode => grapheme - .split('\n') + .split(|c: char| c.is_ascii_control()) .map(|s| s.width()) .sum::() .try_into() From 96b2e13c4a4b0174601d90246d92d148c4230eeb Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 28 Feb 2025 14:30:45 +0100 Subject: [PATCH 07/10] Bump version to 0.3.3 --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b70bde6..48e4615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Procedure when bumping the version number: ## Unreleased +## v0.3.3 - 2025-02-28 + ### Fixed - Rendering glitches in unicode-based with estimation diff --git a/Cargo.toml b/Cargo.toml index af2b654..6868881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toss" -version = "0.3.2" +version = "0.3.3" edition = "2021" [dependencies] From 89b4595ed9228df2ce976e4b0630f5dba1208474 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 8 Mar 2025 19:05:38 +0100 Subject: [PATCH 08/10] Print bell character --- CHANGELOG.md | 3 +++ src/frame.rs | 5 +++++ src/terminal.rs | 11 ++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e4615..c43f3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Procedure when bumping the version number: ## Unreleased +### Added +- `Frame::set_bell` to print a bell character when the frame is displayed + ## v0.3.3 - 2025-02-28 ### Fixed diff --git a/src/frame.rs b/src/frame.rs index 03fbb04..e42ba6b 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -8,6 +8,7 @@ pub struct Frame { pub(crate) widthdb: WidthDb, pub(crate) buffer: Buffer, pub(crate) title: Option, + pub(crate) bell: bool, } impl Frame { @@ -48,6 +49,10 @@ impl Frame { self.title = title; } + pub fn set_bell(&mut self, bell: bool) { + self.bell = bell; + } + pub fn widthdb(&mut self) -> &mut WidthDb { &mut self.widthdb } diff --git a/src/terminal.rs b/src/terminal.rs index c26b0fc..07fe686 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -8,7 +8,7 @@ use crossterm::event::{ DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; -use crossterm::style::{PrintStyledContent, StyledContent}; +use crossterm::style::{Print, PrintStyledContent, StyledContent}; use crossterm::terminal::{ BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, @@ -274,6 +274,7 @@ impl Terminal { self.draw_differences()?; self.update_cursor()?; self.update_title()?; + self.ring_bell()?; Ok(()) } @@ -315,4 +316,12 @@ impl Terminal { } Ok(()) } + + fn ring_bell(&mut self) -> io::Result<()> { + if self.frame.bell { + self.out.queue(Print('\x07'))?; + } + self.frame.bell = false; + Ok(()) + } } From e3af509358dd57e24e3073de8a32640d6a3832d5 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 8 Mar 2025 19:10:29 +0100 Subject: [PATCH 09/10] Add bell widget --- CHANGELOG.md | 1 + src/widgets.rs | 2 ++ src/widgets/bell.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/widgets/bell.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c43f3dc..c30abdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Procedure when bumping the version number: ### Added - `Frame::set_bell` to print a bell character when the frame is displayed +- `widgets::bell` ## v0.3.3 - 2025-02-28 diff --git a/src/widgets.rs b/src/widgets.rs index 28b44d8..cbbff7c 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -1,4 +1,5 @@ pub mod background; +pub mod bell; pub mod border; pub mod boxed; pub mod cursor; @@ -16,6 +17,7 @@ pub mod text; pub mod title; pub use background::*; +pub use bell::*; pub use border::*; pub use boxed::*; pub use cursor::*; diff --git a/src/widgets/bell.rs b/src/widgets/bell.rs new file mode 100644 index 0000000..b37fb67 --- /dev/null +++ b/src/widgets/bell.rs @@ -0,0 +1,55 @@ +use crate::{Frame, Size, Widget, WidthDb}; + +/////////// +// State // +/////////// + +#[derive(Debug, Default, Clone)] +pub struct BellState { + // Whether the bell should be rung the next time the widget is displayed. + pub ring: bool, +} + +impl BellState { + pub fn new() -> Self { + Self::default() + } + + pub fn widget(&mut self) -> Bell<'_> { + Bell { state: self } + } +} + +//////////// +// Widget // +//////////// + +#[derive(Debug)] +pub struct Bell<'a> { + state: &'a mut BellState, +} + +impl Bell<'_> { + pub fn state(&mut self) -> &mut BellState { + self.state + } +} + +impl Widget for Bell<'_> { + fn size( + &self, + _widthdb: &mut WidthDb, + _max_width: Option, + _max_height: Option, + ) -> Result { + Ok(Size::ZERO) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + if self.state.ring { + frame.set_bell(true); + self.state.ring = false + } + Ok(()) + } +} From 57aa8c59308f6f0aa82bde415a42b56c3d6f7c4d Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 8 Mar 2025 19:30:44 +0100 Subject: [PATCH 10/10] Bump version to 0.3.4 --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30abdf..bef60bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Procedure when bumping the version number: ## Unreleased +## v0.3.4 - 2025-03-8 + ### Added - `Frame::set_bell` to print a bell character when the frame is displayed - `widgets::bell` diff --git a/Cargo.toml b/Cargo.toml index 6868881..22967fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toss" -version = "0.3.3" +version = "0.3.4" edition = "2021" [dependencies]