Compare commits

..

27 commits

Author SHA1 Message Date
57aa8c5930 Bump version to 0.3.4 2025-03-08 19:30:44 +01:00
e3af509358 Add bell widget 2025-03-08 19:10:29 +01:00
89b4595ed9 Print bell character 2025-03-08 19:05:38 +01:00
96b2e13c4a Bump version to 0.3.3 2025-02-28 14:30:45 +01:00
712c1537ad Fix incorrect width estimation of ascii control characters 2025-02-28 14:29:53 +01:00
d28ce90ec7 Bump version to 0.3.2 2025-02-23 23:31:25 +01:00
423dd100c1 Add unicode-based grapheme width estimation method 2025-02-23 18:36:42 +01:00
be7eff0979 Bump version to 0.3.1 2025-02-21 00:36:39 +01:00
77a02116a6 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.
2025-02-20 22:10:11 +01:00
1618264cb7 Fix newlines causing bad rendering artifacts
The unicode-width crate has started to consider newlines to have a width
of 1 instead of 0.
2025-02-20 22:08:37 +01:00
73a0268dfd Bump version to 0.3.0 2024-11-06 22:21:20 +01:00
65f31a2697 Update dependencies 2024-11-06 22:17:28 +01:00
3a5ce3832b Add Terminal::mark_dirty 2024-11-06 22:16:48 +01:00
b1d7221bae Bump version to 0.2.3 2024-04-25 20:14:26 +02:00
0f7505ebb4 Update dependencies 2024-04-25 20:13:42 +02:00
ef6d75c23a Fix suspend sequence
In my kitty-based setup, I observed the following bug:

1. Run cove[1], a toss-based application, in a kitty tab
2. Exit cove
3. Start lazygit[2]
4. Stage some files and enter a commit message
5. Try to press enter and observe garbage appearing in the text box

The bug occurred reliably after running cove, but never occurred if cove
was not run in that tab.

This commit fixes the bug by making the suspend sequence undo the
unsuspend sequence's steps in reverse order.
2024-04-25 20:07:48 +02:00
8556fd8176 Fix control character width measurement 2024-04-25 19:46:10 +02:00
94052c5a65 Fix formatting 2024-03-04 00:08:33 +01:00
761e8baeba Bump version to 0.2.2 2024-01-14 12:38:33 +01:00
2d604d606c Fix crash when drawing Predrawn with width 0 2024-01-14 12:32:21 +01:00
b01ee297d5 Bump version to 0.2.1 2024-01-05 13:54:42 +01:00
44512f1088 Update dependencies 2024-01-05 13:53:13 +01:00
b757f1be03 Add Title widget 2024-01-05 13:52:25 +01:00
2714deeafb Add support for setting window title 2024-01-05 13:33:08 +01:00
77b4f825c9 Fix clippy warning 2024-01-05 13:23:18 +01:00
2c7888fa41 Bump version to 0.2.0 2023-08-31 13:23:00 +02:00
f6cbba5231 Update dependencies 2023-08-31 13:20:54 +02:00
11 changed files with 304 additions and 28 deletions

View file

@ -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

View file

@ -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"

View file

@ -332,6 +332,9 @@ impl<'a> Iterator for Cells<'a> {
type Item = (u16, u16, &'a Cell);
fn next(&mut self) -> Option<Self::Item> {
if self.x >= self.buffer.size.width {
return None;
}
if self.y >= self.buffer.size.height {
return None;
}

View file

@ -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<String>,
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<Pos> {
@ -42,6 +45,14 @@ impl Frame {
self.set_cursor(None);
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn set_bell(&mut self, bell: bool) {
self.bell = bell;
}
pub fn widthdb(&mut self) -> &mut WidthDb {
&mut self.widthdb
}

View file

@ -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(())
}
}

View file

@ -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<Self> {
Desync(self)
}
@ -107,6 +108,10 @@ pub trait WidgetExt: Sized {
fn resize(self) -> Resize<Self> {
Resize::new(self)
}
fn title<S: ToString>(self, title: S) -> Title<Self> {
Title::new(self, title)
}
}
// It would be nice if this could be restricted to types implementing Widget.

View file

@ -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::*;

55
src/widgets/bell.rs Normal file
View file

@ -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<E> Widget<E> for Bell<'_> {
fn size(
&self,
_widthdb: &mut WidthDb,
_max_width: Option<u16>,
_max_height: Option<u16>,
) -> Result<Size, E> {
Ok(Size::ZERO)
}
fn draw(self, frame: &mut Frame) -> Result<(), E> {
if self.state.ring {
frame.set_bell(true);
self.state.ring = false
}
Ok(())
}
}

View file

@ -465,7 +465,7 @@ impl<E> Widget<E> 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);

59
src/widgets/title.rs Normal file
View file

@ -0,0 +1,59 @@
use async_trait::async_trait;
use crate::{AsyncWidget, Frame, Size, Widget, WidthDb};
#[derive(Debug, Clone)]
pub struct Title<I> {
pub inner: I,
pub title: String,
}
impl<I> Title<I> {
pub fn new<S: ToString>(inner: I, title: S) -> Self {
Self {
inner,
title: title.to_string(),
}
}
}
impl<E, I> Widget<E> for Title<I>
where
I: Widget<E>,
{
fn size(
&self,
widthdb: &mut WidthDb,
max_width: Option<u16>,
max_height: Option<u16>,
) -> Result<Size, E> {
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<E, I> AsyncWidget<E> for Title<I>
where
I: AsyncWidget<E> + Send + Sync,
{
async fn size(
&self,
widthdb: &mut WidthDb,
max_width: Option<u16>,
max_height: Option<u16>,
) -> Result<Size, E> {
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(())
}
}

View file

@ -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<String, u8>,
requested: HashSet<String>,
@ -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 self.measure {
if let Some(width) = self.known.get(grapheme) {
*width
} else {
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::<usize>()
.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::<usize>()
.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))?;