• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / term-wm / 20885255367

10 Jan 2026 10:20PM UTC coverage: 57.056% (+10.0%) from 47.071%
20885255367

Pull #20

github

web-flow
Merge 123a43984 into bfecf0f75
Pull Request #20: Initial clipboard and offscreen buffer support

2045 of 3183 new or added lines in 26 files covered. (64.25%)

76 existing lines in 15 files now uncovered.

6788 of 11897 relevant lines covered (57.06%)

9.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.13
/src/ui.rs
1
//! UiFrame: a thin wrapper around `ratatui::Frame` that clamps drawing to the
2
//! visible area and centralizes clipping logic.
3
//!
4
//! Why this exists
5
//! - Components and widgets sometimes compute rectangles that drift partially or
6
//!   fully outside the terminal buffer. Writing out-of-bounds into the underlying
7
//!   `Buffer` can panic or corrupt rendering. `UiFrame` prevents that by
8
//!   clipping all draw calls to the visible area.
9
//!
10
//! Benefits
11
//! - Safety: components can call the familiar `render_widget` /
12
//!   `render_stateful_widget` helpers without needing to guard every draw with
13
//!   manual bounds checks.
14
//! - Simplicity: keeps widget code concise and focused on layout rather than
15
//!   buffer-safety details.
16
//! - Clear handling: by routing `Clear` widget rendering through `UiFrame`, we
17
//!   can safely clear regions without exposing a brittle `clear_rect` helper.
18
//!
19
//! Usage
20
//! - In paint closures, construct a `UiFrame` from a `ratatui::Frame` via
21
//!   `UiFrame::new(&mut frame)`. Use `frame.render_widget(...)` and
22
//!   `frame.render_stateful_widget(...)` as before. To clear an area, render the
23
//!   `Clear` widget through the `UiFrame`.
24
use crate::window::FloatRect;
25
use ratatui::Frame;
26
use ratatui::buffer::Buffer;
27
use ratatui::layout::Rect;
28
use ratatui::style::Style;
29
use ratatui::widgets::{StatefulWidget, Widget};
30

31
/// Wrapper around `ratatui::Frame` that clamps drawing to the visible area.
32
///
33
/// Components render through this type so they can keep calling familiar
34
/// `render_widget` / `render_stateful_widget` helpers while automatically
35
/// clipping any rectangles that drift outside the buffer.
36
pub struct UiFrame<'a> {
37
    area: Rect,
38
    buffer: &'a mut Buffer,
39
}
40

41
impl<'a> UiFrame<'a> {
42
    pub fn new(frame: &'a mut Frame<'_>) -> Self {
×
43
        let area = frame.area();
×
44
        let buffer = frame.buffer_mut();
×
45
        Self { area, buffer }
×
46
    }
×
47

48
    /// Construct a `UiFrame` directly from an area and buffer.
49
    ///
50
    /// This powers offscreen rendering paths where components should draw into
51
    /// their logical window size before being composited onto the visible
52
    /// terminal buffer.
53
    pub(crate) fn from_parts(area: Rect, buffer: &'a mut Buffer) -> Self {
18✔
54
        Self { area, buffer }
18✔
55
    }
18✔
56

57
    pub fn area(&self) -> Rect {
×
58
        self.area
×
59
    }
×
60

61
    pub fn buffer_mut(&mut self) -> &mut Buffer {
8✔
62
        self.buffer
8✔
63
    }
8✔
64

65
    fn clip_rect(&self, rect: Rect) -> Option<Rect> {
157✔
66
        let clipped = rect.intersection(self.area);
157✔
67
        if clipped.width == 0 || clipped.height == 0 {
157✔
68
            None
×
69
        } else {
70
            Some(clipped)
157✔
71
        }
72
    }
157✔
73

74
    pub fn render_widget<W>(&mut self, widget: W, area: Rect)
148✔
75
    where
148✔
76
        W: Widget,
148✔
77
    {
78
        if let Some(clipped) = self.clip_rect(area) {
148✔
79
            widget.render(clipped, self.buffer);
148✔
80
        }
148✔
81
    }
148✔
82

83
    pub fn render_stateful_widget<W>(&mut self, widget: W, area: Rect, state: &mut W::State)
9✔
84
    where
9✔
85
        W: StatefulWidget,
9✔
86
    {
87
        if let Some(clipped) = self.clip_rect(area) {
9✔
88
            widget.render(clipped, self.buffer, state);
9✔
89
        }
9✔
90
    }
9✔
91

92
    pub fn blit_from(&mut self, src: &Buffer, src_area: Rect) {
2✔
93
        let overlap = src_area.intersection(self.area);
2✔
94
        if overlap.width == 0 || overlap.height == 0 {
2✔
NEW
95
            return;
×
96
        }
2✔
97
        for y in overlap.y..overlap.y.saturating_add(overlap.height) {
3✔
98
            for x in overlap.x..overlap.x.saturating_add(overlap.width) {
6✔
99
                if let (Some(src_cell), Some(dst_cell)) =
6✔
100
                    (src.cell((x, y)), self.buffer.cell_mut((x, y)))
6✔
101
                {
6✔
102
                    *dst_cell = src_cell.clone();
6✔
103
                }
6✔
104
            }
105
        }
106
    }
2✔
107

108
    pub fn blit_from_signed(&mut self, src: &Buffer, dest: FloatRect) {
2✔
109
        let frame_x0 = self.area.x as i32;
2✔
110
        let frame_y0 = self.area.y as i32;
2✔
111
        let frame_x1 = frame_x0 + self.area.width as i32;
2✔
112
        let frame_y1 = frame_y0 + self.area.height as i32;
2✔
113
        for sy in 0..dest.height as i32 {
4✔
114
            let dy = dest.y + sy;
4✔
115
            if dy < frame_y0 || dy >= frame_y1 {
4✔
116
                continue;
2✔
117
            }
2✔
118
            for sx in 0..dest.width as i32 {
6✔
119
                let dx = dest.x + sx;
6✔
120
                if dx < frame_x0 || dx >= frame_x1 {
6✔
121
                    continue;
2✔
122
                }
4✔
123
                if let (Some(src_cell), Some(dst_cell)) = (
4✔
124
                    src.cell((sx as u16, sy as u16)),
4✔
125
                    self.buffer.cell_mut((dx as u16, dy as u16)),
4✔
126
                ) {
4✔
127
                    *dst_cell = src_cell.clone();
4✔
128
                }
4✔
129
            }
130
        }
131
    }
2✔
132
}
133

134
pub(crate) fn safe_set_string(
7✔
135
    buffer: &mut Buffer,
7✔
136
    bounds: Rect,
7✔
137
    x: u16,
7✔
138
    y: u16,
7✔
139
    text: &str,
7✔
140
    style: Style,
7✔
141
) {
7✔
142
    if bounds.width == 0 || bounds.height == 0 {
7✔
143
        return;
×
144
    }
7✔
145
    let max_x = bounds.x.saturating_add(bounds.width);
7✔
146
    let max_y = bounds.y.saturating_add(bounds.height);
7✔
147
    if x < bounds.x || x >= max_x || y < bounds.y || y >= max_y {
7✔
148
        return;
1✔
149
    }
6✔
150
    let available = max_x.saturating_sub(x);
6✔
151
    if available == 0 {
6✔
152
        return;
×
153
    }
6✔
154
    let text = truncate_to_width(text, available as usize);
6✔
155
    buffer.set_string(x, y, text, style);
6✔
156
}
7✔
157

158
pub(crate) fn truncate_to_width(value: &str, width: usize) -> String {
12✔
159
    if value.chars().count() <= width {
12✔
160
        return value.to_string();
8✔
161
    }
4✔
162
    value.chars().take(width).collect()
4✔
163
}
12✔
164

165
#[cfg(test)]
166
mod tests {
167
    use super::*;
168
    use ratatui::buffer::Buffer;
169
    use ratatui::layout::Rect;
170
    use ratatui::style::Style;
171

172
    #[test]
173
    fn blit_from_signed_clips_negative_offsets() {
1✔
174
        let frame_area = Rect {
1✔
175
            x: 0,
1✔
176
            y: 0,
1✔
177
            width: 4,
1✔
178
            height: 2,
1✔
179
        };
1✔
180
        let mut dest = Buffer::empty(frame_area);
1✔
181
        let mut frame = UiFrame::from_parts(frame_area, &mut dest);
1✔
182
        let src_area = Rect {
1✔
183
            x: 0,
1✔
184
            y: 0,
1✔
185
            width: 3,
1✔
186
            height: 2,
1✔
187
        };
1✔
188
        let mut src = Buffer::empty(src_area);
1✔
189
        for y in 0..src_area.height {
2✔
190
            for x in 0..src_area.width {
6✔
191
                if let Some(cell) = src.cell_mut((x, y)) {
6✔
192
                    cell.set_symbol("#");
6✔
193
                }
6✔
194
            }
195
        }
196
        frame.blit_from_signed(
1✔
197
            &src,
1✔
198
            FloatRect {
1✔
199
                x: -1,
1✔
200
                y: 0,
1✔
201
                width: 3,
1✔
202
                height: 2,
1✔
203
            },
1✔
204
        );
205
        let buffer = frame.buffer;
1✔
206
        assert_eq!(buffer.cell((0, 0)).unwrap().symbol(), "#");
1✔
207
        assert_eq!(buffer.cell((1, 0)).unwrap().symbol(), "#");
1✔
208
        assert_eq!(buffer.cell((2, 0)).unwrap().symbol(), " ");
1✔
209
    }
1✔
210

211
    #[test]
212
    fn blit_from_signed_ignores_non_overlapping() {
1✔
213
        let frame_area = Rect {
1✔
214
            x: 0,
1✔
215
            y: 0,
1✔
216
            width: 3,
1✔
217
            height: 3,
1✔
218
        };
1✔
219
        let mut dest = Buffer::empty(frame_area);
1✔
220
        let mut frame = UiFrame::from_parts(frame_area, &mut dest);
1✔
221
        let src_area = Rect {
1✔
222
            x: 0,
1✔
223
            y: 0,
1✔
224
            width: 2,
1✔
225
            height: 2,
1✔
226
        };
1✔
227
        let mut src = Buffer::empty(src_area);
1✔
228
        for y in 0..src_area.height {
2✔
229
            for x in 0..src_area.width {
4✔
230
                if let Some(cell) = src.cell_mut((x, y)) {
4✔
231
                    cell.set_symbol("#");
4✔
232
                }
4✔
233
            }
234
        }
235
        frame.blit_from_signed(
1✔
236
            &src,
1✔
237
            FloatRect {
1✔
238
                x: -5,
1✔
239
                y: -5,
1✔
240
                width: 2,
1✔
241
                height: 2,
1✔
242
            },
1✔
243
        );
244
        let buffer = frame.buffer;
1✔
245
        for y in 0..frame_area.height {
3✔
246
            for x in 0..frame_area.width {
9✔
247
                assert_eq!(buffer.cell((x, y)).unwrap().symbol(), " ");
9✔
248
            }
249
        }
250
    }
1✔
251

252
    #[test]
253
    fn truncate_to_width_short_and_long() {
1✔
254
        assert_eq!(truncate_to_width("abc", 5), "abc");
1✔
255
        assert_eq!(truncate_to_width("abcdef", 3), "abc");
1✔
256
    }
1✔
257

258
    #[test]
259
    fn safe_set_string_writes_within_bounds() {
1✔
260
        let bounds = ratatui::layout::Rect {
1✔
261
            x: 0,
1✔
262
            y: 0,
1✔
263
            width: 10,
1✔
264
            height: 2,
1✔
265
        };
1✔
266
        let mut buf = Buffer::empty(bounds);
1✔
267
        safe_set_string(&mut buf, bounds, 1, 0, "hello", Style::default());
1✔
268
        let cell = buf.cell_mut((1, 0)).expect("cell present");
1✔
269
        let first = cell.symbol().chars().next().unwrap();
1✔
270
        assert_eq!(first, 'h');
1✔
271

272
        // outside bounds should be ignored (no panic)
273
        safe_set_string(&mut buf, bounds, 100, 0, "x", Style::default());
1✔
274
    }
1✔
275

276
    #[test]
277
    fn render_widget_clips_to_frame_area() {
1✔
278
        use ratatui::layout::Rect;
279

280
        let area = Rect {
1✔
281
            x: 0,
1✔
282
            y: 0,
1✔
283
            width: 5,
1✔
284
            height: 3,
1✔
285
        };
1✔
286
        let mut buf = Buffer::empty(area);
1✔
287
        let mut ui = UiFrame::from_parts(area, &mut buf);
1✔
288

289
        struct FillWidget;
290
        impl Widget for FillWidget {
291
            fn render(self, area: Rect, buf: &mut Buffer) {
1✔
292
                for y in area.y..area.y.saturating_add(area.height) {
2✔
293
                    for x in area.x..area.x.saturating_add(area.width) {
4✔
294
                        if let Some(cell) = buf.cell_mut((x, y)) {
4✔
295
                            cell.set_symbol("A");
4✔
296
                        }
4✔
297
                    }
298
                }
299
            }
1✔
300
        }
301

302
        // Request an area that partially lies outside the right edge.
303
        ui.render_widget(
1✔
304
            FillWidget,
1✔
305
            Rect {
1✔
306
                x: 3,
1✔
307
                y: 1,
1✔
308
                width: 5,
1✔
309
                height: 2,
1✔
310
            },
1✔
311
        );
312

313
        // Inside clipped region
314
        let inside = buf.cell_mut((3, 1)).expect("cell present");
1✔
315
        assert!(inside.symbol().starts_with('A'));
1✔
316

317
        // Outside clipped region (left of the filled area)
318
        let outside = buf.cell_mut((2, 1)).expect("cell present");
1✔
319
        assert!(!outside.symbol().starts_with('A'));
1✔
320
    }
1✔
321

322
    #[test]
323
    fn render_stateful_widget_clips_to_frame_area() {
1✔
324
        use ratatui::layout::Rect;
325

326
        let area = Rect {
1✔
327
            x: 0,
1✔
328
            y: 0,
1✔
329
            width: 6,
1✔
330
            height: 4,
1✔
331
        };
1✔
332
        let mut buf = Buffer::empty(area);
1✔
333
        let mut ui = UiFrame::from_parts(area, &mut buf);
1✔
334

335
        struct FillStateful;
336
        impl StatefulWidget for FillStateful {
337
            type State = usize;
338
            fn render(self, area: Rect, buf: &mut Buffer, _state: &mut Self::State) {
1✔
339
                for y in area.y..area.y.saturating_add(area.height) {
2✔
340
                    for x in area.x..area.x.saturating_add(area.width) {
8✔
341
                        if let Some(cell) = buf.cell_mut((x, y)) {
8✔
342
                            cell.set_symbol("S");
8✔
343
                        }
8✔
344
                    }
345
                }
346
            }
1✔
347
        }
348

349
        // Request an area that exceeds bottom edge.
350
        let mut state = 0usize;
1✔
351
        ui.render_stateful_widget(
1✔
352
            FillStateful,
1✔
353
            Rect {
1✔
354
                x: 1,
1✔
355
                y: 2,
1✔
356
                width: 4,
1✔
357
                height: 4,
1✔
358
            },
1✔
359
            &mut state,
1✔
360
        );
361

362
        // Inside clipped region
363
        let inside = buf.cell_mut((1, 2)).expect("cell present");
1✔
364
        assert!(inside.symbol().starts_with('S'));
1✔
365

366
        // Outside clipped region (below buffer)
367
        // Coordinates (1, 6) are outside; ensure we don't panic by checking a nearby in-bounds cell
368
        let near = buf.cell_mut((1, 3)).expect("cell present");
1✔
369
        assert!(near.symbol().starts_with('S'));
1✔
370
    }
1✔
371

372
    #[test]
373
    fn blit_from_copies_overlapping_region() {
1✔
374
        use ratatui::layout::Rect;
375

376
        let dest_area = Rect {
1✔
377
            x: 0,
1✔
378
            y: 0,
1✔
379
            width: 5,
1✔
380
            height: 3,
1✔
381
        };
1✔
382
        let mut dest = Buffer::empty(dest_area);
1✔
383
        for y in dest_area.y..dest_area.y.saturating_add(dest_area.height) {
3✔
384
            for x in dest_area.x..dest_area.x.saturating_add(dest_area.width) {
15✔
385
                if let Some(cell) = dest.cell_mut((x, y)) {
15✔
386
                    cell.set_symbol(".");
15✔
387
                }
15✔
388
            }
389
        }
390
        let mut frame = UiFrame::from_parts(dest_area, &mut dest);
1✔
391

392
        let src_area = Rect {
1✔
393
            x: 3,
1✔
394
            y: 1,
1✔
395
            width: 4,
1✔
396
            height: 3,
1✔
397
        };
1✔
398
        let mut src = Buffer::empty(src_area);
1✔
399
        for y in src_area.y..src_area.y.saturating_add(src_area.height) {
3✔
400
            for x in src_area.x..src_area.x.saturating_add(src_area.width) {
12✔
401
                if let Some(cell) = src.cell_mut((x, y)) {
12✔
402
                    cell.set_symbol("Z");
12✔
403
                }
12✔
404
            }
405
        }
406

407
        frame.blit_from(&src, src_area);
1✔
408

409
        let buffer = frame.buffer_mut();
1✔
410
        assert_eq!(buffer.cell((3, 1)).unwrap().symbol(), "Z");
1✔
411
        assert_eq!(buffer.cell((4, 2)).unwrap().symbol(), "Z");
1✔
412
        assert_eq!(buffer.cell((2, 1)).unwrap().symbol(), ".");
1✔
413
        assert_eq!(buffer.cell((4, 0)).unwrap().symbol(), ".");
1✔
414
    }
1✔
415

416
    #[test]
417
    fn blit_from_respects_non_zero_origins() {
1✔
418
        use ratatui::layout::Rect;
419

420
        let dest_area = Rect {
1✔
421
            x: 5,
1✔
422
            y: 5,
1✔
423
            width: 4,
1✔
424
            height: 2,
1✔
425
        };
1✔
426
        let mut dest = Buffer::empty(dest_area);
1✔
427
        for y in dest_area.y..dest_area.y.saturating_add(dest_area.height) {
2✔
428
            for x in dest_area.x..dest_area.x.saturating_add(dest_area.width) {
8✔
429
                if let Some(cell) = dest.cell_mut((x, y)) {
8✔
430
                    cell.set_symbol(".");
8✔
431
                }
8✔
432
            }
433
        }
434
        let mut frame = UiFrame::from_parts(dest_area, &mut dest);
1✔
435

436
        let src_area = Rect {
1✔
437
            x: 6,
1✔
438
            y: 6,
1✔
439
            width: 2,
1✔
440
            height: 1,
1✔
441
        };
1✔
442
        let mut src = Buffer::empty(src_area);
1✔
443
        for y in src_area.y..src_area.y.saturating_add(src_area.height) {
1✔
444
            for x in src_area.x..src_area.x.saturating_add(src_area.width) {
2✔
445
                if let Some(cell) = src.cell_mut((x, y)) {
2✔
446
                    cell.set_symbol("Q");
2✔
447
                }
2✔
448
            }
449
        }
450

451
        frame.blit_from(&src, src_area);
1✔
452

453
        let buffer = frame.buffer_mut();
1✔
454
        assert_eq!(buffer.cell((6, 6)).unwrap().symbol(), "Q");
1✔
455
        assert_eq!(buffer.cell((7, 6)).unwrap().symbol(), "Q");
1✔
456
        assert_eq!(buffer.cell((5, 5)).unwrap().symbol(), ".");
1✔
457
        assert_eq!(buffer.cell((8, 6)).unwrap().symbol(), ".");
1✔
458
    }
1✔
459
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc