quadraui 0.1.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
//! `Split` primitive: a two-pane container with a draggable divider.
//! Used for editor-and-sidebar layouts, diff views, horizontal +
//! vertical window splits, and anywhere a resizable boundary between
//! two regions is needed.
//!
//! Like `Panel`, `Split` describes the frame (divider position + pane
//! rectangles) but doesn't hold pane content. Apps draw their content
//! into `first_bounds` and `second_bounds`.
//!
//! # Backend contract
//!
//! **Declarative + draggable.** The backend renders the divider at
//! `divider_bounds` and hit-tests it for drag operations via
//! [`SplitLayout::hit_test`] / [`SplitHit`]. When the user drags, the
//! app updates `ratio` on the primitive for the next frame. Clicks on
//! either pane resolve as `SplitHit::FirstPane` / `SplitHit::SecondPane`.

use crate::event::Rect;
use crate::types::WidgetId;
use serde::{Deserialize, Serialize};

/// Declarative description of a split container.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Split {
    pub id: WidgetId,
    pub direction: SplitDirection,
    /// Divider position as a fraction of the container's cross-axis
    /// length (0.0..=1.0). Clamped to a sensible range in `layout()`
    /// to keep both panes visible.
    pub ratio: f32,
    /// Minimum size of the first pane in the backend's native unit.
    /// `0.0` = no minimum.
    #[serde(default)]
    pub first_min: f32,
    /// Minimum size of the second pane. `0.0` = no minimum.
    #[serde(default)]
    pub second_min: f32,
}

/// Orientation of a `Split`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SplitDirection {
    /// Divider runs vertically; panes are side-by-side (first = left,
    /// second = right).
    Horizontal,
    /// Divider runs horizontally; panes are stacked (first = top,
    /// second = bottom).
    Vertical,
}

// ── D6 Layout API ───────────────────────────────────────────────────────────

/// Divider dimensions.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SplitMeasure {
    /// Thickness of the divider along the cross-axis (e.g. 1 char cell
    /// in TUI, 4–6 px in GTK).
    pub divider_thickness: f32,
}

impl SplitMeasure {
    pub fn new(divider_thickness: f32) -> Self {
        Self { divider_thickness }
    }
}

/// Classification of a hit-test result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SplitHit {
    /// Click landed on the first pane.
    FirstPane(WidgetId),
    /// Click landed on the second pane.
    SecondPane(WidgetId),
    /// Click landed on the divider (start of a drag operation).
    Divider(WidgetId),
    /// Click landed outside the split.
    Outside,
}

/// Fully-resolved split layout.
#[derive(Debug, Clone, PartialEq)]
pub struct SplitLayout {
    pub bounds: Rect,
    pub first_bounds: Rect,
    pub divider_bounds: Rect,
    pub second_bounds: Rect,
    pub hit_regions: Vec<(Rect, SplitHit)>,
    /// Ratio actually used (may differ from input if clamped by the
    /// min-size constraints). Apps should write this back so the next
    /// frame starts coherent.
    pub resolved_ratio: f32,
}

impl SplitLayout {
    pub fn hit_test(&self, x: f32, y: f32) -> SplitHit {
        for (rect, hit) in &self.hit_regions {
            if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
                return hit.clone();
            }
        }
        SplitHit::Outside
    }
}

impl Split {
    /// Compute pane + divider bounds.
    ///
    /// # Arguments
    ///
    /// - `bounds` — container region.
    /// - `measure` — divider thickness.
    ///
    /// # Ratio clamping
    ///
    /// The input `ratio` is clamped so both panes honour their
    /// respective `first_min` / `second_min`. If the container is too
    /// small to satisfy both minimums, the minimums are relaxed
    /// proportionally (first_min and second_min split the available
    /// space).
    pub fn layout(&self, bounds: Rect, measure: SplitMeasure) -> SplitLayout {
        let (total, cross_start) = match self.direction {
            SplitDirection::Horizontal => (bounds.width, bounds.x),
            SplitDirection::Vertical => (bounds.height, bounds.y),
        };
        let available = (total - measure.divider_thickness).max(0.0);

        // Clamp ratio to keep both panes above their minimums.
        let clamped = self.ratio.clamp(0.0, 1.0);
        let first_size_raw = available * clamped;

        let (first_size, second_size) = if self.first_min + self.second_min <= available {
            let fs = first_size_raw.max(self.first_min);
            let fs = fs.min(available - self.second_min);
            (fs, available - fs)
        } else if available > 0.0 {
            // Minimums don't fit — split proportionally to the mins.
            let total_min = self.first_min + self.second_min;
            let f = available * (self.first_min / total_min);
            (f, available - f)
        } else {
            (0.0, 0.0)
        };

        let resolved_ratio = if available > 0.0 {
            first_size / available
        } else {
            clamped
        };

        let (first_bounds, divider_bounds, second_bounds) = match self.direction {
            SplitDirection::Horizontal => (
                Rect::new(cross_start, bounds.y, first_size, bounds.height),
                Rect::new(
                    cross_start + first_size,
                    bounds.y,
                    measure.divider_thickness,
                    bounds.height,
                ),
                Rect::new(
                    cross_start + first_size + measure.divider_thickness,
                    bounds.y,
                    second_size,
                    bounds.height,
                ),
            ),
            SplitDirection::Vertical => (
                Rect::new(bounds.x, cross_start, bounds.width, first_size),
                Rect::new(
                    bounds.x,
                    cross_start + first_size,
                    bounds.width,
                    measure.divider_thickness,
                ),
                Rect::new(
                    bounds.x,
                    cross_start + first_size + measure.divider_thickness,
                    bounds.width,
                    second_size,
                ),
            ),
        };

        // Hit regions: divider first (specificity), then panes.
        let hit_regions: Vec<(Rect, SplitHit)> = vec![
            (divider_bounds, SplitHit::Divider(self.id.clone())),
            (first_bounds, SplitHit::FirstPane(self.id.clone())),
            (second_bounds, SplitHit::SecondPane(self.id.clone())),
        ];

        SplitLayout {
            bounds,
            first_bounds,
            divider_bounds,
            second_bounds,
            hit_regions,
            resolved_ratio,
        }
    }
}

// ── PaintSurface paint (#864, Phase 2d slice 7/9 of the PaintSurface
// milestone, child of #811) ────────────────────────────────────────────
//
// Before this, `gtk::draw_split` (Cairo), `macos::split::draw_split`
// (Core Graphics) and `win::split::draw_split` (Direct2D) each
// independently painted the same divider-only chrome with their own
// drawing API (quadraui#785 child #811, `docs/SMELL_AUDIT_2026-07.md`
// §5). `paint` below is the one shared implementation, written against
// [`crate::paint_surface::PaintSurface`] (#807, Phase 1) instead of
// any one backend's drawing API.
//
// Re-verified while migrating, per this issue's "re-verify before you
// implement": all three deleted copies painted exactly one filled
// rectangle — `layout.divider_bounds` in `theme.separator` — and
// nothing else (pane content is explicitly the host's job, not the
// rasteriser's, on every backend).
//
// **Divergence found and reported here (not silently resolved) — the
// same class of bug `scrollbar` (#811 slice 1) found first.** The
// deleted `gtk::draw_split` painted with `set_source`, which drops
// alpha entirely, while macOS's `CGContextSetRGBFillColor` and
// Windows's `ID2D1SolidColorBrush` both honour the real alpha byte.
// `theme.separator` is **not** guaranteed opaque: `Theme::from_vscode_json`
// (`theme.rs`) assigns it through the plain `color()` closure —
// `Color::from_hex` (`types.rs`) — which parses a real alpha byte out of
// an 8-digit `#rrggbbaa` VS Code `editorGroup.border` value instead of
// clamping it to 255 the way `color_over`/`try_from_hex_over` do for
// genuinely-composited colours. So a loaded theme CAN hand `paint()` a
// translucent `theme.separator`.
//
// `paint` above fills through `surface.surface_fill_rect`, which on GTK
// is `GtkBackend::surface_fill_rect` — already switched from
// `set_source_rgb` to `gtk::set_source_rgba` by the scrollbar migration
// (#811 slice 1; see that fn's doc). So this migration silently
// *inherits* that fix: `Backend::draw_split`'s live GTK path now
// honours the divider's alpha, where the pre-migration free function
// did not. **This is a real GTK behaviour change** for any host whose
// theme sets a translucent `editorGroup.border` — it brings GTK in line
// with macOS/Windows (the same direction as the scrollbar fix, and
// arguably desirable), but it is a divergence from the old GTK-only
// behaviour, so it's reported here rather than asserted away.
//
// The deprecated `gtk::draw_split` free-function shim's `RawSplitSurface`
// deliberately keeps calling the opaque `set_source` instead of
// `set_source_rgba`, so any external caller still holding a direct
// reference to that free function keeps getting byte-identical output
// to before this migration — only the sanctioned `Backend::draw_split`
// entry point's behaviour changed.
//
// One further difference worth naming, though it isn't a paint divergence: the
// pre-migration `win::split::draw_split` always painted with
// `Theme::default()` rather than any live theme (`WinBackend` doesn't
// carry one through to chrome rasterisers yet — see that module's old
// "# Theme" doc section, mirrored now in `win::split`'s module doc).
// `Backend::draw_split`'s Windows arm preserves that by passing
// `Theme::default()` to this fn explicitly, same as `draw_split_tree`'s
// migration did.
//
// Unlike `draw_scrollbar`/`draw_panel`, geometry (pane rects + divider
// placement) was ALREADY the single shared [`Split::layout`] before
// this issue — only the *paint* half (filling `divider_bounds`) was
// triplicated. `paint` therefore takes an already-resolved
// [`SplitLayout`] (the same convention as
// `primitives::split_tree::native_surface_paint::paint`), not the raw
// `Split` + bounds — callers compute the layout once and reuse it for
// both paint and hit-test.
//
// `#[allow(dead_code)]`: see `primitives::form`'s identical note (#808)
// — only *called* once a real pixel backend is compiled in, exercised
// by each backend's own `Backend::draw_split` call site plus this
// module's own `RecordingSurface` tests on every leg that enables one
// of the three cfg'd features.
#[cfg(any(
    feature = "gtk",
    feature = "win",
    all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
    use super::SplitLayout;
    use crate::paint_surface::PaintSurface;
    use crate::theme::Theme;

    /// Paint a [`SplitLayout`]'s divider onto `surface` as a filled
    /// rectangle in `theme.separator`. `layout` must be the same
    /// [`SplitLayout`] the caller uses for hit-testing (typically
    /// `Backend::split_layout`'s return value, or the value this fn's
    /// own caller — `Backend::draw_split` — returns) so paint and
    /// hit-test can never disagree. Pane content is NOT painted —
    /// every backend leaves `first_bounds`/`second_bounds` to the host,
    /// same contract as before this migration.
    pub(crate) fn paint(layout: &SplitLayout, surface: &mut dyn PaintSurface, theme: &Theme) {
        surface.surface_fill_rect(layout.divider_bounds, theme.separator);
    }

    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::backend::ImagePaintResult;
        use crate::event::{Rect as QRect, Viewport};
        use crate::primitives::split::{Split, SplitDirection, SplitMeasure};
        use crate::types::{Color, WidgetId};
        use crate::Image;

        /// Records every `surface_fill_rect` call — mirrors
        /// `primitives::split_tree`'s `RecordingSurface` test double,
        /// scoped to just the verb this primitive uses, so this test
        /// runs on any host without Cairo/Core Graphics/Direct2D.
        #[derive(Default)]
        struct RecordingSurface {
            fills: Vec<(QRect, Color)>,
        }

        impl PaintSurface for RecordingSurface {
            fn surface_begin_frame(&mut self, _viewport: Viewport) {}
            fn surface_end_frame(&mut self) {}
            fn surface_viewport(&self) -> Viewport {
                Viewport::new(200.0, 100.0, 1.0)
            }
            fn surface_line_height(&self) -> f32 {
                16.0
            }
            fn surface_char_width(&self) -> f32 {
                8.0
            }
            fn surface_measure_text(&self, _text: &str) -> (f32, f32) {
                (0.0, 0.0)
            }
            fn surface_fill_rect(&mut self, rect: QRect, color: Color) {
                self.fills.push((rect, color));
            }
            /// #1073: test-only recorder — `paint` never calls this verb
            /// (see this module's own doc for why no primitive here has been
            /// migrated onto it yet); this exists only so `RecordingSurface`
            /// satisfies the trait. Records into the same `fills` list as
            /// `surface_fill_rect` (radius dropped) — no test asserts on it
            /// today.
            fn surface_fill_rounded_rect(&mut self, rect: QRect, _radius: f32, color: Color) {
                self.fills.push((rect, color));
            }
            fn surface_stroke_rect(&mut self, _rect: QRect, _color: Color, _stroke_width: f32) {}
            fn surface_draw_text_run(&mut self, _rect: QRect, _text: &str, _color: Color) {}
            fn surface_draw_line(
                &mut self,
                _from: crate::Point,
                _to: crate::Point,
                _color: Color,
                _stroke_width: f32,
            ) {
            }
            fn surface_push_clip(&mut self, _rect: QRect) {}
            fn surface_pop_clip(&mut self) {}
            fn surface_draw_image(&mut self, _rect: QRect, _image: &Image) -> ImagePaintResult {
                ImagePaintResult::Unsupported
            }
        }

        fn two_pane(direction: SplitDirection) -> Split {
            Split {
                id: WidgetId::new("s"),
                direction,
                ratio: 0.5,
                first_min: 0.0,
                second_min: 0.0,
            }
        }

        #[test]
        fn paints_exactly_the_divider_rect_in_theme_separator() {
            let split = two_pane(SplitDirection::Horizontal);
            let layout = split.layout(QRect::new(0.0, 0.0, 200.0, 100.0), SplitMeasure::new(4.0));
            let theme = Theme::default();
            let mut surface = RecordingSurface::default();
            paint(&layout, &mut surface, &theme);

            assert_eq!(surface.fills.len(), 1, "split paints chrome only");
            assert_eq!(surface.fills[0], (layout.divider_bounds, theme.separator));
        }

        #[test]
        fn vertical_direction_paints_the_same_divider_bounds() {
            let split = two_pane(SplitDirection::Vertical);
            let layout = split.layout(QRect::new(0.0, 0.0, 100.0, 200.0), SplitMeasure::new(4.0));
            let theme = Theme::default();
            let mut surface = RecordingSurface::default();
            paint(&layout, &mut surface, &theme);

            assert_eq!(surface.fills.len(), 1);
            assert_eq!(surface.fills[0].0, layout.divider_bounds);
        }

        #[test]
        fn non_zero_origin_divider_bounds_are_painted_absolute() {
            let split = two_pane(SplitDirection::Horizontal);
            let layout = split.layout(QRect::new(7.0, 13.0, 200.0, 100.0), SplitMeasure::new(4.0));
            let theme = Theme::default();
            let mut surface = RecordingSurface::default();
            paint(&layout, &mut surface, &theme);

            assert_eq!(surface.fills[0].0, layout.divider_bounds);
            assert!(surface.fills[0].0.x >= 7.0);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── Split primitive tests ─────────────────────────────────────────

    #[test]
    fn split_layout_horizontal_even() {
        let s = Split {
            id: WidgetId::new("s"),
            direction: SplitDirection::Horizontal,
            ratio: 0.5,
            first_min: 0.0,
            second_min: 0.0,
        };
        let bounds = Rect::new(0.0, 0.0, 202.0, 100.0);
        let layout = s.layout(bounds, SplitMeasure::new(2.0));
        // available = 200, first = 100, second = 100.
        assert_eq!(layout.first_bounds.width, 100.0);
        assert_eq!(layout.divider_bounds.x, 100.0);
        assert_eq!(layout.divider_bounds.width, 2.0);
        assert_eq!(layout.second_bounds.x, 102.0);
        assert_eq!(layout.second_bounds.width, 100.0);
    }

    #[test]
    fn split_layout_vertical_with_min() {
        let s = Split {
            id: WidgetId::new("s"),
            direction: SplitDirection::Vertical,
            ratio: 0.1, // too small; clamped up to first_min
            first_min: 30.0,
            second_min: 20.0,
        };
        let bounds = Rect::new(0.0, 0.0, 100.0, 101.0);
        let layout = s.layout(bounds, SplitMeasure::new(1.0));
        // available = 100. Raw first = 10; clamped to first_min = 30.
        assert_eq!(layout.first_bounds.height, 30.0);
        assert_eq!(layout.second_bounds.height, 70.0);
        assert_eq!(layout.divider_bounds.y, 30.0);
    }

    #[test]
    fn split_hit_test_regions() {
        let s = Split {
            id: WidgetId::new("s"),
            direction: SplitDirection::Horizontal,
            ratio: 0.5,
            first_min: 0.0,
            second_min: 0.0,
        };
        let bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
        let layout = s.layout(bounds, SplitMeasure::new(2.0));
        // Click in first pane.
        match layout.hit_test(50.0, 50.0) {
            SplitHit::FirstPane(id) => assert_eq!(id.as_str(), "s"),
            _ => panic!(),
        }
        // Click on divider (x = 99.0..101.0).
        match layout.hit_test(99.5, 50.0) {
            SplitHit::Divider(id) => assert_eq!(id.as_str(), "s"),
            _ => panic!(),
        }
        // Click in second pane.
        match layout.hit_test(150.0, 50.0) {
            SplitHit::SecondPane(id) => assert_eq!(id.as_str(), "s"),
            _ => panic!(),
        }
    }

    #[test]
    fn split_layout_resolved_ratio() {
        let s = Split {
            id: WidgetId::new("s"),
            direction: SplitDirection::Horizontal,
            ratio: 0.0, // would collapse first pane
            first_min: 50.0,
            second_min: 0.0,
        };
        let bounds = Rect::new(0.0, 0.0, 201.0, 100.0);
        let layout = s.layout(bounds, SplitMeasure::new(1.0));
        // first_size clamped to 50; resolved_ratio = 50/200 = 0.25.
        assert!((layout.resolved_ratio - 0.25).abs() < 0.001);
    }
}