Take a wide glyph off the grid whole, and stop trusting the cursor after one
Painting over half of an emoji left the other half in the grid, which the terminal had already erased; the flush never saw it change again, so a pencil chip stayed on screen after its row had moved. And a terminal that draws ✏️ or ↩️ one column wide left a column of stale text beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48212a7 parent: 51145df modified
crates/jolt-tui/src/screen.rs +49 -0 | @@ -277,9 +277,32 @@ impl Screen { | ||
| 277 | 277 | } |
| 278 | 278 | } |
| 279 | 279 | |
| 280 | + /// Make room at `i` for a cell that is, or is not, the right half of a | |
| 281 | + /// wide glyph. Painting over half of a wide glyph takes the whole glyph | |
| 282 | + /// off a terminal, so the grid has to lose the other half too: a lead | |
| 283 | + /// left without its trail, or a trail without its lead, is a cell the | |
| 284 | + /// flush believes is on screen and a terminal has already erased — and | |
| 285 | + /// since it never changes again, it is never repainted. That is the | |
| 286 | + /// stray pencil left behind once a row's chips are gone. | |
| 287 | + fn displace(&mut self, i: usize, trail: bool) { | |
| 288 | + let x = i % self.w as usize; | |
| 289 | + if !trail && self.cells[i].trail && x > 0 { | |
| 290 | + let lead = &mut self.cells[i - 1]; | |
| 291 | + lead.ch = ' '; | |
| 292 | + lead.tail = None; | |
| 293 | + } | |
| 294 | + if x + 1 < self.w as usize && self.cells[i + 1].trail { | |
| 295 | + let rest = &mut self.cells[i + 1]; | |
| 296 | + rest.ch = ' '; | |
| 297 | + rest.tail = None; | |
| 298 | + rest.trail = false; | |
| 299 | + } | |
| 300 | + } | |
| 301 | + | |
| 280 | 302 | pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { |
| 281 | 303 | if x < self.w && y < self.h { |
| 282 | 304 | let i = y as usize * self.w as usize + x as usize; |
| 305 | + self.displace(i, false); | |
| 283 | 306 | self.cells[i] = Cell { |
| 284 | 307 | ch, |
| 285 | 308 | tail: None, |
| @@ -293,6 +316,7 @@ impl Screen { | ||
| 293 | 316 | fn set_trail(&mut self, x: u16, y: u16, style: Style) { |
| 294 | 317 | if x < self.w && y < self.h { |
| 295 | 318 | let i = y as usize * self.w as usize + x as usize; |
| 319 | + self.displace(i, true); | |
| 296 | 320 | self.cells[i] = Cell { |
| 297 | 321 | ch: ' ', |
| 298 | 322 | tail: None, |
| @@ -342,6 +366,7 @@ impl Screen { | ||
| 342 | 366 | pub fn put(&mut self, x: u16, y: u16, cell: Cell) { |
| 343 | 367 | if x < self.w && y < self.h { |
| 344 | 368 | let i = y as usize * self.w as usize + x as usize; |
| 369 | + self.displace(i, cell.trail); | |
| 345 | 370 | self.cells[i] = cell; |
| 346 | 371 | } |
| 347 | 372 | } |
| @@ -405,6 +430,30 @@ impl Screen { | ||
| 405 | 430 | mod tests { |
| 406 | 431 | use super::*; |
| 407 | 432 | |
| 433 | + #[test] | |
| 434 | + fn painting_over_half_of_a_wide_glyph_takes_the_other_half_with_it() { | |
| 435 | + // Over the left half: the pencil's right half must not stay behind as | |
| 436 | + // a trail with nothing to its left. | |
| 437 | + let mut screen = Screen::new(6, 1); | |
| 438 | + screen.text(0, 0, 6, "a✏️b", Style::default()); | |
| 439 | + screen.set(1, 0, 'x', Style::default()); | |
| 440 | + assert_eq!(screen.line(0), "ax b"); | |
| 441 | + assert!(!screen.cell(2, 0).unwrap().trail); | |
| 442 | + | |
| 443 | + // Over the right half: the left half is blank, as a terminal shows it. | |
| 444 | + let mut screen = Screen::new(6, 1); | |
| 445 | + screen.text(0, 0, 6, "a✏️b", Style::default()); | |
| 446 | + screen.set(2, 0, 'x', Style::default()); | |
| 447 | + assert_eq!(screen.line(0), "a xb"); | |
| 448 | + assert!(screen.cell(1, 0).unwrap().tail.is_none()); | |
| 449 | + | |
| 450 | + // A wide glyph laid over the right half of another keeps only itself. | |
| 451 | + let mut screen = Screen::new(6, 1); | |
| 452 | + screen.text(0, 0, 6, "a✏️b", Style::default()); | |
| 453 | + screen.text(2, 0, 4, "🙂", Style::default()); | |
| 454 | + assert_eq!(screen.line(0), "a 🙂"); | |
| 455 | + } | |
| 456 | + | |
| 408 | 457 | #[test] |
| 409 | 458 | fn colours_parse_in_every_shape_a_caller_writes_them() { |
| 410 | 459 | assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); |
| @@ -277,9 +277,32 @@ impl Screen { | |||
| 277 | } | 277 | } |
| 278 | } | 278 | } |
| 279 | 279 | ||
| 280 | + /// Make room at `i` for a cell that is, or is not, the right half of a | ||
| 281 | + /// wide glyph. Painting over half of a wide glyph takes the whole glyph | ||
| 282 | + /// off a terminal, so the grid has to lose the other half too: a lead | ||
| 283 | + /// left without its trail, or a trail without its lead, is a cell the | ||
| 284 | + /// flush believes is on screen and a terminal has already erased — and | ||
| 285 | + /// since it never changes again, it is never repainted. That is the | ||
| 286 | + /// stray pencil left behind once a row's chips are gone. | ||
| 287 | + fn displace(&mut self, i: usize, trail: bool) { | ||
| 288 | + let x = i % self.w as usize; | ||
| 289 | + if !trail && self.cells[i].trail && x > 0 { | ||
| 290 | + let lead = &mut self.cells[i - 1]; | ||
| 291 | + lead.ch = ' '; | ||
| 292 | + lead.tail = None; | ||
| 293 | + } | ||
| 294 | + if x + 1 < self.w as usize && self.cells[i + 1].trail { | ||
| 295 | + let rest = &mut self.cells[i + 1]; | ||
| 296 | + rest.ch = ' '; | ||
| 297 | + rest.tail = None; | ||
| 298 | + rest.trail = false; | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + | ||
| 280 | pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { | 302 | pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { |
| 281 | if x < self.w && y < self.h { | 303 | if x < self.w && y < self.h { |
| 282 | let i = y as usize * self.w as usize + x as usize; | 304 | let i = y as usize * self.w as usize + x as usize; |
| 305 | + self.displace(i, false); | ||
| 283 | self.cells[i] = Cell { | 306 | self.cells[i] = Cell { |
| 284 | ch, | 307 | ch, |
| 285 | tail: None, | 308 | tail: None, |
| @@ -293,6 +316,7 @@ impl Screen { | |||
| 293 | fn set_trail(&mut self, x: u16, y: u16, style: Style) { | 316 | fn set_trail(&mut self, x: u16, y: u16, style: Style) { |
| 294 | if x < self.w && y < self.h { | 317 | if x < self.w && y < self.h { |
| 295 | let i = y as usize * self.w as usize + x as usize; | 318 | let i = y as usize * self.w as usize + x as usize; |
| 319 | + self.displace(i, true); | ||
| 296 | self.cells[i] = Cell { | 320 | self.cells[i] = Cell { |
| 297 | ch: ' ', | 321 | ch: ' ', |
| 298 | tail: None, | 322 | tail: None, |
| @@ -342,6 +366,7 @@ impl Screen { | |||
| 342 | pub fn put(&mut self, x: u16, y: u16, cell: Cell) { | 366 | pub fn put(&mut self, x: u16, y: u16, cell: Cell) { |
| 343 | if x < self.w && y < self.h { | 367 | if x < self.w && y < self.h { |
| 344 | let i = y as usize * self.w as usize + x as usize; | 368 | let i = y as usize * self.w as usize + x as usize; |
| 369 | + self.displace(i, cell.trail); | ||
| 345 | self.cells[i] = cell; | 370 | self.cells[i] = cell; |
| 346 | } | 371 | } |
| 347 | } | 372 | } |
| @@ -405,6 +430,30 @@ impl Screen { | |||
| 405 | mod tests { | 430 | mod tests { |
| 406 | use super::*; | 431 | use super::*; |
| 407 | 432 | ||
| 433 | + #[test] | ||
| 434 | + fn painting_over_half_of_a_wide_glyph_takes_the_other_half_with_it() { | ||
| 435 | + // Over the left half: the pencil's right half must not stay behind as | ||
| 436 | + // a trail with nothing to its left. | ||
| 437 | + let mut screen = Screen::new(6, 1); | ||
| 438 | + screen.text(0, 0, 6, "a✏️b", Style::default()); | ||
| 439 | + screen.set(1, 0, 'x', Style::default()); | ||
| 440 | + assert_eq!(screen.line(0), "ax b"); | ||
| 441 | + assert!(!screen.cell(2, 0).unwrap().trail); | ||
| 442 | + | ||
| 443 | + // Over the right half: the left half is blank, as a terminal shows it. | ||
| 444 | + let mut screen = Screen::new(6, 1); | ||
| 445 | + screen.text(0, 0, 6, "a✏️b", Style::default()); | ||
| 446 | + screen.set(2, 0, 'x', Style::default()); | ||
| 447 | + assert_eq!(screen.line(0), "a xb"); | ||
| 448 | + assert!(screen.cell(1, 0).unwrap().tail.is_none()); | ||
| 449 | + | ||
| 450 | + // A wide glyph laid over the right half of another keeps only itself. | ||
| 451 | + let mut screen = Screen::new(6, 1); | ||
| 452 | + screen.text(0, 0, 6, "a✏️b", Style::default()); | ||
| 453 | + screen.text(2, 0, 4, "🙂", Style::default()); | ||
| 454 | + assert_eq!(screen.line(0), "a 🙂"); | ||
| 455 | + } | ||
| 456 | + | ||
| 408 | #[test] | 457 | #[test] |
| 409 | fn colours_parse_in_every_shape_a_caller_writes_them() { | 458 | fn colours_parse_in_every_shape_a_caller_writes_them() { |
| 410 | assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); | 459 | assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); |
modified
crates/jolt-tui/src/term.rs +27 -4 | @@ -22,7 +22,7 @@ use crossterm::{cursor, execute, queue, style}; | ||
| 22 | 22 | |
| 23 | 23 | use crate::graphics::{self, Graphics, Placement}; |
| 24 | 24 | use crate::keys; |
| 25 | -use crate::screen::{self, attr, Color, Screen, Style}; | |
| 25 | +use crate::screen::{attr, Color, Screen, Style}; | |
| 26 | 26 | |
| 27 | 27 | /// How far one notch of the wheel moves a list, in rows. |
| 28 | 28 | const WHEEL_ROWS: i32 = 3; |
| @@ -174,9 +174,16 @@ impl Term { | ||
| 174 | 174 | let mut style = None; |
| 175 | 175 | let mut at: Option<(u16, u16)> = None; |
| 176 | 176 | let width = screen.width(); |
| 177 | - for (i, cell) in screen.cells().iter().enumerate() { | |
| 177 | + let cells = screen.cells(); | |
| 178 | + for (i, cell) in cells.iter().enumerate() { | |
| 178 | 179 | let (x, y) = ((i as u16) % width, (i as u16) / width); |
| 179 | - if self.last.cell(x, y) == Some(cell) { | |
| 180 | + let wide = !cell.trail && x + 1 < width && cells.get(i + 1).is_some_and(|c| c.trail); | |
| 181 | + // A wide glyph is its two cells together: when only the right | |
| 182 | + // half changed, a terminal has lost the whole glyph, so the left | |
| 183 | + // half is written again as well. | |
| 184 | + let changed = self.last.cell(x, y) != Some(cell) | |
| 185 | + || (wide && self.last.cell(x + 1, y) != cells.get(i + 1)); | |
| 186 | + if !changed { | |
| 180 | 187 | continue; |
| 181 | 188 | } |
| 182 | 189 | // The right half of a double-width glyph is not written: the |
| @@ -188,6 +195,19 @@ impl Term { | ||
| 188 | 195 | if cell.trail { |
| 189 | 196 | continue; |
| 190 | 197 | } |
| 198 | + if wide { | |
| 199 | + // Blank the right half first. Terminals do not agree on how | |
| 200 | + // wide `✏️` or `↩️` is — a symbol carrying the emoji selector | |
| 201 | + // is two columns in some and one in others — and one that | |
| 202 | + // draws it narrow would otherwise leave whatever stood in that | |
| 203 | + // column standing beside it. | |
| 204 | + if style != Some(cell.style) { | |
| 205 | + write_style(&mut self.out, cell.style)?; | |
| 206 | + style = Some(cell.style); | |
| 207 | + } | |
| 208 | + queue!(self.out, cursor::MoveTo(x + 1, y), style::Print(' '))?; | |
| 209 | + at = None; | |
| 210 | + } | |
| 191 | 211 | // Only move when the run breaks: a full-width change is one seek |
| 192 | 212 | // and a line of text, not a seek a cell. |
| 193 | 213 | if at != Some((x, y)) { |
| @@ -205,7 +225,10 @@ impl Term { | ||
| 205 | 225 | glyph.push_str(tail); |
| 206 | 226 | } |
| 207 | 227 | queue!(self.out, style::Print(&glyph))?; |
| 208 | - at = Some((x + screen::glyph_cols(&glyph), y)); | |
| 228 | + // After a wide glyph, where the cursor went is the terminal's | |
| 229 | + // opinion rather than ours, so the next cell seeks instead of | |
| 230 | + // trusting it. | |
| 231 | + at = if wide { None } else { Some((x + 1, y)) }; | |
| 209 | 232 | } |
| 210 | 233 | queue!(self.out, style::ResetColor)?; |
| 211 | 234 | // The pictures after the text, and before the cursor is put back: a |
| @@ -22,7 +22,7 @@ use crossterm::{cursor, execute, queue, style}; | |||
| 22 | 22 | ||
| 23 | use crate::graphics::{self, Graphics, Placement}; | 23 | use crate::graphics::{self, Graphics, Placement}; |
| 24 | use crate::keys; | 24 | use crate::keys; |
| 25 | -use crate::screen::{self, attr, Color, Screen, Style}; | 25 | +use crate::screen::{attr, Color, Screen, Style}; |
| 26 | 26 | ||
| 27 | /// How far one notch of the wheel moves a list, in rows. | 27 | /// How far one notch of the wheel moves a list, in rows. |
| 28 | const WHEEL_ROWS: i32 = 3; | 28 | const WHEEL_ROWS: i32 = 3; |
| @@ -174,9 +174,16 @@ impl Term { | |||
| 174 | let mut style = None; | 174 | let mut style = None; |
| 175 | let mut at: Option<(u16, u16)> = None; | 175 | let mut at: Option<(u16, u16)> = None; |
| 176 | let width = screen.width(); | 176 | let width = screen.width(); |
| 177 | - for (i, cell) in screen.cells().iter().enumerate() { | 177 | + let cells = screen.cells(); |
| 178 | + for (i, cell) in cells.iter().enumerate() { | ||
| 178 | let (x, y) = ((i as u16) % width, (i as u16) / width); | 179 | let (x, y) = ((i as u16) % width, (i as u16) / width); |
| 179 | - if self.last.cell(x, y) == Some(cell) { | 180 | + let wide = !cell.trail && x + 1 < width && cells.get(i + 1).is_some_and(|c| c.trail); |
| 181 | + // A wide glyph is its two cells together: when only the right | ||
| 182 | + // half changed, a terminal has lost the whole glyph, so the left | ||
| 183 | + // half is written again as well. | ||
| 184 | + let changed = self.last.cell(x, y) != Some(cell) | ||
| 185 | + || (wide && self.last.cell(x + 1, y) != cells.get(i + 1)); | ||
| 186 | + if !changed { | ||
| 180 | continue; | 187 | continue; |
| 181 | } | 188 | } |
| 182 | // The right half of a double-width glyph is not written: the | 189 | // The right half of a double-width glyph is not written: the |
| @@ -188,6 +195,19 @@ impl Term { | |||
| 188 | if cell.trail { | 195 | if cell.trail { |
| 189 | continue; | 196 | continue; |
| 190 | } | 197 | } |
| 198 | + if wide { | ||
| 199 | + // Blank the right half first. Terminals do not agree on how | ||
| 200 | + // wide `✏️` or `↩️` is — a symbol carrying the emoji selector | ||
| 201 | + // is two columns in some and one in others — and one that | ||
| 202 | + // draws it narrow would otherwise leave whatever stood in that | ||
| 203 | + // column standing beside it. | ||
| 204 | + if style != Some(cell.style) { | ||
| 205 | + write_style(&mut self.out, cell.style)?; | ||
| 206 | + style = Some(cell.style); | ||
| 207 | + } | ||
| 208 | + queue!(self.out, cursor::MoveTo(x + 1, y), style::Print(' '))?; | ||
| 209 | + at = None; | ||
| 210 | + } | ||
| 191 | // Only move when the run breaks: a full-width change is one seek | 211 | // Only move when the run breaks: a full-width change is one seek |
| 192 | // and a line of text, not a seek a cell. | 212 | // and a line of text, not a seek a cell. |
| 193 | if at != Some((x, y)) { | 213 | if at != Some((x, y)) { |
| @@ -205,7 +225,10 @@ impl Term { | |||
| 205 | glyph.push_str(tail); | 225 | glyph.push_str(tail); |
| 206 | } | 226 | } |
| 207 | queue!(self.out, style::Print(&glyph))?; | 227 | queue!(self.out, style::Print(&glyph))?; |
| 208 | - at = Some((x + screen::glyph_cols(&glyph), y)); | 228 | + // After a wide glyph, where the cursor went is the terminal's |
| 229 | + // opinion rather than ours, so the next cell seeks instead of | ||
| 230 | + // trusting it. | ||
| 231 | + at = if wide { None } else { Some((x + 1, y)) }; | ||
| 209 | } | 232 | } |
| 210 | queue!(self.out, style::ResetColor)?; | 233 | queue!(self.out, style::ResetColor)?; |
| 211 | // The pictures after the text, and before the cursor is put back: a | 234 | // The pictures after the text, and before the cursor is put back: a |