Skip to content

Commit d6e42e6

Browse files
WendellXYclaude
andcommitted
fix(cli): eliminate TUI flash on key navigation entirely
Replace terminal.clear() with a zero-flash technique: toggle an invisible BOLD modifier on all cells in the translations panel when the key changes. Bold-on-space is visually identical to normal-space, but ratatui's diff renderer treats it as "changed" and re-sends every cell in the area, overwriting ghost glyphs from Arabic/Bengali/Hindi complex-script characters. Previously, terminal.clear() sent a ClearType::All escape code causing a full-screen flash. The later "detect complex scripts" heuristic was also ineffective because multilingual files have complex-script translations on every key. The fix is purely in ratatui's buffer layer — no escape codes, no screen blanking. frame.buffer_mut().set_style() stamps the alternating style onto all cells before any widget renders; widgets overwrite content cells with their own styles, so only empty cells retain the toggled BOLD, keeping them visually invisible while forcing a diff-driven re-send. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2ca0216 commit d6e42e6

3 files changed

Lines changed: 28 additions & 58 deletions

File tree

langcodec-cli/src/editor/app.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ pub struct App {
7575
pub translation_scroll: u16,
7676
/// Set to true while waiting for delete confirmation
7777
pub confirm_delete: bool,
78+
/// Toggled on every key navigation; used by the renderer to force
79+
/// ratatui's diff to re-send all cells in the translations panel,
80+
/// eliminating ghost glyphs from complex-script characters.
81+
pub redraw_token: bool,
7882
}
7983

8084
impl App {
@@ -128,6 +132,7 @@ impl App {
128132
split_ratio: 38,
129133
translation_scroll: 0,
130134
confirm_delete: false,
135+
redraw_token: false,
131136
}
132137
}
133138

@@ -396,6 +401,7 @@ impl App {
396401
self.dirty = true;
397402
self.status_message =
398403
Some((format!("Deleted key '{key}'"), StatusTone::Success));
404+
self.redraw_token = !self.redraw_token;
399405
}
400406
self.confirm_delete = false;
401407
self.input_mode = InputMode::Normal;
@@ -416,6 +422,7 @@ impl App {
416422
self.key_list_state.select(Some(idx));
417423
self.translation_scroll = 0;
418424
self.status_message = None;
425+
self.redraw_token = !self.redraw_token;
419426
return;
420427
}
421428
}
@@ -435,6 +442,7 @@ impl App {
435442
self.key_list_state.select(Some(idx));
436443
self.translation_scroll = 0;
437444
self.status_message = None;
445+
self.redraw_token = !self.redraw_token;
438446
return;
439447
}
440448
}
@@ -468,6 +476,7 @@ impl App {
468476
self.key_list_state.select(Some(next));
469477
self.translation_scroll = 0;
470478
self.status_message = None;
479+
self.redraw_token = !self.redraw_token;
471480
}
472481

473482
pub fn key_prev(&mut self) {
@@ -482,6 +491,7 @@ impl App {
482491
self.key_list_state.select(Some(prev));
483492
self.translation_scroll = 0;
484493
self.status_message = None;
494+
self.redraw_token = !self.redraw_token;
485495
}
486496

487497
pub fn key_next_page(&mut self, page_size: usize) {
@@ -497,6 +507,7 @@ impl App {
497507
self.key_list_state.select(Some(next));
498508
self.translation_scroll = 0;
499509
self.status_message = None;
510+
self.redraw_token = !self.redraw_token;
500511
}
501512

502513
pub fn key_prev_page(&mut self, page_size: usize) {
@@ -511,13 +522,15 @@ impl App {
511522
self.key_list_state.select(Some(prev));
512523
self.translation_scroll = 0;
513524
self.status_message = None;
525+
self.redraw_token = !self.redraw_token;
514526
}
515527

516528
pub fn key_jump_top(&mut self) {
517529
if !self.filtered_keys.is_empty() {
518530
self.key_list_state.select(Some(0));
519531
self.translation_scroll = 0;
520532
self.status_message = None;
533+
self.redraw_token = !self.redraw_token;
521534
}
522535
}
523536

@@ -527,6 +540,7 @@ impl App {
527540
.select(Some(self.filtered_keys.len() - 1));
528541
self.translation_scroll = 0;
529542
self.status_message = None;
543+
self.redraw_token = !self.redraw_token;
530544
}
531545
}
532546

langcodec-cli/src/editor/mod.rs

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,6 @@ pub fn run_browse_command(opts: BrowseOptions) -> Result<(), String> {
8787

8888
let mut term = TermGuard::new()?;
8989

90-
let mut prev_key_idx = app.key_list_state.selected();
91-
9290
loop {
9391
term.terminal
9492
.draw(|frame| ui::render(frame, &mut app))
@@ -101,62 +99,8 @@ pub fn run_browse_command(opts: BrowseOptions) -> Result<(), String> {
10199
if let HandlerResult::Quit = handle_event(&mut app, event) {
102100
break;
103101
}
104-
// Complex-script glyphs (Arabic, Bengali, Hindi, …) can render wider
105-
// than unicode-width reports, leaving ghost cells that ratatui's diff
106-
// renderer won't overwrite. A full terminal clear fixes this, but it
107-
// causes a visible flash on every navigation. Only clear when the
108-
// old or new key actually contains complex-script text.
109-
let cur_key_idx = app.key_list_state.selected();
110-
if cur_key_idx != prev_key_idx {
111-
let old_key = prev_key_idx.and_then(|i| app.filtered_keys.get(i).cloned());
112-
let new_key = cur_key_idx.and_then(|i| app.filtered_keys.get(i).cloned());
113-
prev_key_idx = cur_key_idx;
114-
115-
let needs_clear = [old_key, new_key].iter().any(|k| {
116-
k.as_ref().map(|key| {
117-
app.languages.iter().any(|lang| {
118-
app.get_translation(key, lang)
119-
.map(|v| has_complex_scripts(&v))
120-
.unwrap_or(false)
121-
})
122-
}).unwrap_or(false)
123-
});
124-
125-
if needs_clear {
126-
term.terminal.clear().ok();
127-
}
128-
}
129102
}
130103
}
131104

132105
Ok(())
133106
}
134-
135-
/// Returns true if the string contains characters from scripts whose glyphs
136-
/// commonly render wider than unicode-width predicts (Arabic, Devanagari,
137-
/// Bengali, Tamil, Thai, Gujarati, Gurmukhi, Kannada, Malayalam, Telugu, …).
138-
/// Used to decide whether a full terminal clear is needed to avoid artifacts.
139-
fn has_complex_scripts(s: &str) -> bool {
140-
s.chars().any(|c| {
141-
let cp = c as u32;
142-
matches!(cp,
143-
0x0600..=0x06FF // Arabic
144-
| 0x0750..=0x077F // Arabic Supplement
145-
| 0xFB50..=0xFDFF // Arabic Pres. Forms-A
146-
| 0xFE70..=0xFEFF // Arabic Pres. Forms-B
147-
| 0x0900..=0x097F // Devanagari (Hindi, Marathi, …)
148-
| 0x0980..=0x09FF // Bengali / Assamese
149-
| 0x0A00..=0x0A7F // Gurmukhi (Punjabi)
150-
| 0x0A80..=0x0AFF // Gujarati
151-
| 0x0B00..=0x0B7F // Odia
152-
| 0x0B80..=0x0BFF // Tamil
153-
| 0x0C00..=0x0C7F // Telugu
154-
| 0x0C80..=0x0CFF // Kannada
155-
| 0x0D00..=0x0D7F // Malayalam
156-
| 0x0E00..=0x0E7F // Thai
157-
| 0x0E80..=0x0EFF // Lao
158-
| 0x0F00..=0x0FFF // Tibetan
159-
| 0x1000..=0x109F // Myanmar
160-
)
161-
})
162-
}

langcodec-cli/src/editor/ui.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,22 @@ fn render_key_list(frame: &mut Frame, app: &mut App, area: Rect) {
115115
// ── Translations panel ────────────────────────────────────────────────────────
116116

117117
fn render_translations(frame: &mut Frame, app: &App, area: Rect) {
118-
// Clear the whole area first to prevent stale cells from complex-script
119-
// characters whose terminal display width differs from unicode-width reports.
118+
// Clear the whole area first to remove stale characters from previous render.
120119
frame.render_widget(Clear, area);
121120

121+
// Force ratatui's diff renderer to re-send every cell in this panel when
122+
// the key changes. We toggle between two invisible styles (BOLD on/off for
123+
// spaces) so ratatui sees each cell as "changed" and overwrites any ghost
124+
// glyphs left by complex-script characters (Arabic, Bengali, Hindi, …) whose
125+
// terminal display width exceeds what unicode-width reports. Without this,
126+
// ratatui's diff skips cells it thinks are unchanged and ghost glyphs persist.
127+
let ghost_fix_style = if app.redraw_token {
128+
Style::default().add_modifier(Modifier::BOLD)
129+
} else {
130+
Style::default()
131+
};
132+
frame.buffer_mut().set_style(area, ghost_fix_style);
133+
122134
let in_edit = matches!(app.input_mode, InputMode::Edit);
123135

124136
// Translations panel is "active" (Cyan border) during edit mode

0 commit comments

Comments
 (0)