Skip to main content

oxihuman_viewer/
split_view.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split/multi-viewport viewer with configurable layouts.
5
6/// Layout mode for the split view.
7#[allow(dead_code)]
8#[derive(Clone, PartialEq, Debug)]
9pub enum SplitLayout {
10    Single,
11    Horizontal,
12    Vertical,
13    Quad,
14}
15
16/// A single viewport pane with pixel-space rectangle.
17#[allow(dead_code)]
18#[derive(Clone, Debug)]
19pub struct ViewportPane {
20    pub x: f32,
21    pub y: f32,
22    pub width: f32,
23    pub height: f32,
24    pub label: String,
25}
26
27/// Configuration for the split view system.
28#[allow(dead_code)]
29pub struct SplitViewConfig {
30    pub layout: SplitLayout,
31    pub viewport_width: f32,
32    pub viewport_height: f32,
33    pub split_ratio: f32,
34    pub active_pane: usize,
35    pub maximized_pane: Option<usize>,
36    pub gap: f32,
37}
38
39/// Type alias for pane rectangle [x, y, w, h].
40#[allow(dead_code)]
41pub type PaneRect = [f32; 4];
42
43#[allow(dead_code)]
44pub fn default_split_view_config(width: f32, height: f32) -> SplitViewConfig {
45    SplitViewConfig {
46        layout: SplitLayout::Single,
47        viewport_width: width,
48        viewport_height: height,
49        split_ratio: 0.5,
50        active_pane: 0,
51        maximized_pane: None,
52        gap: 2.0,
53    }
54}
55
56#[allow(dead_code)]
57pub fn new_split_view(width: f32, height: f32, layout: SplitLayout) -> SplitViewConfig {
58    SplitViewConfig {
59        layout,
60        viewport_width: width,
61        viewport_height: height,
62        split_ratio: 0.5,
63        active_pane: 0,
64        maximized_pane: None,
65        gap: 2.0,
66    }
67}
68
69#[allow(dead_code)]
70pub fn set_layout(config: &mut SplitViewConfig, layout: SplitLayout) {
71    config.layout = layout;
72    config.active_pane = 0;
73    config.maximized_pane = None;
74}
75
76#[allow(dead_code)]
77pub fn pane_count(config: &SplitViewConfig) -> usize {
78    match config.layout {
79        SplitLayout::Single => 1,
80        SplitLayout::Horizontal | SplitLayout::Vertical => 2,
81        SplitLayout::Quad => 4,
82    }
83}
84
85/// Compute pixel rectangle for a given pane index.
86#[allow(dead_code)]
87pub fn pane_rect(config: &SplitViewConfig, index: usize) -> PaneRect {
88    // If a pane is maximized, only that pane gets the full viewport
89    if let Some(max_idx) = config.maximized_pane {
90        if index == max_idx {
91            return [0.0, 0.0, config.viewport_width, config.viewport_height];
92        } else {
93            return [0.0, 0.0, 0.0, 0.0];
94        }
95    }
96
97    let w = config.viewport_width;
98    let h = config.viewport_height;
99    let r = config.split_ratio;
100    let g = config.gap;
101    let half_gap = g * 0.5;
102
103    match config.layout {
104        SplitLayout::Single => [0.0, 0.0, w, h],
105        SplitLayout::Horizontal => {
106            let split_x = w * r;
107            match index {
108                0 => [0.0, 0.0, split_x - half_gap, h],
109                1 => [split_x + half_gap, 0.0, w - split_x - half_gap, h],
110                _ => [0.0, 0.0, 0.0, 0.0],
111            }
112        }
113        SplitLayout::Vertical => {
114            let split_y = h * r;
115            match index {
116                0 => [0.0, 0.0, w, split_y - half_gap],
117                1 => [0.0, split_y + half_gap, w, h - split_y - half_gap],
118                _ => [0.0, 0.0, 0.0, 0.0],
119            }
120        }
121        SplitLayout::Quad => {
122            let split_x = w * r;
123            let split_y = h * r;
124            match index {
125                0 => [0.0, 0.0, split_x - half_gap, split_y - half_gap],
126                1 => [
127                    split_x + half_gap,
128                    0.0,
129                    w - split_x - half_gap,
130                    split_y - half_gap,
131                ],
132                2 => [
133                    0.0,
134                    split_y + half_gap,
135                    split_x - half_gap,
136                    h - split_y - half_gap,
137                ],
138                3 => [
139                    split_x + half_gap,
140                    split_y + half_gap,
141                    w - split_x - half_gap,
142                    h - split_y - half_gap,
143                ],
144                _ => [0.0, 0.0, 0.0, 0.0],
145            }
146        }
147    }
148}
149
150#[allow(dead_code)]
151pub fn active_pane(config: &SplitViewConfig) -> usize {
152    config.active_pane
153}
154
155#[allow(dead_code)]
156pub fn set_active_pane(config: &mut SplitViewConfig, index: usize) {
157    if index < pane_count(config) {
158        config.active_pane = index;
159    }
160}
161
162/// Move the divider by changing the split ratio.
163#[allow(dead_code)]
164pub fn resize_split(config: &mut SplitViewConfig, ratio: f32) {
165    config.split_ratio = ratio.clamp(0.1, 0.9);
166}
167
168#[allow(dead_code)]
169pub fn split_ratio(config: &SplitViewConfig) -> f32 {
170    config.split_ratio
171}
172
173/// Find which pane a screen position falls in.
174#[allow(dead_code)]
175pub fn pane_at_position(config: &SplitViewConfig, x: f32, y: f32) -> Option<usize> {
176    let count = pane_count(config);
177    for i in 0..count {
178        let rect = pane_rect(config, i);
179        let rx = rect[0];
180        let ry = rect[1];
181        let rw = rect[2];
182        let rh = rect[3];
183        if x >= rx && x < rx + rw && y >= ry && y < ry + rh {
184            return Some(i);
185        }
186    }
187    None
188}
189
190/// Human-readable name of the layout.
191#[allow(dead_code)]
192pub fn layout_name(config: &SplitViewConfig) -> &'static str {
193    match config.layout {
194        SplitLayout::Single => "Single",
195        SplitLayout::Horizontal => "Horizontal",
196        SplitLayout::Vertical => "Vertical",
197        SplitLayout::Quad => "Quad",
198    }
199}
200
201/// Serialize split view config to a JSON string.
202#[allow(dead_code)]
203pub fn split_view_to_json(config: &SplitViewConfig) -> String {
204    let layout_str = match config.layout {
205        SplitLayout::Single => "single",
206        SplitLayout::Horizontal => "horizontal",
207        SplitLayout::Vertical => "vertical",
208        SplitLayout::Quad => "quad",
209    };
210    let max_str = match config.maximized_pane {
211        Some(idx) => format!("{}", idx),
212        None => "null".to_string(),
213    };
214    format!(
215        "{{\"layout\":\"{}\",\"viewport_width\":{},\"viewport_height\":{},\"split_ratio\":{},\"active_pane\":{},\"maximized_pane\":{},\"gap\":{}}}",
216        layout_str, config.viewport_width, config.viewport_height,
217        config.split_ratio, config.active_pane, max_str, config.gap
218    )
219}
220
221/// Toggle maximize for a pane. If already maximized, restore.
222#[allow(dead_code)]
223pub fn toggle_maximize_pane(config: &mut SplitViewConfig, index: usize) {
224    if config.maximized_pane == Some(index) {
225        config.maximized_pane = None;
226    } else if index < pane_count(config) {
227        config.maximized_pane = Some(index);
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_default_config() {
237        let cfg = default_split_view_config(800.0, 600.0);
238        assert_eq!(cfg.layout, SplitLayout::Single);
239        assert!((cfg.viewport_width - 800.0).abs() < f32::EPSILON);
240        assert!((cfg.viewport_height - 600.0).abs() < f32::EPSILON);
241        assert!((cfg.split_ratio - 0.5).abs() < f32::EPSILON);
242    }
243
244    #[test]
245    fn test_new_split_view() {
246        let cfg = new_split_view(1024.0, 768.0, SplitLayout::Horizontal);
247        assert_eq!(cfg.layout, SplitLayout::Horizontal);
248        assert_eq!(cfg.active_pane, 0);
249    }
250
251    #[test]
252    fn test_set_layout() {
253        let mut cfg = default_split_view_config(800.0, 600.0);
254        set_layout(&mut cfg, SplitLayout::Quad);
255        assert_eq!(cfg.layout, SplitLayout::Quad);
256        assert_eq!(cfg.active_pane, 0);
257    }
258
259    #[test]
260    fn test_pane_count_single() {
261        let cfg = default_split_view_config(800.0, 600.0);
262        assert_eq!(pane_count(&cfg), 1);
263    }
264
265    #[test]
266    fn test_pane_count_horizontal() {
267        let cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
268        assert_eq!(pane_count(&cfg), 2);
269    }
270
271    #[test]
272    fn test_pane_count_quad() {
273        let cfg = new_split_view(800.0, 600.0, SplitLayout::Quad);
274        assert_eq!(pane_count(&cfg), 4);
275    }
276
277    #[test]
278    fn test_pane_rect_single() {
279        let cfg = default_split_view_config(800.0, 600.0);
280        let rect = pane_rect(&cfg, 0);
281        assert!((rect[0] - 0.0).abs() < f32::EPSILON);
282        assert!((rect[1] - 0.0).abs() < f32::EPSILON);
283        assert!((rect[2] - 800.0).abs() < f32::EPSILON);
284        assert!((rect[3] - 600.0).abs() < f32::EPSILON);
285    }
286
287    #[test]
288    fn test_pane_rect_horizontal() {
289        let cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
290        let r0 = pane_rect(&cfg, 0);
291        let r1 = pane_rect(&cfg, 1);
292        assert!(r0[2] > 0.0);
293        assert!(r1[2] > 0.0);
294        // Together they should roughly span the viewport width
295        assert!((r0[2] + r1[2] + cfg.gap - 800.0).abs() < 1.0);
296    }
297
298    #[test]
299    fn test_active_pane() {
300        let cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
301        assert_eq!(active_pane(&cfg), 0);
302    }
303
304    #[test]
305    fn test_set_active_pane() {
306        let mut cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
307        set_active_pane(&mut cfg, 1);
308        assert_eq!(active_pane(&cfg), 1);
309    }
310
311    #[test]
312    fn test_set_active_pane_out_of_range() {
313        let mut cfg = default_split_view_config(800.0, 600.0);
314        set_active_pane(&mut cfg, 5);
315        assert_eq!(active_pane(&cfg), 0); // unchanged
316    }
317
318    #[test]
319    fn test_resize_split() {
320        let mut cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
321        resize_split(&mut cfg, 0.3);
322        assert!((split_ratio(&cfg) - 0.3).abs() < f32::EPSILON);
323    }
324
325    #[test]
326    fn test_resize_split_clamped() {
327        let mut cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
328        resize_split(&mut cfg, 0.0);
329        assert!((split_ratio(&cfg) - 0.1).abs() < f32::EPSILON);
330        resize_split(&mut cfg, 1.0);
331        assert!((split_ratio(&cfg) - 0.9).abs() < f32::EPSILON);
332    }
333
334    #[test]
335    fn test_pane_at_position_single() {
336        let cfg = default_split_view_config(800.0, 600.0);
337        assert_eq!(pane_at_position(&cfg, 400.0, 300.0), Some(0));
338    }
339
340    #[test]
341    fn test_pane_at_position_horizontal() {
342        let cfg = new_split_view(800.0, 600.0, SplitLayout::Horizontal);
343        // Left half
344        assert_eq!(pane_at_position(&cfg, 100.0, 300.0), Some(0));
345        // Right half
346        assert_eq!(pane_at_position(&cfg, 600.0, 300.0), Some(1));
347    }
348
349    #[test]
350    fn test_layout_name() {
351        let cfg = new_split_view(800.0, 600.0, SplitLayout::Vertical);
352        assert_eq!(layout_name(&cfg), "Vertical");
353    }
354
355    #[test]
356    fn test_split_view_to_json() {
357        let cfg = default_split_view_config(800.0, 600.0);
358        let json = split_view_to_json(&cfg);
359        assert!(json.contains("\"layout\":\"single\""));
360        assert!(json.contains("\"maximized_pane\":null"));
361    }
362
363    #[test]
364    fn test_toggle_maximize_pane() {
365        let mut cfg = new_split_view(800.0, 600.0, SplitLayout::Quad);
366        toggle_maximize_pane(&mut cfg, 2);
367        assert_eq!(cfg.maximized_pane, Some(2));
368        // Maximized pane gets full rect
369        let rect = pane_rect(&cfg, 2);
370        assert!((rect[2] - 800.0).abs() < f32::EPSILON);
371        // Other pane gets zero rect
372        let rect0 = pane_rect(&cfg, 0);
373        assert!((rect0[2] - 0.0).abs() < f32::EPSILON);
374        // Toggle again to restore
375        toggle_maximize_pane(&mut cfg, 2);
376        assert_eq!(cfg.maximized_pane, None);
377    }
378
379    #[test]
380    fn test_pane_at_position_out_of_bounds() {
381        let cfg = default_split_view_config(800.0, 600.0);
382        assert_eq!(pane_at_position(&cfg, -10.0, -10.0), None);
383    }
384}