diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bef79..bef60bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,58 @@ Procedure when bumping the version number: ## Unreleased -## v0.1.0 - 2023-05.14 +## v0.3.4 - 2025-03-8 + +### Added +- `Frame::set_bell` to print a bell character when the frame is displayed +- `widgets::bell` + +## v0.3.3 - 2025-02-28 + +### Fixed +- Rendering glitches in unicode-based with estimation + +## v0.3.2 - 2025-02-23 + +### Added +- Unicode-based grapheme width estimation method + +## v0.3.1 - 2025-02-21 + +### Fixed +- Rendering glitches, mainly related to emoji + +## v0.3.0 - 2024-11-06 + +### Added +- `Terminal::mark_dirty` + +### Changed +- **(breaking)** Updated dependencies + +## v0.2.3 - 2024-04-25 + +### Fixed +- Width measurements of ASCII control characters +- Toss messing up the terminal state + +## v0.2.2 - 2024-01-14 + +### Fixed +- Crash when drawing `widgets::Predrawn` with width 0 + +## v0.2.1 - 2024-01-05 + +### Added +- `Frame::set_title` +- `WidgetExt::title` +- `widgets::title` + +## v0.2.0 - 2023-08-31 + +### Changed +- **(breaking)** Updated dependencies + +## v0.1.0 - 2023-05-14 Initial release diff --git a/Cargo.toml b/Cargo.toml index 3de8599..22967fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "toss" -version = "0.1.0" +version = "0.3.4" edition = "2021" [dependencies] -async-trait = "0.1.68" -crossterm = "0.26.1" -unicode-linebreak = "0.1.4" -unicode-segmentation = "1.10.1" -unicode-width = "0.1.10" +async-trait = "0.1.83" +crossterm = "0.28.1" +unicode-linebreak = "0.1.5" +unicode-segmentation = "1.12.0" +unicode-width = "0.2.0" diff --git a/src/buffer.rs b/src/buffer.rs index 094a143..022145b 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -332,6 +332,9 @@ impl<'a> Iterator for Cells<'a> { type Item = (u16, u16, &'a Cell); fn next(&mut self) -> Option { + if self.x >= self.buffer.size.width { + return None; + } if self.y >= self.buffer.size.height { return None; } diff --git a/src/frame.rs b/src/frame.rs index 2e9bad1..e42ba6b 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -7,6 +7,8 @@ use crate::{Pos, Size, Styled, WidthDb}; pub struct Frame { pub(crate) widthdb: WidthDb, pub(crate) buffer: Buffer, + pub(crate) title: Option, + pub(crate) bell: bool, } impl Frame { @@ -24,6 +26,7 @@ impl Frame { pub fn reset(&mut self) { self.buffer.reset(); + self.title = None; } pub fn cursor(&self) -> Option { @@ -42,6 +45,14 @@ impl Frame { self.set_cursor(None); } + pub fn set_title(&mut self, title: Option) { + 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 db7dcdd..07fe686 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -8,15 +8,15 @@ 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, + LeaveAlternateScreen, SetTitle, }; 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. /// @@ -68,12 +68,12 @@ impl Terminal { /// presenting the next frame. pub fn suspend(&mut self) -> io::Result<()> { crossterm::terminal::disable_raw_mode()?; - self.out.execute(LeaveAlternateScreen)?; #[cfg(not(windows))] { - self.out.execute(DisableBracketedPaste)?; self.out.execute(PopKeyboardEnhancementFlags)?; + self.out.execute(DisableBracketedPaste)?; } + self.out.execute(LeaveAlternateScreen)?; self.out.execute(Show)?; Ok(()) } @@ -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 @@ -193,6 +207,12 @@ impl Terminal { &mut self.frame.widthdb } + /// Mark the terminal as dirty, forcing a full redraw whenever any variant + /// of [`Self::present`] is called. + pub fn mark_dirty(&mut self) { + self.full_redraw = true; + } + /// Display the current frame on the screen and prepare the next frame. /// /// Before drawing and presenting a frame, [`Self::measure_widths`] and @@ -253,6 +273,8 @@ impl Terminal { self.draw_differences()?; self.update_cursor()?; + self.update_title()?; + self.ring_bell()?; Ok(()) } @@ -287,4 +309,19 @@ impl Terminal { self.out.queue(Hide)?; Ok(()) } + + fn update_title(&mut self) -> io::Result<()> { + if let Some(title) = &self.frame.title { + self.out.queue(SetTitle(title.clone()))?; + } + Ok(()) + } + + fn ring_bell(&mut self) -> io::Result<()> { + if self.frame.bell { + self.out.queue(Print('\x07'))?; + } + self.frame.bell = false; + Ok(()) + } } diff --git a/src/widget.rs b/src/widget.rs index 58ce562..356a047 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use crate::widgets::{ Background, Border, Boxed, BoxedAsync, BoxedSendSync, Desync, Either2, Either3, Float, - JoinSegment, Layer2, Padding, Resize, + JoinSegment, Layer2, Padding, Resize, Title, }; use crate::{Frame, Size, WidthDb}; @@ -60,6 +60,7 @@ pub trait WidgetExt: Sized { { BoxedAsync::new(self) } + fn desync(self) -> Desync { Desync(self) } @@ -107,6 +108,10 @@ pub trait WidgetExt: Sized { fn resize(self) -> Resize { Resize::new(self) } + + fn title(self, title: S) -> Title { + Title::new(self, title) + } } // It would be nice if this could be restricted to types implementing Widget. diff --git a/src/widgets.rs b/src/widgets.rs index 7c3fc55..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; @@ -13,8 +14,10 @@ pub mod padding; pub mod predrawn; pub mod resize; pub mod text; +pub mod title; pub use background::*; +pub use bell::*; pub use border::*; pub use boxed::*; pub use cursor::*; @@ -29,3 +32,4 @@ pub use padding::*; pub use predrawn::*; pub use resize::*; pub use text::*; +pub use title::*; 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(()) + } +} diff --git a/src/widgets/editor.rs b/src/widgets/editor.rs index 3417ebe..aa36e8e 100644 --- a/src/widgets/editor.rs +++ b/src/widgets/editor.rs @@ -465,7 +465,7 @@ impl Widget for Editor<'_> { Ok(Size::new(width, height)) } - fn draw(mut self, frame: &mut Frame) -> Result<(), E> { + fn draw(self, frame: &mut Frame) -> Result<(), E> { let size = frame.size(); let indices = self.indices(frame.widthdb(), Some(size.width)); let rows = self.rows(&indices); diff --git a/src/widgets/title.rs b/src/widgets/title.rs new file mode 100644 index 0000000..c0dc0d4 --- /dev/null +++ b/src/widgets/title.rs @@ -0,0 +1,59 @@ +use async_trait::async_trait; + +use crate::{AsyncWidget, Frame, Size, Widget, WidthDb}; + +#[derive(Debug, Clone)] +pub struct Title { + pub inner: I, + pub title: String, +} + +impl Title { + pub fn new(inner: I, title: S) -> Self { + Self { + inner, + title: title.to_string(), + } + } +} + +impl Widget for Title +where + I: Widget, +{ + fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option, + max_height: Option, + ) -> Result { + self.inner.size(widthdb, max_width, max_height) + } + + fn draw(self, frame: &mut Frame) -> Result<(), E> { + self.inner.draw(frame)?; + frame.set_title(Some(self.title)); + Ok(()) + } +} + +#[async_trait] +impl AsyncWidget for Title +where + I: AsyncWidget + Send + Sync, +{ + async fn size( + &self, + widthdb: &mut WidthDb, + max_width: Option, + max_height: Option, + ) -> Result { + self.inner.size(widthdb, max_width, max_height).await + } + + async fn draw(self, frame: &mut Frame) -> Result<(), E> { + self.inner.draw(frame).await?; + frame.set_title(Some(self.title)); + Ok(()) + } +} diff --git a/src/widthdb.rs b/src/widthdb.rs index 7d18570..fe5a26e 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::UnicodeWidthStr; +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(), @@ -47,14 +65,37 @@ impl WidthDb { if grapheme == "\t" { return self.tab_width_at_column(col); } - if !self.active { - return grapheme.width() as u8; - } - 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()); - grapheme.width() as u8 + } + + 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 control chars to have a width + // of 1 even though they usually have a width of 0 when displayed. + WidthEstimationMethod::Unicode => grapheme + .split(|c: char| c.is_ascii_control()) + .map(|s| s.width()) + .sum::() + .try_into() + .unwrap_or(u8::MAX), } } @@ -87,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 @@ -97,10 +138,19 @@ 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() { + 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 + // assign each control character a with of 0. + self.known.insert(grapheme, 0); + continue; + } + out.queue(Clear(ClearType::All))? .queue(MoveTo(0, 0))? .queue(Print(&grapheme))?;