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
//! `CommandCenter` primitive: a horizontal strip with back/forward nav
//! arrows and a clickable search box. Lives in the menu bar row,
//! centered between the menu labels and any trailing chrome (window
//! controls, etc.).
use crate::event::Rect;
use crate::types::WidgetId;
use serde::{Deserialize, Serialize};
/// Declarative description of a command center strip.
///
/// Stays a plain, non-`#[non_exhaustive]` struct: a published consumer
/// builds it with an exhaustive struct literal today, so marking it
/// `#[non_exhaustive]` would break that consumer's build. `Default` is
/// still provided for consumers that want the
/// `Default::default()`-plus-field-assignment pattern.
///
/// # Examples
///
/// ```
/// use quadraui::{CommandCenter, CommandCenterHit, CommandCenterMeasure, Rect, WidgetId};
///
/// let mut center = CommandCenter::default();
/// center.id = WidgetId::new("command_center:main");
/// center.back_enabled = true;
/// center.search_label = "my-project".to_string();
///
/// let bounds = Rect::new(0.0, 0.0, 400.0, 1.0);
/// let measure = CommandCenterMeasure::from_char_width(¢er.search_label, 8.0, 1.0);
/// let layout = center.layout(bounds, measure);
///
/// let back = layout.back_bounds.expect("back arrow is always reserved");
/// assert_eq!(layout.hit_test(back.x, back.y), CommandCenterHit::Back);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandCenter {
pub id: WidgetId,
pub back_enabled: bool,
pub forward_enabled: bool,
/// Text shown inside the search box (e.g. "🔍 project-name").
/// Empty string hides the search box entirely.
#[serde(default)]
pub search_label: String,
}
impl Default for CommandCenter {
/// An empty-id command center with both nav arrows disabled and no
/// search box. Set fields directly afterwards (every field is
/// `pub`) — see `docs/PRIMITIVE_RULES.md` rule 8.
fn default() -> Self {
CommandCenter {
id: WidgetId::new(""),
back_enabled: false,
forward_enabled: false,
search_label: String::new(),
}
}
}
/// Measurement for a `CommandCenter`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CommandCenterMeasure {
/// Width of each nav arrow slot.
pub arrow_width: f32,
/// Gap between arrows and between arrow group and search box.
pub gap: f32,
/// Width of the search box. `0.0` when `search_label` is empty.
pub search_box_width: f32,
/// Height of the command center (matches the row).
pub height: f32,
}
/// Classification of a hit-test result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandCenterHit {
Back,
Forward,
SearchBox,
Bar,
Outside,
}
/// Fully-resolved command-center layout.
#[derive(Debug, Clone, PartialEq)]
pub struct CommandCenterLayout {
pub bounds: Rect,
pub back_bounds: Option<Rect>,
pub forward_bounds: Option<Rect>,
pub search_bounds: Option<Rect>,
pub hit_regions: Vec<(Rect, CommandCenterHit)>,
}
impl CommandCenterLayout {
/// A layout for an area the rasteriser did not (or could not) paint
/// into: no hit regions at all, so [`Self::hit_test`] returns
/// [`CommandCenterHit::Outside`] for every point. Use this instead of
/// [`CommandCenter::layout`]'s geometric result whenever the paint
/// loop skips the widget entirely — a `width == 0` / `height == 0`
/// area, for instance — so the returned layout never describes cells
/// that were never actually drawn.
pub fn empty(bounds: Rect) -> Self {
CommandCenterLayout {
bounds,
back_bounds: None,
forward_bounds: None,
search_bounds: None,
hit_regions: Vec::new(),
}
}
pub fn hit_test(&self, x: f32, y: f32) -> CommandCenterHit {
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();
}
}
CommandCenterHit::Outside
}
}
impl CommandCenterMeasure {
/// Standard nav-arrow width (px/DIPs) for every pixel backend that
/// measures the search box from a plain `char_width` average rather
/// than a live font layout — see [`Self::from_char_width`]. Matches
/// the value `gtk_command_center_layout` / `mac_command_center_layout`
/// use for their own (pixel-exact) measurement, so a command center
/// looks the same size across backends regardless of which
/// measurement path produced its layout.
pub const ARROW_WIDTH_PX: f32 = 24.0;
/// Gap (px/DIPs) between arrows and between the arrow group and the
/// search box. See [`Self::ARROW_WIDTH_PX`].
pub const GAP_PX: f32 = 8.0;
/// Horizontal padding added on top of the estimated search-label
/// width, in px/DIPs. See [`Self::from_char_width`].
const SEARCH_H_PAD_PX: f32 = 16.0;
/// Minimum search-box width (px/DIPs), regardless of how short the
/// label is. See [`Self::from_char_width`].
const SEARCH_MIN_WIDTH_PX: f32 = 280.0;
/// Build a [`CommandCenterMeasure`] from a plain average `char_width`
/// rather than a live per-glyph text layout — the shape a backend
/// needs when it has only a `char_width` number on hand (e.g. a
/// `*_layout` query made outside a frame, with no live font context
/// to measure against).
///
/// Search-box width is `search_label.len() as f32 * char_width +
/// 16.0`, floored at `280.0`, or `0.0` when `search_label` is empty
/// (hides the search box — see [`CommandCenter::search_label`]'s
/// doc). This formula is shared by all backends that measure search
/// width from a plain `char_width` average, so they all compute the
/// same value for identical inputs.
///
/// Distinct from `gtk_command_center_layout` / `mac_command_center_layout`
/// (the *pixel-exact* measurement path used when painting, which lays
/// the label out against a live Pango/Core Text font and reads back
/// its real width) — this constructor is the cheaper approximation
/// for callers without one.
pub fn from_char_width(search_label: &str, char_width: f32, height: f32) -> Self {
let search_box_width = if search_label.is_empty() {
0.0
} else {
(search_label.len() as f32 * char_width + Self::SEARCH_H_PAD_PX)
.max(Self::SEARCH_MIN_WIDTH_PX)
};
CommandCenterMeasure {
arrow_width: Self::ARROW_WIDTH_PX,
gap: Self::GAP_PX,
search_box_width,
height,
}
}
}
impl CommandCenter {
/// Compute layout. The entire command center is centered within `bounds`.
pub fn layout(&self, bounds: Rect, measure: CommandCenterMeasure) -> CommandCenterLayout {
let content_width = measure.arrow_width * 2.0
+ measure.gap
+ if measure.search_box_width > 0.0 {
measure.gap + measure.search_box_width
} else {
0.0
};
let center_x = bounds.x + (bounds.width - content_width).max(0.0) / 2.0;
let y = bounds.y;
let h = measure.height;
let back_rect = Rect::new(center_x, y, measure.arrow_width, h);
let fwd_rect = Rect::new(
center_x + measure.arrow_width + measure.gap,
y,
measure.arrow_width,
h,
);
let search_rect = if measure.search_box_width > 0.0 {
Some(Rect::new(
fwd_rect.x + fwd_rect.width + measure.gap,
y,
measure.search_box_width,
h,
))
} else {
None
};
let mut hit_regions = Vec::new();
hit_regions.push((back_rect, CommandCenterHit::Back));
hit_regions.push((fwd_rect, CommandCenterHit::Forward));
if let Some(sb) = search_rect {
hit_regions.push((sb, CommandCenterHit::SearchBox));
}
hit_regions.push((bounds, CommandCenterHit::Bar));
CommandCenterLayout {
bounds,
back_bounds: Some(back_rect),
forward_bounds: Some(fwd_rect),
search_bounds: search_rect,
hit_regions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Shared char-width measure formula ──────────────────────────────
#[test]
fn from_char_width_empty_label_hides_search_box() {
let m = CommandCenterMeasure::from_char_width("", 8.0, 24.0);
assert_eq!(m.search_box_width, 0.0);
assert_eq!(m.arrow_width, CommandCenterMeasure::ARROW_WIDTH_PX);
assert_eq!(m.gap, CommandCenterMeasure::GAP_PX);
assert_eq!(m.height, 24.0);
}
#[test]
fn from_char_width_short_label_floors_at_min_width() {
// "hi" is nowhere near wide enough to clear the 280px floor at
// any sane char width.
let m = CommandCenterMeasure::from_char_width("hi", 8.0, 24.0);
assert_eq!(m.search_box_width, 280.0);
}
#[test]
fn from_char_width_long_label_exceeds_min_width() {
// 40 chars * 8.0 + 16.0 = 336.0, comfortably past the 280 floor —
// pins the exact formula, not just the floor clamp above.
let label = "x".repeat(40);
let m = CommandCenterMeasure::from_char_width(&label, 8.0, 24.0);
assert_eq!(m.search_box_width, 40.0 * 8.0 + 16.0);
}
/// All backends that measure search width from a plain `char_width`
/// average compute this value for identical inputs through this single
/// constructor, ensuring they agree on the search-box width.
#[test]
fn from_char_width_is_deterministic_for_the_same_inputs() {
let label = "project-name";
let a = CommandCenterMeasure::from_char_width(label, 7.5, 20.0);
let b = CommandCenterMeasure::from_char_width(label, 7.5, 20.0);
assert_eq!(a, b);
}
#[test]
fn from_char_width_feeds_into_layout_consistently() {
let cc = CommandCenter {
id: WidgetId::new("cc"),
back_enabled: true,
forward_enabled: true,
search_label: "project".into(),
};
let measure = CommandCenterMeasure::from_char_width(&cc.search_label, 8.0, 24.0);
let layout = cc.layout(Rect::new(0.0, 0.0, 500.0, 24.0), measure);
let search = layout
.search_bounds
.expect("non-empty label has search bounds");
assert_eq!(search.width, measure.search_box_width);
}
}