1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#![allow(clippy::uninlined_format_args)] // Clarity over style in test code
#[cfg(test)]
mod tests {
use opentui::buffer::{BoxOptions, BoxStyle, OptimizedBuffer};
use opentui::cell::CellContent;
use opentui::grapheme_pool::GraphemePool;
use opentui_rust as opentui;
#[test]
fn test_box_title_emoji_placeholder_problem() {
let mut buffer = OptimizedBuffer::new(20, 5);
let options = BoxOptions {
title: Some("Title 👨👩👧".to_string()),
..BoxOptions::new(BoxStyle::default())
};
// Use standard draw_box_with_options (no pool)
buffer.draw_box_with_options(0, 0, 15, 5, options);
// Find the emoji cell. Title starts at x=2. "Title " is 6 chars.
// T(2), i(3), t(4), l(5), e(6), (7), emoji(8)
let cell = buffer.get(8, 0).unwrap();
if let CellContent::Grapheme(id) = cell.content {
// Expect placeholder ID (0) because we didn't use a pool
assert_eq!(
id.pool_id(),
0,
"Expected placeholder ID 0 from non-pool drawing"
);
assert_eq!(id.width(), 2, "Expected width 2");
} else {
unreachable!("Expected grapheme content, got {:?}", cell.content);
}
}
#[test]
fn test_overwrite_no_longer_leaks() {
let mut buffer = OptimizedBuffer::new(10, 10);
let mut pool = GraphemePool::new();
// 1. Alloc grapheme
let id = pool.alloc("👨👩👧");
let initial_refcount = pool.refcount(id);
assert_eq!(initial_refcount, 1);
// 2. Draw it to buffer with pool
// set_with_pool DECREMENTS the OLD content. It does NOT increment the NEW content.
// The caller is responsible for ensuring the new content has a valid refcount.
// When we called pool.alloc(), we got refcount 1. So we are "giving" that refcount to the buffer.
let cell = opentui::cell::Cell {
content: CellContent::Grapheme(id),
fg: opentui::color::Rgba::WHITE,
bg: opentui::color::Rgba::TRANSPARENT,
attributes: opentui::style::TextAttributes::empty(),
};
buffer.set_with_pool(&mut pool, 0, 0, cell);
// Refcount should still be 1 (held by buffer now)
assert_eq!(pool.refcount(id), 1);
// 3. Overwrite with set() (no pool)
// The buffer now tracks the orphaned grapheme ID
let clear_cell = opentui::cell::Cell::clear(opentui::color::Rgba::BLACK);
buffer.set(0, 0, clear_cell);
// Buffer now has clear_cell, but orphaned grapheme is tracked internally.
// Refcount is still 1 because we haven't called a pool-aware method yet.
assert_eq!(
pool.refcount(id),
1,
"Refcount should still be 1 before drain"
);
// 4. Clear buffer with pool - this drains orphaned graphemes
buffer.clear_with_pool(&mut pool, opentui::color::Rgba::BLACK);
// The orphaned grapheme has been released!
assert_eq!(
pool.refcount(id),
0,
"Refcount should be 0 after clear_with_pool drains orphans"
);
}
/// Regression test for cursor drift when continuation cells are skipped.
///
/// BUG: In `present_diff()`, when iterating over dirty regions and skipping
/// continuation cells, the cursor position would not advance properly,
/// causing subsequent characters to be written at wrong positions.
///
/// SYMPTOM: Text became garbled as log lines scrolled ("`HashMap`" -> "skseshap").
#[test]
fn test_continuation_cell_cursor_positioning() {
use opentui::buffer::OptimizedBuffer;
use opentui::cell::{Cell, GraphemeId};
use opentui::color::Rgba;
use opentui::renderer::BufferDiff;
use opentui::style::{Style, TextAttributes};
// Create old and new buffers
let mut old_buf = OptimizedBuffer::new(10, 1);
let mut new_buf = OptimizedBuffer::new(10, 1);
// Manually set up old buffer with a wide character (simulating emoji)
// GraphemeId with width 2 at position 0, continuation at position 1
let wide_id = GraphemeId::new(1, 2); // pool_id=1, width=2
old_buf.set(
0,
0,
Cell {
content: CellContent::Grapheme(wide_id),
fg: Rgba::WHITE,
bg: Rgba::BLACK,
attributes: TextAttributes::empty(),
},
);
old_buf.set(1, 0, Cell::continuation(Rgba::BLACK));
old_buf.set(2, 0, Cell::new('B', Style::NONE));
old_buf.set(3, 0, Cell::new('C', Style::NONE));
old_buf.set(4, 0, Cell::new('D', Style::NONE));
// Verify old buffer has: wide(0), continuation(1), B(2), C(3), D(4)
assert!(
old_buf.get(1, 0).unwrap().is_continuation(),
"Position 1 should be continuation"
);
assert!(
matches!(old_buf.get(2, 0).unwrap().content, CellContent::Char('B')),
"Position 2 should be 'B'"
);
// Set up new buffer - same wide char, but different letters after
new_buf.set(
0,
0,
Cell {
content: CellContent::Grapheme(wide_id),
fg: Rgba::WHITE,
bg: Rgba::BLACK,
attributes: TextAttributes::empty(),
},
);
new_buf.set(1, 0, Cell::continuation(Rgba::BLACK));
new_buf.set(2, 0, Cell::new('X', Style::NONE));
new_buf.set(3, 0, Cell::new('Y', Style::NONE));
new_buf.set(4, 0, Cell::new('Z', Style::NONE));
// Compute diff - should detect changes at positions 2, 3, 4
let diff = BufferDiff::compute(&old_buf, &new_buf);
// The diff should contain the changed cells
assert!(
!diff.is_empty(),
"Diff should detect changes at positions 2, 3, 4"
);
// Verify that the changed cells are at positions 2, 3, 4
// (The grapheme and continuation at 0,1 are the same, so shouldn't be in diff)
assert_eq!(
diff.changed_cells.len(),
3,
"Should have exactly 3 changed cells (positions 2, 3, 4)"
);
for &(x, _y) in &diff.changed_cells {
assert!(
(2..=4).contains(&x),
"Changed cell at x={} should be in range [2, 4]",
x
);
}
// The key insight: when rendering this diff, if we have a dirty region
// that includes positions 2, 3, 4 and the renderer incorrectly assumes
// continuous cursor advancement, the output would be wrong.
//
// The fix ensures we move_cursor to exact position before each cell write:
// writer.move_cursor(y, x); // Exact position before each write
// Instead of:
// writer.move_cursor(region.y, region.x); // Only at region start
//
// This test verifies the diff computation correctly identifies
// which cells changed without including the unchanged wide char.
}
}