nandi/jolt-nativepublic Fork 0
48212a7
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-10T14:39:56-07:00 Browse files
48212a7 parent: 51145df
modified crates/jolt-tui/src/screen.rs +49 -0
@@ -277,9 +277,32 @@ impl Screen {
277277 }
278278 }
279279
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+
280302 pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
281303 if x < self.w && y < self.h {
282304 let i = y as usize * self.w as usize + x as usize;
305+ self.displace(i, false);
283306 self.cells[i] = Cell {
284307 ch,
285308 tail: None,
@@ -293,6 +316,7 @@ impl Screen {
293316 fn set_trail(&mut self, x: u16, y: u16, style: Style) {
294317 if x < self.w && y < self.h {
295318 let i = y as usize * self.w as usize + x as usize;
319+ self.displace(i, true);
296320 self.cells[i] = Cell {
297321 ch: ' ',
298322 tail: None,
@@ -342,6 +366,7 @@ impl Screen {
342366 pub fn put(&mut self, x: u16, y: u16, cell: Cell) {
343367 if x < self.w && y < self.h {
344368 let i = y as usize * self.w as usize + x as usize;
369+ self.displace(i, cell.trail);
345370 self.cells[i] = cell;
346371 }
347372 }
@@ -405,6 +430,30 @@ impl Screen {
405430 mod tests {
406431 use super::*;
407432
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+
408457 #[test]
409458 fn colours_parse_in_every_shape_a_caller_writes_them() {
410459 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};
2222
2323 use crate::graphics::{self, Graphics, Placement};
2424 use crate::keys;
25-use crate::screen::{self, attr, Color, Screen, Style};
25+use crate::screen::{attr, Color, Screen, Style};
2626
2727 /// How far one notch of the wheel moves a list, in rows.
2828 const WHEEL_ROWS: i32 = 3;
@@ -174,9 +174,16 @@ impl Term {
174174 let mut style = None;
175175 let mut at: Option<(u16, u16)> = None;
176176 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() {
178179 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 {
180187 continue;
181188 }
182189 // The right half of a double-width glyph is not written: the
@@ -188,6 +195,19 @@ impl Term {
188195 if cell.trail {
189196 continue;
190197 }
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+ }
191211 // Only move when the run breaks: a full-width change is one seek
192212 // and a line of text, not a seek a cell.
193213 if at != Some((x, y)) {
@@ -205,7 +225,10 @@ impl Term {
205225 glyph.push_str(tail);
206226 }
207227 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)) };
209232 }
210233 queue!(self.out, style::ResetColor)?;
211234 // 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: the189 // 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 seek211 // 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: a234 // The pictures after the text, and before the cursor is put back: a