slt/context/container.rs
1use super::*;
2
3/// Options for [`Context::modal_with`].
4///
5/// Controls focus behavior when a modal overlay is active.
6///
7/// # Example
8///
9/// ```no_run
10/// # let mut show = true;
11/// # slt::run(|ui: &mut slt::Context| {
12/// if show {
13/// ui.modal_with(slt::context::ModalOptions { tab_trap: true }, |ui| {
14/// ui.text("Are you sure?");
15/// if ui.button("OK").clicked { show = false; }
16/// });
17/// }
18/// # });
19/// ```
20#[derive(Debug, Clone, Copy)]
21pub struct ModalOptions {
22 /// When `true`, Tab/Shift+Tab navigation cannot leave the modal's focus
23 /// range, even if [`Context::set_focus_index`] or a mouse click moved
24 /// focus outside.
25 ///
26 /// Default: `true` — aligned with WCAG 2.1 SC 2.4.3 (Focus Order),
27 /// which recommends trapping focus inside modal dialogs.
28 ///
29 /// Set to `false` to preserve the legacy behavior where focus could
30 /// escape via programmatic means.
31 pub tab_trap: bool,
32}
33
34impl Default for ModalOptions {
35 fn default() -> Self {
36 Self { tab_trap: true }
37 }
38}
39
40/// Fluent builder for configuring containers before calling `.col()` or `.row()`.
41///
42/// Obtain one via [`Context::container`] or [`Context::bordered`]. Chain the
43/// configuration methods you need, then finalize with `.col(|ui| { ... })` or
44/// `.row(|ui| { ... })`.
45///
46/// # Example
47///
48/// ```no_run
49/// # slt::run(|ui: &mut slt::Context| {
50/// use slt::{Border, Color};
51/// ui.container()
52/// .border(Border::Rounded)
53/// .p(1)
54/// .grow(1)
55/// .col(|ui| {
56/// ui.text("inside a bordered, padded, growing column");
57/// });
58/// # });
59/// ```
60#[must_use = "ContainerBuilder does nothing until .col(), .row(), .line(), or .draw() is called"]
61pub struct ContainerBuilder<'a> {
62 pub(crate) ctx: &'a mut Context,
63 /// Resolved main-axis gap, in cells. Signed (#222): negative means
64 /// adjacent children overlap, set via [`ContainerBuilder::gap_overlap`].
65 /// The public [`ContainerBuilder::gap`] setter takes `u32` and is
66 /// source-compatible; only `gap_overlap` can store a negative value.
67 pub(crate) gap: i32,
68 pub(crate) row_gap: Option<u32>,
69 pub(crate) col_gap: Option<u32>,
70 pub(crate) align: Align,
71 pub(crate) align_self_value: Option<Align>,
72 pub(crate) justify: Justify,
73 pub(crate) border: Option<Border>,
74 pub(crate) border_sides: BorderSides,
75 pub(crate) border_style: Style,
76 pub(crate) bg: Option<Color>,
77 pub(crate) text_color: Option<Color>,
78 pub(crate) dark_bg: Option<Color>,
79 pub(crate) dark_border_style: Option<Style>,
80 pub(crate) group_hover_bg: Option<Color>,
81 pub(crate) group_hover_border_style: Option<Style>,
82 pub(crate) group_name: Option<std::sync::Arc<str>>,
83 pub(crate) padding: Padding,
84 pub(crate) margin: Margin,
85 pub(crate) constraints: Constraints,
86 pub(crate) title: Option<(String, Style)>,
87 pub(crate) grow: u16,
88 /// Opt-in flex-shrink flag. Set via [`ContainerBuilder::shrink`].
89 ///
90 /// When `true`, this container participates in proportional shrinking
91 /// if its parent row/column overflows. Default `false` keeps the
92 /// historic overflow-by-design behavior. Closes #161.
93 pub(crate) shrink_flag: bool,
94 /// Opt-in container-level flex-wrap flag. Set via
95 /// [`ContainerBuilder::wrap`].
96 ///
97 /// When `true` on a row, children that overflow the available width flow
98 /// onto subsequent lines instead of overflowing past the right edge.
99 /// Default `false` keeps the historic single-line behavior. No-op on a
100 /// column. Closes #258.
101 pub(crate) wrap_flag: bool,
102 /// Optional flex-basis (initial main-axis size, in cells). Set via
103 /// [`ContainerBuilder::basis`]. `None` (default) falls back to the
104 /// child's min size, preserving current behavior. Closes #258.
105 pub(crate) basis: Option<u32>,
106 pub(crate) scroll_offset: Option<u32>,
107 /// Horizontal scroll offset for a scrollable row (#247). Set internally by
108 /// [`crate::Context::scrollable`] from `ScrollState::offset_x`; carried into
109 /// `BeginScrollableArgs` and applied by the tree builder only when the
110 /// finalizing direction is `Direction::Row`.
111 pub(crate) scroll_offset_x: Option<u32>,
112 pub(crate) theme_override: Option<Theme>,
113}
114
115/// Drawing context for the [`Context::canvas`] widget.
116///
117/// Provides pixel-level drawing on a braille character grid. Each terminal
118/// cell maps to a 2x4 dot matrix, so a canvas of `width` columns x `height`
119/// rows gives `width*2` x `height*4` pixel resolution.
120/// A colored pixel in the canvas grid.
121#[derive(Debug, Clone, Copy)]
122struct CanvasPixel {
123 bits: u32,
124 color: Color,
125}
126
127/// Text label placed on the canvas.
128#[derive(Debug, Clone)]
129struct CanvasLabel {
130 x: usize,
131 y: usize,
132 text: String,
133 color: Color,
134}
135
136/// A layer in the canvas, supporting z-ordering.
137#[derive(Debug, Clone)]
138struct CanvasLayer {
139 grid: Vec<Vec<CanvasPixel>>,
140 labels: Vec<CanvasLabel>,
141}
142
143/// Drawing context for the canvas widget.
144pub struct CanvasContext {
145 layers: Vec<CanvasLayer>,
146 cols: usize,
147 rows: usize,
148 px_w: usize,
149 px_h: usize,
150 current_color: Color,
151 /// Flat scratch buffer for `render()` pixel composition.
152 /// Capacity = `cols * rows`; flat index = `row * cols + col`.
153 scratch_pixels: Vec<CanvasPixel>,
154 /// Flat scratch buffer for `render()` label overlay.
155 /// Capacity = `cols * rows`; flat index = `row * cols + col`.
156 scratch_labels: Vec<Option<(char, Color)>>,
157}
158
159/// Integer square root for non-negative `i64` values, returning `isize`.
160///
161/// Uses an `f64` seed plus a bounded correction step to absorb rounding at
162/// integer boundaries. Avoids the unconditional `f64` round-trip used in
163/// hot canvas paths (e.g. `filled_circle`). Replace with `u64::isqrt()`
164/// once the project MSRV reaches 1.84.
165#[inline]
166fn isqrt_i64(n: i64) -> isize {
167 if n <= 0 {
168 return 0;
169 }
170 let mut x = (n as f64).sqrt() as i64;
171 // Single correction step handles f64 rounding at integer boundaries.
172 while x > 0 && x.saturating_mul(x) > n {
173 x -= 1;
174 }
175 while (x + 1).saturating_mul(x + 1) <= n {
176 x += 1;
177 }
178 x as isize
179}
180
181impl CanvasContext {
182 pub(crate) fn new(cols: usize, rows: usize) -> Self {
183 let cell_count = cols.saturating_mul(rows);
184 Self {
185 layers: vec![Self::new_layer(cols, rows)],
186 cols,
187 rows,
188 px_w: cols * 2,
189 px_h: rows * 4,
190 current_color: Color::Reset,
191 scratch_pixels: vec![
192 CanvasPixel {
193 bits: 0,
194 color: Color::Reset,
195 };
196 cell_count
197 ],
198 scratch_labels: vec![None; cell_count],
199 }
200 }
201
202 fn new_layer(cols: usize, rows: usize) -> CanvasLayer {
203 CanvasLayer {
204 grid: vec![
205 vec![
206 CanvasPixel {
207 bits: 0,
208 color: Color::Reset,
209 };
210 cols
211 ];
212 rows
213 ],
214 labels: Vec::new(),
215 }
216 }
217
218 fn current_layer_mut(&mut self) -> Option<&mut CanvasLayer> {
219 self.layers.last_mut()
220 }
221
222 fn dot_with_color(&mut self, x: usize, y: usize, color: Color) {
223 if x >= self.px_w || y >= self.px_h {
224 return;
225 }
226
227 let char_col = x / 2;
228 let char_row = y / 4;
229 let sub_col = x % 2;
230 let sub_row = y % 4;
231 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
232 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
233
234 let bit = if sub_col == 0 {
235 LEFT_BITS[sub_row]
236 } else {
237 RIGHT_BITS[sub_row]
238 };
239
240 if let Some(layer) = self.current_layer_mut() {
241 let cell = &mut layer.grid[char_row][char_col];
242 let new_bits = cell.bits | bit;
243 if new_bits != cell.bits {
244 cell.bits = new_bits;
245 cell.color = color;
246 }
247 }
248 }
249
250 fn dot_isize(&mut self, x: isize, y: isize) {
251 if x >= 0 && y >= 0 {
252 self.dot(x as usize, y as usize);
253 }
254 }
255
256 /// Get the pixel width of the canvas.
257 pub fn width(&self) -> usize {
258 self.px_w
259 }
260
261 /// Get the pixel height of the canvas.
262 pub fn height(&self) -> usize {
263 self.px_h
264 }
265
266 /// Set a single pixel at `(x, y)`.
267 pub fn dot(&mut self, x: usize, y: usize) {
268 self.dot_with_color(x, y, self.current_color);
269 }
270
271 /// Draw a line from `(x0, y0)` to `(x1, y1)` using Bresenham's algorithm.
272 pub fn line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
273 let (mut x, mut y) = (x0 as isize, y0 as isize);
274 let (x1, y1) = (x1 as isize, y1 as isize);
275 let dx = (x1 - x).abs();
276 let dy = -(y1 - y).abs();
277 let sx = if x < x1 { 1 } else { -1 };
278 let sy = if y < y1 { 1 } else { -1 };
279 let mut err = dx + dy;
280
281 loop {
282 self.dot_isize(x, y);
283 if x == x1 && y == y1 {
284 break;
285 }
286 let e2 = 2 * err;
287 if e2 >= dy {
288 err += dy;
289 x += sx;
290 }
291 if e2 <= dx {
292 err += dx;
293 y += sy;
294 }
295 }
296 }
297
298 /// Draw a rectangle outline from `(x, y)` with `w` width and `h` height.
299 pub fn rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
300 if w == 0 || h == 0 {
301 return;
302 }
303
304 self.line(x, y, x + w.saturating_sub(1), y);
305 self.line(
306 x + w.saturating_sub(1),
307 y,
308 x + w.saturating_sub(1),
309 y + h.saturating_sub(1),
310 );
311 self.line(
312 x + w.saturating_sub(1),
313 y + h.saturating_sub(1),
314 x,
315 y + h.saturating_sub(1),
316 );
317 self.line(x, y + h.saturating_sub(1), x, y);
318 }
319
320 /// Draw a circle outline centered at `(cx, cy)` with radius `r`.
321 pub fn circle(&mut self, cx: usize, cy: usize, r: usize) {
322 let mut x = r as isize;
323 let mut y: isize = 0;
324 let mut err: isize = 1 - x;
325 let (cx, cy) = (cx as isize, cy as isize);
326
327 while x >= y {
328 for &(dx, dy) in &[
329 (x, y),
330 (y, x),
331 (-x, y),
332 (-y, x),
333 (x, -y),
334 (y, -x),
335 (-x, -y),
336 (-y, -x),
337 ] {
338 let px = cx + dx;
339 let py = cy + dy;
340 self.dot_isize(px, py);
341 }
342
343 y += 1;
344 if err < 0 {
345 err += 2 * y + 1;
346 } else {
347 x -= 1;
348 err += 2 * (y - x) + 1;
349 }
350 }
351 }
352
353 /// Set the drawing color for subsequent shapes.
354 pub fn set_color(&mut self, color: Color) {
355 self.current_color = color;
356 }
357
358 /// Get the current drawing color.
359 pub fn color(&self) -> Color {
360 self.current_color
361 }
362
363 /// Draw a filled rectangle.
364 pub fn filled_rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
365 if w == 0 || h == 0 {
366 return;
367 }
368
369 let x_end = x.saturating_add(w).min(self.px_w);
370 let y_end = y.saturating_add(h).min(self.px_h);
371 if x >= x_end || y >= y_end {
372 return;
373 }
374
375 for yy in y..y_end {
376 self.line(x, yy, x_end.saturating_sub(1), yy);
377 }
378 }
379
380 /// Draw a filled circle.
381 pub fn filled_circle(&mut self, cx: usize, cy: usize, r: usize) {
382 let (cx, cy, r) = (cx as isize, cy as isize, r as isize);
383 for y in (cy - r)..=(cy + r) {
384 let dy = y - cy;
385 let span_sq = (r * r - dy * dy).max(0);
386 // TODO(msrv): switch to u64::isqrt() when MSRV >= 1.84
387 let dx = isqrt_i64(span_sq as i64);
388 for x in (cx - dx)..=(cx + dx) {
389 self.dot_isize(x, y);
390 }
391 }
392 }
393
394 /// Draw a triangle outline.
395 pub fn triangle(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, x2: usize, y2: usize) {
396 self.line(x0, y0, x1, y1);
397 self.line(x1, y1, x2, y2);
398 self.line(x2, y2, x0, y0);
399 }
400
401 /// Draw a filled triangle.
402 pub fn filled_triangle(
403 &mut self,
404 x0: usize,
405 y0: usize,
406 x1: usize,
407 y1: usize,
408 x2: usize,
409 y2: usize,
410 ) {
411 let vertices = [
412 (x0 as isize, y0 as isize),
413 (x1 as isize, y1 as isize),
414 (x2 as isize, y2 as isize),
415 ];
416 let min_y = vertices.iter().map(|(_, y)| *y).min().unwrap_or(0);
417 let max_y = vertices.iter().map(|(_, y)| *y).max().unwrap_or(-1);
418
419 for y in min_y..=max_y {
420 // A triangle has exactly 3 edges -> at most 3 intersections per
421 // scanline. A 4-element stack array avoids per-scanline heap
422 // allocations from the previous Vec<f64>.
423 let mut intersections = [0.0f64; 4];
424 let mut isect_count = 0usize;
425
426 for edge in [(0usize, 1usize), (1usize, 2usize), (2usize, 0usize)] {
427 let (x_a, y_a) = vertices[edge.0];
428 let (x_b, y_b) = vertices[edge.1];
429 if y_a == y_b {
430 continue;
431 }
432
433 let (x_start, y_start, x_end, y_end) = if y_a < y_b {
434 (x_a, y_a, x_b, y_b)
435 } else {
436 (x_b, y_b, x_a, y_a)
437 };
438
439 if y < y_start || y >= y_end {
440 continue;
441 }
442
443 let t = (y - y_start) as f64 / (y_end - y_start) as f64;
444 if isect_count < intersections.len() {
445 intersections[isect_count] = x_start as f64 + t * (x_end - x_start) as f64;
446 isect_count += 1;
447 }
448 }
449
450 intersections[..isect_count].sort_by(|a, b| a.total_cmp(b));
451 let mut i = 0usize;
452 while i + 1 < isect_count {
453 let x_start = intersections[i].ceil() as isize;
454 let x_end = intersections[i + 1].floor() as isize;
455 for x in x_start..=x_end {
456 self.dot_isize(x, y);
457 }
458 i += 2;
459 }
460 }
461
462 self.triangle(x0, y0, x1, y1, x2, y2);
463 }
464
465 /// Draw multiple points at once.
466 pub fn points(&mut self, pts: &[(usize, usize)]) {
467 for &(x, y) in pts {
468 self.dot(x, y);
469 }
470 }
471
472 /// Draw a polyline connecting the given points in order.
473 pub fn polyline(&mut self, pts: &[(usize, usize)]) {
474 for window in pts.windows(2) {
475 if let [(x0, y0), (x1, y1)] = window {
476 self.line(*x0, *y0, *x1, *y1);
477 }
478 }
479 }
480
481 /// Place a text label at pixel position `(x, y)`.
482 /// Text is rendered in regular characters overlaying the braille grid.
483 pub fn print(&mut self, x: usize, y: usize, text: &str) {
484 if text.is_empty() {
485 return;
486 }
487
488 let color = self.current_color;
489 if let Some(layer) = self.current_layer_mut() {
490 layer.labels.push(CanvasLabel {
491 x,
492 y,
493 text: text.to_string(),
494 color,
495 });
496 }
497 }
498
499 /// Start a new drawing layer. Shapes on later layers overlay earlier ones.
500 pub fn layer(&mut self) {
501 self.layers.push(Self::new_layer(self.cols, self.rows));
502 }
503
504 pub(crate) fn render(&mut self) -> Vec<Vec<(String, Color)>> {
505 let cell_count = self.cols.saturating_mul(self.rows);
506
507 // Reset reusable scratch buffers, growing them only if `cols`/`rows`
508 // changed since construction. `fill` keeps the existing allocation.
509 if self.scratch_pixels.len() < cell_count {
510 self.scratch_pixels.resize(
511 cell_count,
512 CanvasPixel {
513 bits: 0,
514 color: Color::Reset,
515 },
516 );
517 }
518 if self.scratch_labels.len() < cell_count {
519 self.scratch_labels.resize(cell_count, None);
520 }
521 for px in &mut self.scratch_pixels[..cell_count] {
522 *px = CanvasPixel {
523 bits: 0,
524 color: Color::Reset,
525 };
526 }
527 for slot in &mut self.scratch_labels[..cell_count] {
528 *slot = None;
529 }
530
531 let cols = self.cols;
532 let rows = self.rows;
533
534 for layer in &self.layers {
535 for (row, src_row) in layer.grid.iter().enumerate().take(rows) {
536 let row_offset = row * cols;
537 for (col, src) in src_row.iter().enumerate().take(cols) {
538 if src.bits == 0 {
539 continue;
540 }
541 let dst = &mut self.scratch_pixels[row_offset + col];
542 let merged = dst.bits | src.bits;
543 if merged != dst.bits {
544 dst.bits = merged;
545 dst.color = src.color;
546 }
547 }
548 }
549
550 for label in &layer.labels {
551 let row = label.y / 4;
552 if row >= rows {
553 continue;
554 }
555 let start_col = label.x / 2;
556 let row_offset = row * cols;
557 for (offset, ch) in label.text.chars().enumerate() {
558 let col = start_col + offset;
559 if col >= cols {
560 break;
561 }
562 self.scratch_labels[row_offset + col] = Some((ch, label.color));
563 }
564 }
565 }
566
567 let mut lines: Vec<Vec<(String, Color)>> = Vec::with_capacity(rows);
568 for row in 0..rows {
569 let row_offset = row * cols;
570 let mut segments: Vec<(String, Color)> = Vec::new();
571 let mut current_color: Option<Color> = None;
572 let mut current_text = String::new();
573
574 for col in 0..cols {
575 let idx = row_offset + col;
576 let (ch, color) = if let Some((label_ch, label_color)) = self.scratch_labels[idx] {
577 (label_ch, label_color)
578 } else {
579 let pixel = self.scratch_pixels[idx];
580 let ch = char::from_u32(0x2800 + pixel.bits).unwrap_or(' ');
581 (ch, pixel.color)
582 };
583
584 match current_color {
585 Some(c) if c == color => {
586 current_text.push(ch);
587 }
588 Some(c) => {
589 segments.push((std::mem::take(&mut current_text), c));
590 current_text.push(ch);
591 current_color = Some(color);
592 }
593 None => {
594 current_text.push(ch);
595 current_color = Some(color);
596 }
597 }
598 }
599
600 if let Some(color) = current_color {
601 segments.push((current_text, color));
602 }
603 lines.push(segments);
604 }
605
606 lines
607 }
608}
609
610macro_rules! define_breakpoint_methods {
611 (
612 base = $base:ident,
613 arg = $arg:ident : $arg_ty:ty,
614 xs = $xs_fn:ident => [$( $xs_doc:literal ),* $(,)?],
615 sm = $sm_fn:ident => [$( $sm_doc:literal ),* $(,)?],
616 md = $md_fn:ident => [$( $md_doc:literal ),* $(,)?],
617 lg = $lg_fn:ident => [$( $lg_doc:literal ),* $(,)?],
618 xl = $xl_fn:ident => [$( $xl_doc:literal ),* $(,)?],
619 at = $at_fn:ident => [$( $at_doc:literal ),* $(,)?]
620 ) => {
621 $(#[doc = $xs_doc])*
622 pub fn $xs_fn(self, $arg: $arg_ty) -> Self {
623 if self.ctx.breakpoint() == Breakpoint::Xs {
624 self.$base($arg)
625 } else {
626 self
627 }
628 }
629
630 $(#[doc = $sm_doc])*
631 pub fn $sm_fn(self, $arg: $arg_ty) -> Self {
632 if self.ctx.breakpoint() == Breakpoint::Sm {
633 self.$base($arg)
634 } else {
635 self
636 }
637 }
638
639 $(#[doc = $md_doc])*
640 pub fn $md_fn(self, $arg: $arg_ty) -> Self {
641 if self.ctx.breakpoint() == Breakpoint::Md {
642 self.$base($arg)
643 } else {
644 self
645 }
646 }
647
648 $(#[doc = $lg_doc])*
649 pub fn $lg_fn(self, $arg: $arg_ty) -> Self {
650 if self.ctx.breakpoint() == Breakpoint::Lg {
651 self.$base($arg)
652 } else {
653 self
654 }
655 }
656
657 $(#[doc = $xl_doc])*
658 pub fn $xl_fn(self, $arg: $arg_ty) -> Self {
659 if self.ctx.breakpoint() == Breakpoint::Xl {
660 self.$base($arg)
661 } else {
662 self
663 }
664 }
665
666 $(#[doc = $at_doc])*
667 pub fn $at_fn(self, bp: Breakpoint, $arg: $arg_ty) -> Self {
668 if self.ctx.breakpoint() == bp {
669 self.$base($arg)
670 } else {
671 self
672 }
673 }
674 };
675}
676
677impl<'a> ContainerBuilder<'a> {
678 // ── border ───────────────────────────────────────────────────────
679
680 /// Apply a reusable [`ContainerStyle`] recipe. Only set fields override
681 /// the builder's current values. Chain multiple `.apply()` calls to compose.
682 ///
683 /// If the style has an [`ContainerStyle::extends`] base, the base is applied
684 /// first, then the style's own fields override.
685 ///
686 /// [`ThemeColor`] fields (`theme_bg`, `theme_text_color`, `theme_border_fg`)
687 /// are resolved against the active theme at apply time.
688 pub fn apply(mut self, style: &ContainerStyle) -> Self {
689 // Apply base style first if this style extends another
690 if let Some(base) = style.extends {
691 self = self.apply(base);
692 }
693 if let Some(v) = style.border {
694 self.border = Some(v);
695 }
696 if let Some(v) = style.border_sides {
697 self.border_sides = v;
698 }
699 if let Some(v) = style.border_style {
700 self.border_style = v;
701 }
702 if let Some(v) = style.bg {
703 self.bg = Some(v);
704 }
705 if let Some(v) = style.dark_bg {
706 self.dark_bg = Some(v);
707 }
708 if let Some(v) = style.dark_border_style {
709 self.dark_border_style = Some(v);
710 }
711 if let Some(v) = style.padding {
712 self.padding = v;
713 }
714 if let Some(v) = style.margin {
715 self.margin = v;
716 }
717 if let Some(v) = style.gap {
718 // `ContainerStyle::gap` stays `Option<u32>` (positive only); only
719 // `gap_overlap` produces a negative builder gap (#222).
720 self.gap = v as i32;
721 }
722 if let Some(v) = style.row_gap {
723 self.row_gap = Some(v);
724 }
725 if let Some(v) = style.col_gap {
726 self.col_gap = Some(v);
727 }
728 if let Some(v) = style.grow {
729 self.grow = v;
730 }
731 if let Some(v) = style.align {
732 self.align = v;
733 }
734 if let Some(v) = style.align_self {
735 self.align_self_value = Some(v);
736 }
737 if let Some(v) = style.justify {
738 self.justify = v;
739 }
740 if let Some(v) = style.text_color {
741 self.text_color = Some(v);
742 }
743 if let Some(w) = style.w {
744 self.constraints = self.constraints.w(w);
745 }
746 if let Some(h) = style.h {
747 self.constraints = self.constraints.h(h);
748 }
749 if let Some(v) = style.min_w {
750 self.constraints.set_min_width(Some(v));
751 }
752 if let Some(v) = style.max_w {
753 self.constraints.set_max_width(Some(v));
754 }
755 if let Some(v) = style.min_h {
756 self.constraints.set_min_height(Some(v));
757 }
758 if let Some(v) = style.max_h {
759 self.constraints.set_max_height(Some(v));
760 }
761 if let Some(v) = style.w_pct {
762 self.constraints.set_width_pct(Some(v));
763 }
764 if let Some(v) = style.h_pct {
765 self.constraints.set_height_pct(Some(v));
766 }
767 // Resolve ThemeColor fields against the active theme (overrides literal colors)
768 if let Some(tc) = style.theme_bg {
769 self.bg = Some(self.ctx.theme.resolve(tc));
770 }
771 if let Some(tc) = style.theme_text_color {
772 self.text_color = Some(self.ctx.theme.resolve(tc));
773 }
774 if let Some(tc) = style.theme_border_fg {
775 let color = self.ctx.theme.resolve(tc);
776 self.border_style = Style::new().fg(color);
777 }
778 self
779 }
780
781 /// Set the border style.
782 pub fn border(mut self, border: Border) -> Self {
783 self.border = Some(border);
784 self
785 }
786
787 /// Show or hide the top border.
788 pub fn border_top(mut self, show: bool) -> Self {
789 self.border_sides.top = show;
790 self
791 }
792
793 /// Show or hide the right border.
794 pub fn border_right(mut self, show: bool) -> Self {
795 self.border_sides.right = show;
796 self
797 }
798
799 /// Show or hide the bottom border.
800 pub fn border_bottom(mut self, show: bool) -> Self {
801 self.border_sides.bottom = show;
802 self
803 }
804
805 /// Show or hide the left border.
806 pub fn border_left(mut self, show: bool) -> Self {
807 self.border_sides.left = show;
808 self
809 }
810
811 /// Set which border sides are visible.
812 pub fn border_sides(mut self, sides: BorderSides) -> Self {
813 self.border_sides = sides;
814 self
815 }
816
817 /// Show only left and right borders. Shorthand for horizontal border sides.
818 pub fn border_x(self) -> Self {
819 self.border_sides(BorderSides {
820 top: false,
821 right: true,
822 bottom: false,
823 left: true,
824 })
825 }
826
827 /// Show only top and bottom borders. Shorthand for vertical border sides.
828 pub fn border_y(self) -> Self {
829 self.border_sides(BorderSides {
830 top: true,
831 right: false,
832 bottom: true,
833 left: false,
834 })
835 }
836
837 /// Set rounded border style. Shorthand for `.border(Border::Rounded)`.
838 pub fn rounded(self) -> Self {
839 self.border(Border::Rounded)
840 }
841
842 /// Set the style applied to the border characters.
843 pub fn border_style(mut self, style: Style) -> Self {
844 self.border_style = style;
845 self
846 }
847
848 /// Set the border foreground color.
849 pub fn border_fg(mut self, color: Color) -> Self {
850 self.border_style = self.border_style.fg(color);
851 self
852 }
853
854 /// Border style used when dark mode is active.
855 pub fn dark_border_style(mut self, style: Style) -> Self {
856 self.dark_border_style = Some(style);
857 self
858 }
859
860 /// Set the background color.
861 pub fn bg(mut self, color: Color) -> Self {
862 self.bg = Some(color);
863 self
864 }
865
866 /// Set the default text color for all child text elements in this container.
867 /// Individual `.fg()` calls on text elements will still override this.
868 pub fn text_color(mut self, color: Color) -> Self {
869 self.text_color = Some(color);
870 self
871 }
872
873 /// Background color used when dark mode is active.
874 pub fn dark_bg(mut self, color: Color) -> Self {
875 self.dark_bg = Some(color);
876 self
877 }
878
879 /// Background color applied when the parent group is hovered.
880 pub fn group_hover_bg(mut self, color: Color) -> Self {
881 self.group_hover_bg = Some(color);
882 self
883 }
884
885 /// Border style applied when the parent group is hovered.
886 pub fn group_hover_border_style(mut self, style: Style) -> Self {
887 self.group_hover_border_style = Some(style);
888 self
889 }
890
891 // ── padding (Tailwind: p, px, py, pt, pr, pb, pl) ───────────────
892
893 /// Set uniform padding on all sides.
894 pub fn p(mut self, value: u32) -> Self {
895 self.padding = Padding::all(value);
896 self
897 }
898
899 /// Set uniform padding on all sides. Deprecated alias for [`p`](Self::p).
900 #[deprecated(since = "0.20.0", note = "Use `p()` instead")]
901 pub fn pad(self, value: u32) -> Self {
902 self.p(value)
903 }
904
905 /// Set horizontal padding (left and right).
906 pub fn px(mut self, value: u32) -> Self {
907 self.padding.left = value;
908 self.padding.right = value;
909 self
910 }
911
912 /// Set vertical padding (top and bottom).
913 pub fn py(mut self, value: u32) -> Self {
914 self.padding.top = value;
915 self.padding.bottom = value;
916 self
917 }
918
919 /// Set top padding.
920 pub fn pt(mut self, value: u32) -> Self {
921 self.padding.top = value;
922 self
923 }
924
925 /// Set right padding.
926 pub fn pr(mut self, value: u32) -> Self {
927 self.padding.right = value;
928 self
929 }
930
931 /// Set bottom padding.
932 pub fn pb(mut self, value: u32) -> Self {
933 self.padding.bottom = value;
934 self
935 }
936
937 /// Set left padding.
938 pub fn pl(mut self, value: u32) -> Self {
939 self.padding.left = value;
940 self
941 }
942
943 /// Set per-side padding using a [`Padding`] value.
944 pub fn padding(mut self, padding: Padding) -> Self {
945 self.padding = padding;
946 self
947 }
948
949 // ── margin (Tailwind: m, mx, my, mt, mr, mb, ml) ────────────────
950
951 /// Set uniform margin on all sides.
952 pub fn m(mut self, value: u32) -> Self {
953 self.margin = Margin::all(value);
954 self
955 }
956
957 /// Set horizontal margin (left and right).
958 pub fn mx(mut self, value: u32) -> Self {
959 self.margin.left = value;
960 self.margin.right = value;
961 self
962 }
963
964 /// Set vertical margin (top and bottom).
965 pub fn my(mut self, value: u32) -> Self {
966 self.margin.top = value;
967 self.margin.bottom = value;
968 self
969 }
970
971 /// Set top margin.
972 pub fn mt(mut self, value: u32) -> Self {
973 self.margin.top = value;
974 self
975 }
976
977 /// Set right margin.
978 pub fn mr(mut self, value: u32) -> Self {
979 self.margin.right = value;
980 self
981 }
982
983 /// Set bottom margin.
984 pub fn mb(mut self, value: u32) -> Self {
985 self.margin.bottom = value;
986 self
987 }
988
989 /// Set left margin.
990 pub fn ml(mut self, value: u32) -> Self {
991 self.margin.left = value;
992 self
993 }
994
995 /// Set per-side margin using a [`Margin`] value.
996 pub fn margin(mut self, margin: Margin) -> Self {
997 self.margin = margin;
998 self
999 }
1000
1001 // ── sizing (Tailwind: w, h, min-w, max-w, min-h, max-h) ────────
1002
1003 /// Set a fixed width (sets both min and max width).
1004 pub fn w(mut self, value: u32) -> Self {
1005 self.constraints = self.constraints.w(value);
1006 self
1007 }
1008
1009 define_breakpoint_methods!(
1010 base = w,
1011 arg = value: u32,
1012 xs = xs_w => [
1013 "Width applied only at Xs breakpoint (< 40 cols).",
1014 "",
1015 "# Example",
1016 "```ignore",
1017 "ui.container().w(20).md_w(40).lg_w(60).col(|ui| { ... });",
1018 "```"
1019 ],
1020 sm = sm_w => ["Width applied only at Sm breakpoint (40-79 cols)."],
1021 md = md_w => ["Width applied only at Md breakpoint (80-119 cols)."],
1022 lg = lg_w => ["Width applied only at Lg breakpoint (120-159 cols)."],
1023 xl = xl_w => ["Width applied only at Xl breakpoint (>= 160 cols)."],
1024 at = w_at => ["Width applied only at the given breakpoint."]
1025 );
1026
1027 /// Set a fixed height (sets both min and max height).
1028 pub fn h(mut self, value: u32) -> Self {
1029 self.constraints = self.constraints.h(value);
1030 self
1031 }
1032
1033 define_breakpoint_methods!(
1034 base = h,
1035 arg = value: u32,
1036 xs = xs_h => ["Height applied only at Xs breakpoint (< 40 cols)."],
1037 sm = sm_h => ["Height applied only at Sm breakpoint (40-79 cols)."],
1038 md = md_h => ["Height applied only at Md breakpoint (80-119 cols)."],
1039 lg = lg_h => ["Height applied only at Lg breakpoint (120-159 cols)."],
1040 xl = xl_h => ["Height applied only at Xl breakpoint (>= 160 cols)."],
1041 at = h_at => ["Height applied only at the given breakpoint."]
1042 );
1043
1044 /// Set the minimum width constraint. Shorthand for [`min_width`](Self::min_width).
1045 pub fn min_w(mut self, value: u32) -> Self {
1046 self.constraints.set_min_width(Some(value));
1047 self
1048 }
1049
1050 define_breakpoint_methods!(
1051 base = min_w,
1052 arg = value: u32,
1053 xs = xs_min_w => ["Minimum width applied only at Xs breakpoint (< 40 cols)."],
1054 sm = sm_min_w => ["Minimum width applied only at Sm breakpoint (40-79 cols)."],
1055 md = md_min_w => ["Minimum width applied only at Md breakpoint (80-119 cols)."],
1056 lg = lg_min_w => ["Minimum width applied only at Lg breakpoint (120-159 cols)."],
1057 xl = xl_min_w => ["Minimum width applied only at Xl breakpoint (>= 160 cols)."],
1058 at = min_w_at => ["Minimum width applied only at the given breakpoint."]
1059 );
1060
1061 /// Set the maximum width constraint. Shorthand for [`max_width`](Self::max_width).
1062 pub fn max_w(mut self, value: u32) -> Self {
1063 self.constraints.set_max_width(Some(value));
1064 self
1065 }
1066
1067 define_breakpoint_methods!(
1068 base = max_w,
1069 arg = value: u32,
1070 xs = xs_max_w => ["Maximum width applied only at Xs breakpoint (< 40 cols)."],
1071 sm = sm_max_w => ["Maximum width applied only at Sm breakpoint (40-79 cols)."],
1072 md = md_max_w => ["Maximum width applied only at Md breakpoint (80-119 cols)."],
1073 lg = lg_max_w => ["Maximum width applied only at Lg breakpoint (120-159 cols)."],
1074 xl = xl_max_w => ["Maximum width applied only at Xl breakpoint (>= 160 cols)."],
1075 at = max_w_at => ["Maximum width applied only at the given breakpoint."]
1076 );
1077
1078 /// Set the minimum height constraint. Shorthand for [`min_height`](Self::min_height).
1079 pub fn min_h(mut self, value: u32) -> Self {
1080 self.constraints.set_min_height(Some(value));
1081 self
1082 }
1083
1084 define_breakpoint_methods!(
1085 base = min_h,
1086 arg = value: u32,
1087 xs = xs_min_h => ["Minimum height applied only at Xs breakpoint (< 40 cols)."],
1088 sm = sm_min_h => ["Minimum height applied only at Sm breakpoint (40-79 cols)."],
1089 md = md_min_h => ["Minimum height applied only at Md breakpoint (80-119 cols)."],
1090 lg = lg_min_h => ["Minimum height applied only at Lg breakpoint (120-159 cols)."],
1091 xl = xl_min_h => ["Minimum height applied only at Xl breakpoint (>= 160 cols)."],
1092 at = min_h_at => ["Minimum height applied only at the given breakpoint."]
1093 );
1094
1095 /// Set the maximum height constraint. Shorthand for [`max_height`](Self::max_height).
1096 pub fn max_h(mut self, value: u32) -> Self {
1097 self.constraints.set_max_height(Some(value));
1098 self
1099 }
1100
1101 define_breakpoint_methods!(
1102 base = max_h,
1103 arg = value: u32,
1104 xs = xs_max_h => ["Maximum height applied only at Xs breakpoint (< 40 cols)."],
1105 sm = sm_max_h => ["Maximum height applied only at Sm breakpoint (40-79 cols)."],
1106 md = md_max_h => ["Maximum height applied only at Md breakpoint (80-119 cols)."],
1107 lg = lg_max_h => ["Maximum height applied only at Lg breakpoint (120-159 cols)."],
1108 xl = xl_max_h => ["Maximum height applied only at Xl breakpoint (>= 160 cols)."],
1109 at = max_h_at => ["Maximum height applied only at the given breakpoint."]
1110 );
1111
1112 /// Set the minimum width constraint in cells. Deprecated alias for [`min_w`](Self::min_w).
1113 #[deprecated(since = "0.20.0", note = "Use `min_w()` instead")]
1114 pub fn min_width(self, value: u32) -> Self {
1115 self.min_w(value)
1116 }
1117
1118 /// Set the maximum width constraint in cells. Deprecated alias for [`max_w`](Self::max_w).
1119 #[deprecated(since = "0.20.0", note = "Use `max_w()` instead")]
1120 pub fn max_width(self, value: u32) -> Self {
1121 self.max_w(value)
1122 }
1123
1124 /// Set the minimum height constraint in rows. Deprecated alias for [`min_h`](Self::min_h).
1125 #[deprecated(since = "0.20.0", note = "Use `min_h()` instead")]
1126 pub fn min_height(self, value: u32) -> Self {
1127 self.min_h(value)
1128 }
1129
1130 /// Set the maximum height constraint in rows. Deprecated alias for [`max_h`](Self::max_h).
1131 #[deprecated(since = "0.20.0", note = "Use `max_h()` instead")]
1132 pub fn max_height(self, value: u32) -> Self {
1133 self.max_h(value)
1134 }
1135
1136 /// Set width as a percentage (1-100) of the parent container.
1137 pub fn w_pct(mut self, pct: u8) -> Self {
1138 self.constraints.set_width_pct(Some(pct.min(100)));
1139 self
1140 }
1141
1142 /// Set height as a percentage (1-100) of the parent container.
1143 pub fn h_pct(mut self, pct: u8) -> Self {
1144 self.constraints.set_height_pct(Some(pct.min(100)));
1145 self
1146 }
1147
1148 /// Set all size constraints at once using a [`Constraints`] value.
1149 pub fn constraints(mut self, constraints: Constraints) -> Self {
1150 self.constraints = constraints;
1151 self
1152 }
1153
1154 // ── flex ─────────────────────────────────────────────────────────
1155
1156 /// Set the gap (in cells) between child elements.
1157 pub fn gap(mut self, gap: u32) -> Self {
1158 self.gap = gap as i32;
1159 self
1160 }
1161
1162 /// Set a *negative* gap, causing adjacent children to overlap by `overlap`
1163 /// cells on the main axis.
1164 ///
1165 /// This is SLT's analogue of ratatui's `Layout::spacing(-1)`. The common
1166 /// use is collapsing the duplicate border between two adjacent bordered
1167 /// panels: with `gap_overlap(1)` each panel's shared edge lands in the
1168 /// same column (row layout) or row (column layout), so the doubled border
1169 ///
1170 /// ```text
1171 /// ┌────┐┌────┐
1172 /// │ ││ │
1173 /// └────┘└────┘
1174 /// ```
1175 ///
1176 /// collapses to a single shared edge.
1177 ///
1178 /// `gap_overlap(0)` is identical to `gap(0)` (no overlap). It composes with
1179 /// the existing `gap` family: the last call wins, so call exactly one of
1180 /// `gap` / `gap_overlap` per builder.
1181 ///
1182 /// # Rendering note
1183 ///
1184 /// SLT does not (yet) merge the shared cells into junction glyphs (`┬`,
1185 /// `┼`, `┴`). When two bordered panels overlap, both write the shared
1186 /// column/row and the later panel's border character wins by buffer-diff
1187 /// order. To get a clean seam, give the panels compatible border styles or
1188 /// drop one panel's shared side (e.g. `border_sides` without the left edge).
1189 ///
1190 /// Large overlaps saturate gracefully — `gap_overlap(N)` past a child's
1191 /// extent never panics or wraps; positions clamp at 0.
1192 ///
1193 /// # Example
1194 ///
1195 /// ```no_run
1196 /// # slt::run(|ui: &mut slt::Context| {
1197 /// use slt::Border;
1198 /// // Two bordered panels sharing one border column.
1199 /// ui.container().gap_overlap(1).row(|ui| {
1200 /// ui.bordered(Border::Single).w(10).col(|ui| {
1201 /// ui.text("left");
1202 /// });
1203 /// ui.bordered(Border::Single).w(10).col(|ui| {
1204 /// ui.text("right");
1205 /// });
1206 /// });
1207 /// # });
1208 /// ```
1209 pub fn gap_overlap(mut self, overlap: u32) -> Self {
1210 self.gap = -(overlap as i32);
1211 self
1212 }
1213
1214 /// Set the gap between children for column layouts (vertical spacing).
1215 /// Overrides `.gap()` when finalized with `.col()`.
1216 pub fn row_gap(mut self, value: u32) -> Self {
1217 self.row_gap = Some(value);
1218 self
1219 }
1220
1221 /// Set the gap between children for row layouts (horizontal spacing).
1222 /// Overrides `.gap()` when finalized with `.row()`.
1223 pub fn col_gap(mut self, value: u32) -> Self {
1224 self.col_gap = Some(value);
1225 self
1226 }
1227
1228 define_breakpoint_methods!(
1229 base = gap,
1230 arg = value: u32,
1231 xs = xs_gap => ["Gap applied only at Xs breakpoint (< 40 cols)."],
1232 sm = sm_gap => ["Gap applied only at Sm breakpoint (40-79 cols)."],
1233 md = md_gap => [
1234 "Gap applied only at Md breakpoint (80-119 cols).",
1235 "",
1236 "# Example",
1237 "```ignore",
1238 "ui.container().gap(0).md_gap(2).col(|ui| { ... });",
1239 "```"
1240 ],
1241 lg = lg_gap => ["Gap applied only at Lg breakpoint (120-159 cols)."],
1242 xl = xl_gap => ["Gap applied only at Xl breakpoint (>= 160 cols)."],
1243 at = gap_at => ["Gap applied only at the given breakpoint."]
1244 );
1245
1246 /// Set the flex-grow factor. `1` means the container expands to fill available space.
1247 pub fn grow(mut self, grow: u16) -> Self {
1248 self.grow = grow;
1249 self
1250 }
1251
1252 /// Expand to fill remaining space on the main axis. Shorthand for
1253 /// [`grow(1)`](Self::grow).
1254 ///
1255 /// Equivalent to CSS `flex: 1` and ratatui's `Constraint::Fill(1)`.
1256 /// This is the most common case in flex layouts and reads more
1257 /// naturally than `grow(1)` for new readers — the abstract "grow
1258 /// factor" terminology is replaced by a self-documenting verb.
1259 ///
1260 /// ```ignore
1261 /// ui.container().fill().col(|ui| { ... });
1262 /// // identical to:
1263 /// ui.container().grow(1).col(|ui| { ... });
1264 /// ```
1265 ///
1266 /// For other weights (e.g. a 2:1 split between two siblings), use
1267 /// `grow(N)` directly.
1268 pub fn fill(self) -> Self {
1269 self.grow(1)
1270 }
1271
1272 /// Opt this container into proportional flex-shrink.
1273 ///
1274 /// Marks this container as a shrink participant. When the parent
1275 /// row / column overflows (its children's combined width or height
1276 /// exceeds available space), shrink-flagged children scale their
1277 /// fixed sizes by `available / fixed_total` (CSS `flex-shrink`-style).
1278 /// Children without `.shrink()` keep their historic
1279 /// overflow-by-design size and clip naturally.
1280 ///
1281 /// Default for every container is `false` — opt in per child.
1282 /// Equivalent to CSS `flex-shrink: 1` (vs the SLT default of `0`).
1283 /// Closes #161.
1284 ///
1285 /// # Example
1286 ///
1287 /// Two siblings with combined fixed width `60` placed inside a
1288 /// `40`-cell row. Without `.shrink()`, the row overflows; with
1289 /// `.shrink()` on both, each scales to `40 * 30/60 = 20`:
1290 ///
1291 /// ```no_run
1292 /// # slt::run(|ui: &mut slt::Context| {
1293 /// // Without shrink — overflows the parent.
1294 /// ui.row(|ui| {
1295 /// ui.container().w(30).col(|ui| { ui.text("left"); });
1296 /// ui.container().w(30).col(|ui| { ui.text("right"); });
1297 /// });
1298 ///
1299 /// // With shrink on both — proportional fit, no clipping.
1300 /// ui.row(|ui| {
1301 /// ui.container().w(30).shrink().col(|ui| { ui.text("left"); });
1302 /// ui.container().w(30).shrink().col(|ui| { ui.text("right"); });
1303 /// });
1304 /// # });
1305 /// ```
1306 ///
1307 /// # Layout
1308 ///
1309 /// Only fixed-width children with `grow == 0` participate. Grow
1310 /// children already absorb leftover space and ignore the shrink
1311 /// flag. Mixing shrink and non-shrink siblings is supported — only
1312 /// the flagged ones contribute to the shrink budget.
1313 pub fn shrink(mut self) -> Self {
1314 self.shrink_flag = true;
1315 self
1316 }
1317
1318 /// Allow row children to wrap onto subsequent lines on main-axis overflow.
1319 ///
1320 /// When a `.row()` finalized with `wrap()` has children whose combined
1321 /// width exceeds the available width, the overflowing children flow onto
1322 /// the next line, and lines stack on the cross axis. This is the
1323 /// immediate-mode primitive for tag clouds, chip lists, wrapping toolbars,
1324 /// and responsive card grids that reflow as the terminal resizes — without
1325 /// per-frame breakpoint math. Equivalent to CSS `flex-wrap: wrap`.
1326 ///
1327 /// Spacing: within-line (main-axis) spacing uses `gap` / `col_gap` as
1328 /// usual; between-line (cross-axis) spacing uses `row_gap` when set, else
1329 /// `gap`. A child wider than the full available width occupies its own
1330 /// line (clipped, as a single-line row would clip) rather than producing
1331 /// an empty line.
1332 ///
1333 /// Row only. On `col()` this is a documented no-op (vertical-axis wrap is
1334 /// out of scope). Default: no wrap (single-line, current
1335 /// overflow-by-design behavior). Closes #258.
1336 ///
1337 /// # Example
1338 ///
1339 /// ```no_run
1340 /// # slt::run(|ui: &mut slt::Context| {
1341 /// // A chip list that reflows onto as many lines as the width needs.
1342 /// ui.container().wrap().gap(1).row(|ui| {
1343 /// for tag in ["rust", "tui", "flexbox", "wrap", "immediate-mode"] {
1344 /// ui.container().p(1).col(|ui| { ui.text(tag); });
1345 /// }
1346 /// });
1347 /// # });
1348 /// ```
1349 #[doc(alias = "flex-wrap")]
1350 pub fn wrap(mut self) -> Self {
1351 self.wrap_flag = true;
1352 self
1353 }
1354
1355 /// Set the flex-basis: the initial main-axis size (in cells) that `grow`
1356 /// grows from and `shrink` (#161) shrinks from.
1357 ///
1358 /// CSS resolves flex sizing as `basis` (initial) → distribute free space
1359 /// by `grow` → distribute the deficit by `shrink`. By default SLT uses a
1360 /// child's min size as that base; `basis(n)` overrides it so a child can
1361 /// say "start at `n` cells, then grow / shrink from there". `None`
1362 /// (default, i.e. not calling this) falls back to the min size, preserving
1363 /// current behavior. Equivalent to CSS `flex-basis: <n>`. Closes #258.
1364 ///
1365 /// # Example
1366 ///
1367 /// ```no_run
1368 /// # slt::run(|ui: &mut slt::Context| {
1369 /// // Two cards that each start at 10 cells, then split the leftover.
1370 /// ui.row(|ui| {
1371 /// ui.container().basis(10).grow(1).col(|ui| { ui.text("a"); });
1372 /// ui.container().basis(10).grow(1).col(|ui| { ui.text("b"); });
1373 /// });
1374 /// # });
1375 /// ```
1376 #[doc(alias = "flex-basis")]
1377 pub fn basis(mut self, cells: u32) -> Self {
1378 self.basis = Some(cells);
1379 self
1380 }
1381
1382 define_breakpoint_methods!(
1383 base = grow,
1384 arg = value: u16,
1385 xs = xs_grow => ["Grow factor applied only at Xs breakpoint (< 40 cols)."],
1386 sm = sm_grow => ["Grow factor applied only at Sm breakpoint (40-79 cols)."],
1387 md = md_grow => ["Grow factor applied only at Md breakpoint (80-119 cols)."],
1388 lg = lg_grow => ["Grow factor applied only at Lg breakpoint (120-159 cols)."],
1389 xl = xl_grow => ["Grow factor applied only at Xl breakpoint (>= 160 cols)."],
1390 at = grow_at => ["Grow factor applied only at the given breakpoint."]
1391 );
1392
1393 define_breakpoint_methods!(
1394 base = p,
1395 arg = value: u32,
1396 xs = xs_p => ["Uniform padding applied only at Xs breakpoint (< 40 cols)."],
1397 sm = sm_p => ["Uniform padding applied only at Sm breakpoint (40-79 cols)."],
1398 md = md_p => ["Uniform padding applied only at Md breakpoint (80-119 cols)."],
1399 lg = lg_p => ["Uniform padding applied only at Lg breakpoint (120-159 cols)."],
1400 xl = xl_p => ["Uniform padding applied only at Xl breakpoint (>= 160 cols)."],
1401 at = p_at => ["Padding applied only at the given breakpoint."]
1402 );
1403
1404 // ── alignment ───────────────────────────────────────────────────
1405
1406 /// Set the cross-axis alignment of child elements.
1407 pub fn align(mut self, align: Align) -> Self {
1408 self.align = align;
1409 self
1410 }
1411
1412 /// Center children on the cross axis. Shorthand for `.align(Align::Center)`.
1413 pub fn center(self) -> Self {
1414 self.align(Align::Center)
1415 }
1416
1417 /// Set the main-axis content distribution mode.
1418 pub fn justify(mut self, justify: Justify) -> Self {
1419 self.justify = justify;
1420 self
1421 }
1422
1423 /// Distribute children with equal space between; first at start, last at end.
1424 pub fn space_between(self) -> Self {
1425 self.justify(Justify::SpaceBetween)
1426 }
1427
1428 /// Distribute children with equal space around each child.
1429 pub fn space_around(self) -> Self {
1430 self.justify(Justify::SpaceAround)
1431 }
1432
1433 /// Distribute children with equal space between all children and edges.
1434 pub fn space_evenly(self) -> Self {
1435 self.justify(Justify::SpaceEvenly)
1436 }
1437
1438 /// Center children on both axes. Shorthand for `.justify(Justify::Center).align(Align::Center)`.
1439 pub fn flex_center(self) -> Self {
1440 self.justify(Justify::Center).align(Align::Center)
1441 }
1442
1443 /// Override the parent's cross-axis alignment for this container only.
1444 /// Like CSS `align-self`.
1445 pub fn align_self(mut self, align: Align) -> Self {
1446 self.align_self_value = Some(align);
1447 self
1448 }
1449
1450 // ── title ────────────────────────────────────────────────────────
1451
1452 /// Set a plain-text title rendered in the top border.
1453 pub fn title(self, title: impl Into<String>) -> Self {
1454 self.title_styled(title, Style::new())
1455 }
1456
1457 /// Set a styled title rendered in the top border.
1458 pub fn title_styled(mut self, title: impl Into<String>, style: Style) -> Self {
1459 self.title = Some((title.into(), style));
1460 self
1461 }
1462
1463 // ── conditional / grouped builder helpers ───────────────────────
1464
1465 /// Apply `f` only if `cond` is true. Returns the builder for chaining.
1466 ///
1467 /// Use this to attach a block of builder modifiers without breaking the
1468 /// fluent chain. The closure takes the builder by value and must return
1469 /// it (matching the rest of `ContainerBuilder`'s by-value API), so any
1470 /// builder method (`.border()`, `.title()`, `.bg()`, etc.) can be chained
1471 /// inside.
1472 ///
1473 /// Zero allocation: the closure is inlined and skipped entirely when
1474 /// `cond` is `false`.
1475 ///
1476 /// # Example
1477 ///
1478 /// ```no_run
1479 /// # slt::run(|ui: &mut slt::Context| {
1480 /// use slt::Border;
1481 /// let highlighted = true;
1482 /// ui.container()
1483 /// .p(1)
1484 /// .with_if(highlighted, |c| c.border(Border::Single).title("Active"))
1485 /// .col(|ui| {
1486 /// ui.text("body");
1487 /// });
1488 /// # });
1489 /// ```
1490 pub fn with_if(self, cond: bool, f: impl FnOnce(Self) -> Self) -> Self {
1491 if cond { f(self) } else { self }
1492 }
1493
1494 /// Override the active theme for all widgets rendered inside this container.
1495 ///
1496 /// The override is scoped to the container body (the closure passed to
1497 /// `.col()`, `.row()`, or `.line()`). The parent theme is restored when
1498 /// the container closes — including on panic.
1499 ///
1500 /// All built-in widgets read `ctx.theme` directly for color decisions,
1501 /// so this swap propagates through every nested widget without requiring
1502 /// them to opt in. Nested `.theme(...)` calls correctly nest: the
1503 /// innermost theme wins inside its own subtree, and the outer theme
1504 /// resumes once it closes.
1505 ///
1506 /// Independent of [`Context::provide`] / [`Context::use_context`] —
1507 /// this directly mutates the active theme used by SLT-owned widgets,
1508 /// while `provide`/`use_context` is the general-purpose context
1509 /// injection mechanism for user code.
1510 ///
1511 /// # Example
1512 ///
1513 /// ```no_run
1514 /// # slt::run(|ui: &mut slt::Context| {
1515 /// use slt::{Border, Theme};
1516 /// ui.container()
1517 /// .theme(Theme::light())
1518 /// .border(Border::Rounded)
1519 /// .col(|ui| {
1520 /// ui.text("This subtree renders with the light theme");
1521 /// ui.button("Click me"); // also uses light theme colors
1522 /// });
1523 /// # });
1524 /// ```
1525 pub fn theme(mut self, theme: Theme) -> Self {
1526 self.theme_override = Some(theme);
1527 self
1528 }
1529
1530 /// Apply `f` unconditionally. Useful for factoring out a block of builder
1531 /// modifier calls while keeping the fluent chain intact.
1532 ///
1533 /// The closure takes the builder by value and must return it.
1534 ///
1535 /// # Example
1536 ///
1537 /// ```no_run
1538 /// # slt::run(|ui: &mut slt::Context| {
1539 /// use slt::Border;
1540 /// ui.container()
1541 /// .with(|c| c.border(Border::Rounded).p(1))
1542 /// .col(|ui| {
1543 /// ui.text("body");
1544 /// });
1545 /// # });
1546 /// ```
1547 pub fn with(self, f: impl FnOnce(Self) -> Self) -> Self {
1548 f(self)
1549 }
1550
1551 // ── opt-in scoped cache (issue #273) ───────────────────────────────
1552
1553 /// Opt-in: declare a subtree **stable** when `version_key` is unchanged
1554 /// from the previous frame at this call site.
1555 ///
1556 /// This is an **author-controlled cache, not reactive binding**. Your
1557 /// closure is still the app ([Principle 2 — "Your Closure IS the App"]):
1558 /// `f` runs **every frame** exactly like `.col(f)`, so the rendered output
1559 /// is **byte-for-byte identical** to an uncached container — there is no
1560 /// retained widget identity, no message passing, no reactive subscription,
1561 /// and no behavior change whatsoever when you do not call `cached`.
1562 ///
1563 /// What `cached` adds is a single, principle-preserving signal: it records
1564 /// the `version_key` you supply (a value you already own — e.g. a hash of
1565 /// the non-streaming inputs, or `StreamingTextState::version` of the
1566 /// *other* panes) and compares it to the key this call site recorded last
1567 /// frame. A match is a *cache hit* (the subtree is declared unchanged); a
1568 /// change, a new call site, the first frame, or a terminal resize is a
1569 /// *miss*. The hit/miss tally is exposed via
1570 /// [`Context::region_cache_hits`](crate::Context::region_cache_hits) /
1571 /// [`Context::region_cache_misses`](crate::Context::region_cache_misses).
1572 ///
1573 /// # Why output is identical even on a hit (current implementation)
1574 ///
1575 /// Skipping `f` on a hit would require splicing the prior frame's recorded
1576 /// `Command`s, replaying its focus / hit-map / scroll / raw-draw feedback,
1577 /// and reusing its rendered cells — without that full replay the immediate-
1578 /// mode invariant breaks (focus and interaction would silently drop). That
1579 /// replay is deliberately **out of scope** here (it risks reintroducing a
1580 /// retained tree, the thing Principle 2 forbids). So `cached` keeps the
1581 /// invariant absolute — `f` always runs — and instead lands the *safe,
1582 /// reversible* half: a measured, author-keyed stability gate plus
1583 /// diagnostics. The streaming benchmark `bench_streaming_append_chat`
1584 /// (`benches/benchmarks.rs`) quantifies the upstream cost this gate is
1585 /// designed to eventually elide; see `docs/PERFORMANCE.md`.
1586 ///
1587 /// # Pattern: cache the chrome, not the stream
1588 ///
1589 /// During token streaming, wrap the *static* surroundings (chat history,
1590 /// sidebar, status bar) keyed off everything *except* the stream, and
1591 /// leave the stream itself uncached — it changes every token:
1592 ///
1593 /// ```no_run
1594 /// # slt::run(|ui: &mut slt::Context| {
1595 /// # let history_version = 3u64;
1596 /// # let mut stream = slt::StreamingTextState::new();
1597 /// ui.container().cached(history_version, |ui| {
1598 /// ui.text("…long chat transcript…"); // unchanged this token
1599 /// });
1600 /// ui.streaming_text(&mut stream); // changes every token
1601 /// # });
1602 /// ```
1603 ///
1604 /// [Principle 2 — "Your Closure IS the App"]: https://docs.rs/slt
1605 pub fn cached(self, version_key: u64, f: impl FnOnce(&mut Context)) -> Response {
1606 // Record the key / classify hit-vs-miss BEFORE running the body so the
1607 // declaration order (and thus the per-call-site slot index) matches
1608 // the order regions are authored, exactly like the hook cursor.
1609 let _hit = self.ctx.record_cached_region(version_key);
1610 // Always run the body: byte-identical output, immediate-mode invariant
1611 // preserved. `_hit` is the gate a future cell-level cache would use.
1612 self.col(f)
1613 }
1614
1615 // ── internal ─────────────────────────────────────────────────────
1616
1617 /// Set the vertical scroll offset in rows. Used internally by [`Context::scrollable`].
1618 ///
1619 /// This is a crate-internal helper; external callers should use
1620 /// [`Context::scrollable`] together with a [`ScrollState`].
1621 ///
1622 /// Hidden from rustdoc with `#[doc(hidden)]` so it does not appear in the
1623 /// public API surface, while remaining callable for backwards compatibility
1624 /// (cargo-semver-checks still tracks the symbol). Promote to `pub(crate)`
1625 /// at v1.0.
1626 ///
1627 /// [`ScrollState`]: crate::widgets::ScrollState
1628 #[doc(hidden)]
1629 pub fn scroll_offset(mut self, offset: u32) -> Self {
1630 self.scroll_offset = Some(offset);
1631 self
1632 }
1633
1634 /// Internal entry point that takes an already-shared `Arc<str>`.
1635 ///
1636 /// Used by `Context::group()` so the name allocated in the public path
1637 /// is pushed onto `group_stack` and threaded into `BeginContainerArgs`
1638 /// through a single `Arc::clone` instead of two `String` allocations.
1639 /// Closes #145 (double `to_string`) and completes the `Arc<str>`
1640 /// migration in #139.
1641 pub(crate) fn group_name_arc(mut self, name: std::sync::Arc<str>) -> Self {
1642 self.group_name = Some(name);
1643 self
1644 }
1645
1646 /// Finalize the builder as a vertical (column) container.
1647 ///
1648 /// The closure receives a `&mut Context` for rendering children.
1649 /// Returns a [`Response`] with click/hover state for this container.
1650 pub fn col(self, f: impl FnOnce(&mut Context)) -> Response {
1651 self.finish(Direction::Column, f)
1652 }
1653
1654 /// Finalize the builder as a horizontal (row) container.
1655 ///
1656 /// The closure receives a `&mut Context` for rendering children.
1657 /// Returns a [`Response`] with click/hover state for this container.
1658 pub fn row(self, f: impl FnOnce(&mut Context)) -> Response {
1659 self.finish(Direction::Row, f)
1660 }
1661
1662 /// Finalize the builder as an inline text line.
1663 ///
1664 /// Like [`row`](ContainerBuilder::row) but gap is forced to zero
1665 /// for seamless inline rendering of mixed-style text.
1666 pub fn line(mut self, f: impl FnOnce(&mut Context)) -> Response {
1667 self.gap = 0;
1668 self.finish(Direction::Row, f)
1669 }
1670
1671 /// Finalize the builder as a raw-draw region with direct buffer access.
1672 ///
1673 /// The closure receives `(&mut Buffer, Rect)` after layout is computed.
1674 /// Use `buf.set_char()`, `buf.set_string()`, `buf.get_mut()` to write
1675 /// directly into the terminal buffer. Writes outside `rect` are clipped.
1676 ///
1677 /// The closure must be `'static` because it is deferred until after layout.
1678 /// To capture local data, clone or move it into the closure:
1679 /// ```ignore
1680 /// let data = my_vec.clone();
1681 /// ui.container().w(40).h(20).draw(move |buf, rect| {
1682 /// // use `data` here
1683 /// });
1684 /// ```
1685 pub fn draw(self, f: impl FnOnce(&mut crate::buffer::Buffer, Rect) + 'static) {
1686 let draw_id = self.ctx.deferred_draws.len();
1687 self.ctx.deferred_draws.push(Some(Box::new(f)));
1688 self.ctx.skip_interaction_slot();
1689 self.ctx.commands.push(Command::RawDraw {
1690 draw_id,
1691 constraints: self.constraints,
1692 grow: self.grow,
1693 margin: self.margin,
1694 });
1695 }
1696
1697 /// Like [`draw`](Self::draw), but carries owned per-frame `data` through
1698 /// to the deferred closure as a borrow.
1699 ///
1700 /// Raw-draw closures must be `'static` because they run after layout is
1701 /// computed — which normally forces callers to snapshot any borrowed
1702 /// state into an owned value before passing it in. `draw_with` makes
1703 /// that explicit: hand the snapshot over, borrow it inside the closure.
1704 ///
1705 /// # Example
1706 ///
1707 /// ```no_run
1708 /// # use slt::{Buffer, Rect, Style};
1709 /// # slt::run(|ui: &mut slt::Context| {
1710 /// let points: Vec<(u32, u32)> = (0..20).map(|i| (i, i * 2)).collect();
1711 /// ui.container().w(40).h(20).draw_with(points, |buf, rect, points| {
1712 /// for (x, y) in points {
1713 /// if rect.contains(*x, *y) {
1714 /// buf.set_char(*x, *y, '●', Style::new());
1715 /// }
1716 /// }
1717 /// });
1718 /// # });
1719 /// ```
1720 pub fn draw_with<D: 'static>(
1721 self,
1722 data: D,
1723 f: impl FnOnce(&mut crate::buffer::Buffer, Rect, &D) + 'static,
1724 ) {
1725 let draw_id = self.ctx.deferred_draws.len();
1726 self.ctx
1727 .deferred_draws
1728 .push(Some(Box::new(move |buf, rect| f(buf, rect, &data))));
1729 self.ctx.skip_interaction_slot();
1730 self.ctx.commands.push(Command::RawDraw {
1731 draw_id,
1732 constraints: self.constraints,
1733 grow: self.grow,
1734 margin: self.margin,
1735 });
1736 }
1737
1738 /// Custom drawing with click and hover detection.
1739 ///
1740 /// Like [`draw`](Self::draw), but the returned [`Response`] reports
1741 /// `clicked` and `hovered` based on the laid-out region — exactly like
1742 /// `.col()` or `.row()`.
1743 ///
1744 /// # Example
1745 ///
1746 /// ```no_run
1747 /// # slt::run(|ui: &mut slt::Context| {
1748 /// let resp = ui.container()
1749 /// .w(40).h(10)
1750 /// .draw_interactive(|buf, rect| {
1751 /// buf.set_string(rect.x, rect.y, "Click me!", slt::Style::new());
1752 /// });
1753 /// if resp.clicked {
1754 /// // handle click
1755 /// }
1756 /// # });
1757 /// ```
1758 pub fn draw_interactive(
1759 self,
1760 f: impl FnOnce(&mut crate::buffer::Buffer, Rect) + 'static,
1761 ) -> Response {
1762 let draw_id = self.ctx.deferred_draws.len();
1763 self.ctx.deferred_draws.push(Some(Box::new(f)));
1764 let interaction_id = self.ctx.next_interaction_id();
1765 self.ctx.commands.push(Command::RawDraw {
1766 draw_id,
1767 constraints: self.constraints,
1768 grow: self.grow,
1769 margin: self.margin,
1770 });
1771 self.ctx.response_for(interaction_id)
1772 }
1773
1774 fn finish(mut self, direction: Direction, f: impl FnOnce(&mut Context)) -> Response {
1775 let interaction_id = self.ctx.next_interaction_id();
1776 // `row_gap` / `col_gap` are `Option<u32>` (positive override); fall back
1777 // to the signed builder `gap`, which alone can carry an overlap (#222).
1778 let resolved_gap: i32 = match direction {
1779 Direction::Column => self.row_gap.map(|g| g as i32).unwrap_or(self.gap),
1780 Direction::Row => self.col_gap.map(|g| g as i32).unwrap_or(self.gap),
1781 };
1782 // Cross-axis (between-line) gap for a wrapping row (#258): `row_gap`
1783 // when set, else the builder `gap`. Only consulted by the layout pass
1784 // when this container is a wrapping `Direction::Row`.
1785 let resolved_cross_gap: i32 = self.row_gap.map(|g| g as i32).unwrap_or(self.gap);
1786
1787 let in_hovered_group = self
1788 .group_name
1789 .as_ref()
1790 .map(|name| self.ctx.is_group_hovered(name))
1791 .unwrap_or(false)
1792 || self
1793 .ctx
1794 .rollback
1795 .group_stack
1796 .last()
1797 .map(|name| self.ctx.is_group_hovered(name))
1798 .unwrap_or(false);
1799 let in_focused_group = self
1800 .group_name
1801 .as_ref()
1802 .map(|name| self.ctx.is_group_focused(name))
1803 .unwrap_or(false)
1804 || self
1805 .ctx
1806 .rollback
1807 .group_stack
1808 .last()
1809 .map(|name| self.ctx.is_group_focused(name))
1810 .unwrap_or(false);
1811
1812 let resolved_bg = if self.ctx.rollback.dark_mode {
1813 self.dark_bg.or(self.bg)
1814 } else {
1815 self.bg
1816 };
1817 let resolved_border_style = if self.ctx.rollback.dark_mode {
1818 self.dark_border_style.unwrap_or(self.border_style)
1819 } else {
1820 self.border_style
1821 };
1822 let bg_color = if in_hovered_group || in_focused_group {
1823 self.group_hover_bg.or(resolved_bg)
1824 } else {
1825 resolved_bg
1826 };
1827 let border_style = if in_hovered_group || in_focused_group {
1828 self.group_hover_border_style
1829 .unwrap_or(resolved_border_style)
1830 } else {
1831 resolved_border_style
1832 };
1833 let group_name = self.group_name.take();
1834 let is_group_container = group_name.is_some();
1835
1836 // Opt-in flex-shrink (#161). Push a marker the layout pass picks up
1837 // and applies to the next `BeginContainer` / `BeginScrollable`,
1838 // mirroring the existing `FocusMarker` / `InteractionMarker` pattern.
1839 // This avoids touching every `BeginContainerArgs` construction site
1840 // across the widget modules — only `ContainerBuilder.shrink()`
1841 // emits the marker, and `LayoutNode::shrink` defaults to `false`.
1842 if self.shrink_flag {
1843 self.ctx.commands.push(Command::ShrinkMarker);
1844 }
1845
1846 // Opt-in flex-wrap / flex-basis (#258). Same marker pattern as shrink:
1847 // pushed just before the matching `Begin*`, picked up by the layout
1848 // pass and applied to the next node. Both default off / `None`, so
1849 // unflagged containers are byte-identical to pre-#258.
1850 if self.wrap_flag {
1851 self.ctx
1852 .commands
1853 .push(Command::WrapMarker(resolved_cross_gap));
1854 }
1855 if let Some(basis) = self.basis {
1856 self.ctx.commands.push(Command::BasisMarker(basis));
1857 }
1858
1859 if let Some(scroll_offset) = self.scroll_offset {
1860 // #247: carry the finalizing `.row()` / `.col()` direction and both
1861 // axis offsets. The tree builder applies the offset matching
1862 // `direction`; the cross-axis offset is `0` for a single-axis
1863 // scroller (the common case).
1864 self.ctx
1865 .commands
1866 .push(Command::BeginScrollable(Box::new(BeginScrollableArgs {
1867 grow: self.grow,
1868 direction,
1869 border: self.border,
1870 border_sides: self.border_sides,
1871 border_style,
1872 bg_color,
1873 align: self.align,
1874 align_self: self.align_self_value,
1875 justify: self.justify,
1876 gap: resolved_gap,
1877 padding: self.padding,
1878 margin: self.margin,
1879 constraints: self.constraints,
1880 title: self.title,
1881 scroll_offset,
1882 scroll_offset_x: self.scroll_offset_x.unwrap_or(0),
1883 group_name,
1884 })));
1885 } else {
1886 self.ctx
1887 .commands
1888 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1889 direction,
1890 gap: resolved_gap,
1891 align: self.align,
1892 align_self: self.align_self_value,
1893 justify: self.justify,
1894 border: self.border,
1895 border_sides: self.border_sides,
1896 border_style,
1897 bg_color,
1898 padding: self.padding,
1899 margin: self.margin,
1900 constraints: self.constraints,
1901 title: self.title,
1902 grow: self.grow,
1903 group_name,
1904 })));
1905 }
1906 self.ctx.rollback.text_color_stack.push(self.text_color);
1907 // Swap active theme if a per-subtree override was requested.
1908 // The previous theme is restored after `f` returns — including on
1909 // panic, so no widget ever sees a leaked override theme.
1910 let theme_save = self.theme_override.map(|t| {
1911 let prev = self.ctx.theme;
1912 self.ctx.theme = t;
1913 // Also keep dark_mode flag in sync so `dark_*` style variants
1914 // resolve to the new theme's brightness, not the stale flag.
1915 self.ctx.rollback.dark_mode = t.is_dark;
1916 (prev, prev.is_dark)
1917 });
1918 // catch_unwind guards the restore path against panics inside `f`.
1919 // The overlay/group bookkeeping that follows assumes `theme` reflects
1920 // the parent scope, so we must restore before propagating the panic.
1921 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self.ctx)));
1922 if let Some((prev, prev_dark)) = theme_save {
1923 self.ctx.theme = prev;
1924 self.ctx.rollback.dark_mode = prev_dark;
1925 }
1926 self.ctx.rollback.text_color_stack.pop();
1927 self.ctx.commands.push(Command::EndContainer);
1928 self.ctx.rollback.last_text_idx = None;
1929 if let Err(panic) = result {
1930 std::panic::resume_unwind(panic);
1931 }
1932
1933 if is_group_container {
1934 self.ctx.rollback.group_stack.pop();
1935 self.ctx.rollback.group_count = self.ctx.rollback.group_count.saturating_sub(1);
1936 }
1937
1938 self.ctx.response_for(interaction_id)
1939 }
1940}
1941
1942#[cfg(test)]
1943mod hotfix_tests {
1944 //! Regression tests for v0.19.1 A3 hotfixes (issues #143, #144, #146, #149).
1945
1946 use super::*;
1947
1948 // -- #143: filled_triangle stack-array intersections ----------------
1949
1950 /// Filling a triangle must paint the same pixel set whether the
1951 /// previous Vec<f64> path or the new inline-array path is used.
1952 #[test]
1953 fn filled_triangle_paints_expected_interior() {
1954 let mut canvas = CanvasContext::new(20, 20);
1955 canvas.filled_triangle(2, 2, 18, 4, 6, 18);
1956
1957 // Sample a point that must be filled (lies clearly inside the
1958 // triangle) and a point that must remain empty.
1959 let lines = canvas.render();
1960 // Pixel (8, 8) -> char cell (4, 2). Pull bits via re-render fallback.
1961 let inside_row = 8 / 4;
1962 let outside_row = 0;
1963 // Each row must be present in the rendered output.
1964 assert!(lines.len() > inside_row);
1965 assert!(lines.len() > outside_row);
1966
1967 // Inside row must contain at least one non-blank braille glyph.
1968 let inside: String = lines[inside_row].iter().map(|(s, _)| s.as_str()).collect();
1969 assert!(
1970 inside.chars().any(|c| c != '\u{2800}' && c != ' '),
1971 "expected filled glyphs inside triangle, got: {inside:?}"
1972 );
1973 }
1974
1975 /// Tall triangles previously allocated O(H) Vecs; the new path must
1976 /// still produce filled output for many scanlines without panicking.
1977 #[test]
1978 fn filled_triangle_handles_tall_triangle_without_panic() {
1979 let mut canvas = CanvasContext::new(8, 50);
1980 canvas.filled_triangle(0, 0, 15, 0, 8, 199);
1981 let lines = canvas.render();
1982 assert_eq!(lines.len(), 50);
1983 }
1984
1985 /// Degenerate horizontal triangle (all three vertices on the same row)
1986 /// must not panic and must produce no fill (only the outline edges).
1987 #[test]
1988 fn filled_triangle_degenerate_horizontal_is_safe() {
1989 let mut canvas = CanvasContext::new(20, 20);
1990 canvas.filled_triangle(0, 0, 10, 0, 19, 0);
1991 let _ = canvas.render();
1992 }
1993
1994 // -- #146: integer isqrt for filled_circle -------------------------
1995
1996 #[test]
1997 fn isqrt_i64_matches_floor_sqrt_for_small_values() {
1998 for n in 0i64..=10_000 {
1999 let expected = (n as f64).sqrt().floor() as isize;
2000 assert_eq!(isqrt_i64(n), expected, "mismatch at n={n}");
2001 }
2002 }
2003
2004 #[test]
2005 fn isqrt_i64_handles_perfect_squares_and_boundaries() {
2006 for k in 0i64..=4096 {
2007 assert_eq!(isqrt_i64(k * k), k as isize);
2008 if k > 0 {
2009 assert_eq!(isqrt_i64(k * k - 1), (k - 1) as isize);
2010 }
2011 }
2012 }
2013
2014 #[test]
2015 fn isqrt_i64_clamps_non_positive_to_zero() {
2016 assert_eq!(isqrt_i64(0), 0);
2017 assert_eq!(isqrt_i64(-1), 0);
2018 assert_eq!(isqrt_i64(i64::MIN), 0);
2019 }
2020
2021 /// `filled_circle` should produce a symmetric span around its center
2022 /// after switching from f64 sqrt to integer isqrt.
2023 #[test]
2024 fn filled_circle_renders_without_panic_and_is_non_empty() {
2025 let mut canvas = CanvasContext::new(20, 20);
2026 canvas.filled_circle(10, 10, 6);
2027 let lines = canvas.render();
2028 let any_filled = lines
2029 .iter()
2030 .flatten()
2031 .any(|(s, _)| s.chars().any(|c| c != '\u{2800}' && c != ' '));
2032 assert!(any_filled, "filled_circle produced empty output");
2033 }
2034
2035 // -- #149: scroll_offset visibility (compile-time check) -----------
2036
2037 /// The `scroll_offset` helper must remain callable from inside the crate.
2038 /// It is `#[doc(hidden)] pub` (Option B from the issue) so it is removed
2039 /// from rustdoc but still semver-tracked; this test compiles only when
2040 /// the path is reachable.
2041 #[test]
2042 fn scroll_offset_is_crate_internal_api() {
2043 let _ = ContainerBuilder::scroll_offset;
2044 }
2045}
2046
2047#[cfg(test)]
2048mod flex_wrap_tests {
2049 //! Render-level regression tests for flex-wrap / flex-basis (#258).
2050
2051 use crate::test_utils::TestBackend;
2052
2053 /// A wrapping row of labels wider than the backend must flow the
2054 /// overflowing label onto the second terminal row, not clip it off the
2055 /// right edge. Each label is a 1-cell-tall text node, so a line is one
2056 /// cell tall and a wrap is visible as text on row 1.
2057 #[test]
2058 fn wrap_row_flows_overflow_to_second_line() {
2059 // Backend is 12 wide. `col_gap(1)` sets within-line spacing only, so
2060 // the cross-axis (between-line) gap falls back to 0. "alpha"(5) + 1 +
2061 // "bravo"(5) = 11 fits line 0; "gamma" overflows (11 + 1 + 5 = 17 >
2062 // 12) to line 1, immediately below with no blank gap row.
2063 let mut tb = TestBackend::new(12, 4);
2064 tb.render(|ui| {
2065 let _ = ui.container().wrap().col_gap(1).row(|ui| {
2066 ui.text("alpha");
2067 ui.text("bravo");
2068 ui.text("gamma");
2069 });
2070 });
2071
2072 // Line 0 holds the first two labels; the third wrapped to line 1.
2073 tb.assert_line_contains(0, "alpha");
2074 tb.assert_line_contains(0, "bravo");
2075 tb.assert_line_contains(1, "gamma");
2076 }
2077
2078 /// `wrap()` is opt-in: without it the overflowing label clips off the
2079 /// right edge rather than wrapping, so nothing appears on row 1.
2080 #[test]
2081 fn no_wrap_row_keeps_single_line() {
2082 let mut tb = TestBackend::new(12, 4);
2083 tb.render(|ui| {
2084 let _ = ui.container().col_gap(1).row(|ui| {
2085 ui.text("alpha");
2086 ui.text("bravo");
2087 ui.text("gamma");
2088 });
2089 });
2090
2091 // Single line: first label on row 0, nothing wrapped to row 1.
2092 tb.assert_line_contains(0, "alpha");
2093 assert_eq!(tb.line(1), "");
2094 }
2095}
2096
2097#[cfg(test)]
2098mod cached_region_tests {
2099 //! Issue #273 — opt-in scoped cached region.
2100 //!
2101 //! The invariant under test: `cached(key, f)` is byte-identical to an
2102 //! uncached container in EVERY case (the body always runs), and it
2103 //! correctly classifies each call site as a hit (key unchanged) or miss
2104 //! (key changed / new / first frame / post-resize) so the hit/miss
2105 //! diagnostics — and a future cell-level cache — have a sound gate.
2106
2107 use crate::event::Event;
2108 use crate::test_utils::{EventBuilder, TestBackend};
2109 use std::cell::Cell;
2110
2111 /// First frame is always a miss, output identical to a plain container.
2112 #[test]
2113 fn cached_region_byte_identical_on_first_frame() {
2114 let mut cached = TestBackend::new(40, 6);
2115 cached.render(|ui| {
2116 let _ = ui.container().cached(7, |ui| {
2117 ui.text("static chrome line one");
2118 ui.text("static chrome line two");
2119 });
2120 });
2121
2122 let mut plain = TestBackend::new(40, 6);
2123 plain.render(|ui| {
2124 let _ = ui.container().col(|ui| {
2125 ui.text("static chrome line one");
2126 ui.text("static chrome line two");
2127 });
2128 });
2129
2130 assert_eq!(
2131 cached.buffer().snapshot_format(),
2132 plain.buffer().snapshot_format(),
2133 "cached region must render byte-identically to an uncached container"
2134 );
2135 }
2136
2137 /// An unchanged key is a hit on the second frame. The body still runs
2138 /// every frame (immediate-mode invariant), so the content stays visible
2139 /// and identical — `cached` only flips the hit classification.
2140 #[test]
2141 fn cached_region_hit_on_unchanged_key_body_still_runs() {
2142 let mut tb = TestBackend::new(40, 4);
2143 let runs = Cell::new(0u32);
2144 let hits = Cell::new(0u32);
2145 let misses = Cell::new(0u32);
2146
2147 let frame = |tb: &mut TestBackend| {
2148 tb.render(|ui| {
2149 let _ = ui.container().cached(99, |ui| {
2150 runs.set(runs.get() + 1);
2151 ui.text("stable");
2152 });
2153 hits.set(ui.region_cache_hits());
2154 misses.set(ui.region_cache_misses());
2155 });
2156 };
2157
2158 frame(&mut tb);
2159 assert_eq!(runs.get(), 1, "first frame runs the body");
2160 assert_eq!(misses.get(), 1, "first frame is a miss");
2161 assert_eq!(hits.get(), 0);
2162 tb.assert_contains("stable");
2163
2164 frame(&mut tb);
2165 // Body STILL runs (byte-identical guarantee) even though the key
2166 // matched — the only observable change is the hit classification.
2167 assert_eq!(runs.get(), 2, "body re-runs every frame regardless of hit");
2168 assert_eq!(hits.get(), 1, "unchanged key on the second frame is a hit");
2169 assert_eq!(misses.get(), 0);
2170 tb.assert_contains("stable");
2171 }
2172
2173 /// A changed key is a miss and the new content renders.
2174 #[test]
2175 fn cached_region_miss_on_key_change() {
2176 let mut tb = TestBackend::new(40, 4);
2177 let hits = Cell::new(0u32);
2178 let misses = Cell::new(0u32);
2179
2180 tb.render(|ui| {
2181 let _ = ui.container().cached(1, |ui| {
2182 ui.text("first");
2183 });
2184 hits.set(ui.region_cache_hits());
2185 misses.set(ui.region_cache_misses());
2186 });
2187 assert_eq!(misses.get(), 1);
2188 tb.assert_contains("first");
2189
2190 tb.render(|ui| {
2191 let _ = ui.container().cached(2, |ui| {
2192 ui.text("second");
2193 });
2194 hits.set(ui.region_cache_hits());
2195 misses.set(ui.region_cache_misses());
2196 });
2197 assert_eq!(hits.get(), 0, "changed key is not a hit");
2198 assert_eq!(misses.get(), 1, "changed key is a miss");
2199 tb.assert_contains("second");
2200 }
2201
2202 /// A resize clears the persisted keys, forcing the next frame to miss even
2203 /// when the author passes the same key.
2204 #[test]
2205 fn cached_region_invalidates_on_resize() {
2206 let mut tb = TestBackend::new(40, 4);
2207 let hits = Cell::new(0u32);
2208
2209 tb.render(|ui| {
2210 let _ = ui.container().cached(5, |ui| {
2211 ui.text("body");
2212 });
2213 });
2214 // Second frame, same key, no resize → hit.
2215 tb.render(|ui| {
2216 let _ = ui.container().cached(5, |ui| {
2217 ui.text("body");
2218 });
2219 hits.set(ui.region_cache_hits());
2220 });
2221 assert_eq!(hits.get(), 1, "same key without resize is a hit");
2222
2223 // Now resize: the persisted region keys are cleared, so the SAME key
2224 // is treated as a fresh slot (miss) on the post-resize frame.
2225 tb.render_with_events(vec![Event::Resize(60, 8)], 0, 0, |ui| {
2226 let _ = ui.container().cached(5, |ui| {
2227 ui.text("body");
2228 });
2229 hits.set(ui.region_cache_hits());
2230 });
2231 assert_eq!(hits.get(), 0, "resize forces a cache miss for all regions");
2232 }
2233
2234 /// Focus + hit-map continuity: a button inside a cached region keeps
2235 /// firing `clicked` across cached (hit) frames because the body always
2236 /// runs, so its focusable + hit-area are re-registered every frame.
2237 #[test]
2238 fn cached_region_preserves_focus_and_hit_map() {
2239 let mut tb = TestBackend::new(30, 5);
2240 let clicked = Cell::new(false);
2241
2242 // Frame 1: register the button so its hit-area lands in the feedback
2243 // map for the next frame's click resolution. Same key both frames.
2244 tb.render(|ui| {
2245 let _ = ui.container().cached(3, |ui| {
2246 let _ = ui.button("Go");
2247 });
2248 });
2249
2250 // Frame 2: click on the button's cell — even though the region is a
2251 // cache hit, the body re-ran and re-registered the hit-area, so the
2252 // click resolves.
2253 tb.render_with_events(EventBuilder::new().click(2, 0).build(), 0, 1, |ui| {
2254 let _ = ui.container().cached(3, |ui| {
2255 let resp = ui.button("Go");
2256 if resp.clicked {
2257 clicked.set(true);
2258 }
2259 });
2260 });
2261 assert!(
2262 clicked.get(),
2263 "button inside a cached region must still receive clicks across hit frames"
2264 );
2265 }
2266
2267 /// Raw-draw inside a cached region: the deferred draw runs on every frame
2268 /// including cache-hit frames (deferred draws are one-shot per frame, and
2269 /// the body always runs, so they re-register).
2270 #[test]
2271 fn cached_region_raw_draw_replays() {
2272 let mut tb = TestBackend::new(20, 3);
2273
2274 let frame = |tb: &mut TestBackend| {
2275 tb.render(|ui| {
2276 let _ = ui.container().cached(8, |ui| {
2277 ui.container().w(5).h(1).draw(|buf, rect| {
2278 buf.set_string(rect.x, rect.y, "XXXXX", crate::style::Style::new());
2279 });
2280 });
2281 });
2282 };
2283
2284 frame(&mut tb);
2285 tb.assert_contains("XXXXX");
2286
2287 // Second frame is a cache hit, but the raw draw must still paint.
2288 frame(&mut tb);
2289 tb.assert_contains("XXXXX");
2290 }
2291
2292 /// Two adjacent cached regions get independent per-call-site slots; one
2293 /// changing its key does not disturb the other's hit classification.
2294 #[test]
2295 fn cached_regions_do_not_collide_per_call_site() {
2296 let mut tb = TestBackend::new(40, 6);
2297 let hits = Cell::new(0u32);
2298 let misses = Cell::new(0u32);
2299
2300 // Frame 1: both new → 2 misses.
2301 tb.render(|ui| {
2302 let _ = ui.container().cached(10, |ui| {
2303 ui.text("region A");
2304 });
2305 let _ = ui.container().cached(20, |ui| {
2306 ui.text("region B");
2307 });
2308 });
2309
2310 // Frame 2: A unchanged (hit), B changed (miss).
2311 tb.render(|ui| {
2312 let _ = ui.container().cached(10, |ui| {
2313 ui.text("region A");
2314 });
2315 let _ = ui.container().cached(21, |ui| {
2316 ui.text("region B2");
2317 });
2318 hits.set(ui.region_cache_hits());
2319 misses.set(ui.region_cache_misses());
2320 });
2321 assert_eq!(hits.get(), 1, "region A unchanged → exactly one hit");
2322 assert_eq!(misses.get(), 1, "region B changed → exactly one miss");
2323 tb.assert_contains("region A");
2324 tb.assert_contains("region B2");
2325 }
2326}