Skip to main content

khora_editor/chrome/
spine.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Spine โ€” the vertical workspace switcher on the far-left edge.
16//!
17//! The active workspace is marked in **gold**, because which workspace you are
18//! in *is* a selection โ€” the same thing the gold bar means everywhere else in
19//! the editor. (The hub's sidebar is different: it navigates between places,
20//! so it tints with the brand silver instead.)
21
22use std::sync::{Arc, Mutex};
23
24use khora_sdk::editor_ui::*;
25use khora_tool_ui::widgets::paint::{icon_centered, selection_bar};
26
27/// Width of the spine strip.
28pub const SPINE_WIDTH: f32 = 48.0;
29const BTN_SIZE: f32 = 34.0;
30
31/// The workspaces that ship today.
32const MODES: &[(EditorMode, Icon, &str)] = &[
33    (EditorMode::Scene, Icon::Cube, "Scene"),
34    (EditorMode::ControlPlane, Icon::Cpu, "Control Plane ยท DCC"),
35];
36
37/// Vertical workspace-switcher strip.
38pub struct SpinePanel {
39    state: Arc<Mutex<EditorState>>,
40    theme: UiTheme,
41}
42
43impl SpinePanel {
44    /// Creates a new spine.
45    pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
46        Self { state, theme }
47    }
48
49    fn current_mode(&self) -> EditorMode {
50        self.state
51            .lock()
52            .ok()
53            .map(|s| s.active_mode)
54            .unwrap_or_default()
55    }
56
57    fn set_mode(&self, mode: EditorMode) {
58        if let Ok(mut s) = self.state.lock() {
59            s.active_mode = mode;
60        }
61    }
62}
63
64impl EditorPanel for SpinePanel {
65    fn id(&self) -> &str {
66        "khora.editor.spine"
67    }
68
69    fn title(&self) -> &str {
70        "Spine"
71    }
72
73    fn preferred_size(&self) -> Option<f32> {
74        Some(SPINE_WIDTH)
75    }
76
77    fn ui(&mut self, ui: &mut dyn UiBuilder) {
78        let theme = &self.theme;
79        let [px, py, pw, ph] = ui.panel_rect();
80
81        ui.paint_rect_filled([px, py], [pw, ph], theme.surface, 0.0);
82        ui.paint_line([px + pw, py], [px + pw, py + ph], theme.border, 1.0);
83
84        // Brand mark โ€” the diamond, no plate around it. The spine is chrome;
85        // it should recede, not compete with the work.
86        let cx = px + pw * 0.5;
87        khora_tool_ui::widgets::diamond(ui, [cx, py + 22.0], 18.0, theme.primary);
88
89        // Mode buttons.
90        let current = self.current_mode();
91        let mut y = py + 48.0;
92
93        for (mode, icon, tooltip) in MODES {
94            let bx = px + (pw - BTN_SIZE) * 0.5;
95            let rect = [bx, y, BTN_SIZE, BTN_SIZE];
96            let active = *mode == current;
97
98            let hit = ui.interact_rect(&format!("spine-{tooltip}"), rect);
99
100            if active || hit.hovered {
101                ui.paint_rect_filled(
102                    [rect[0], rect[1]],
103                    [rect[2], rect[3]],
104                    theme.surface_interactive,
105                    theme.radius_md,
106                );
107            }
108            if active {
109                // The gold bar sits in the gutter, left of the button.
110                selection_bar(ui, [px + 1.0, y, 2.0, BTN_SIZE], theme.accent_c);
111            }
112
113            let color = if active {
114                theme.accent_c
115            } else if hit.hovered {
116                theme.text
117            } else {
118                theme.text_muted
119            };
120            icon_centered(ui, rect, *icon, 17.0, color);
121
122            if hit.hovered {
123                ui.tooltip_for_last(tooltip);
124            }
125            if hit.clicked {
126                self.set_mode(*mode);
127            }
128
129            y += BTN_SIZE + 4.0;
130        }
131    }
132}