Another project
1use bone_ui::frame::FrameCtx;
2use bone_ui::hotkey::{ActionId, HotkeyScope};
3use bone_ui::input::{KeyCode, ModifierMask, NamedKey, PointerButton};
4use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
5use bone_ui::strings::StringKey;
6use bone_ui::theme::{Border, Step12, StrokeWidth};
7use bone_ui::widgets::{
8 Button, ButtonState, ButtonVariant, LabelText, TakeKey, WidgetPaint, show_button, take_key,
9};
10use bone_ui::{WidgetId, WidgetKey};
11
12use crate::hotkeys::{
13 COMMANDS, DELETE_SELECTION_ACTION, ENTER_SKETCH_ACTION, EXTEND_ACTION, MIRROR_ACTION,
14 SELECT_ALL_ACTION, SMART_DIMENSION_ACTION, TOGGLE_CONSTRUCTION_ACTION, TRIM_ACTION,
15 ZOOM_FIT_ACTION,
16};
17use crate::strings as s;
18
19const ITEM_HEIGHT_PX: f32 = 28.0;
20const ITEM_GAP_PX: f32 = 2.0;
21const PANEL_WIDTH_PX: f32 = 220.0;
22const PANEL_PAD_PX: f32 = 6.0;
23const TITLE_HEIGHT_PX: f32 = 24.0;
24
25#[derive(Copy, Clone, Debug, PartialEq)]
26pub struct ShortcutBarState {
27 pub anchor: LayoutPos,
28}
29
30#[derive(Debug, Default)]
31pub struct ShortcutBarOutcome {
32 pub paints: Vec<WidgetPaint>,
33 pub activated: Option<ActionId>,
34 pub dismissed: bool,
35}
36
37const ITEMS: &[(ActionId, StringKey)] = &[
38 (ENTER_SKETCH_ACTION, s::HOTKEY_LABEL_SKETCH),
39 (SMART_DIMENSION_ACTION, s::HOTKEY_LABEL_SMART_DIMENSION),
40 (TRIM_ACTION, s::HOTKEY_LABEL_TRIM),
41 (EXTEND_ACTION, s::HOTKEY_LABEL_EXTEND),
42 (MIRROR_ACTION, s::HOTKEY_LABEL_MIRROR),
43 (
44 TOGGLE_CONSTRUCTION_ACTION,
45 s::HOTKEY_LABEL_CONSTRUCTION_TOGGLE,
46 ),
47 (ZOOM_FIT_ACTION, s::HOTKEY_LABEL_ZOOM_FIT),
48 (SELECT_ALL_ACTION, s::HOTKEY_LABEL_SELECT_ALL),
49 (DELETE_SELECTION_ACTION, s::HOTKEY_LABEL_DELETE_SELECTION),
50];
51
52#[must_use]
53#[allow(
54 clippy::cast_precision_loss,
55 reason = "ITEMS.len() and item indices fit in u8; f32 mantissa is fine"
56)]
57pub fn panel_rect(anchor: LayoutPos, viewport: LayoutSize) -> LayoutRect {
58 let height =
59 TITLE_HEIGHT_PX + PANEL_PAD_PX * 2.0 + ITEMS.len() as f32 * (ITEM_HEIGHT_PX + ITEM_GAP_PX)
60 - ITEM_GAP_PX;
61 let width = PANEL_WIDTH_PX;
62 let max_x = (viewport.width.value() - width).max(0.0);
63 let max_y = (viewport.height.value() - height).max(0.0);
64 let x = anchor.x.value().min(max_x).max(0.0);
65 let y = anchor.y.value().min(max_y).max(0.0);
66 LayoutRect::new(
67 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
68 LayoutSize::new(LayoutPx::new(width), LayoutPx::new(height)),
69 )
70}
71
72#[must_use]
73#[allow(
74 clippy::cast_precision_loss,
75 reason = "ITEMS.len() and item indices fit in u8; f32 mantissa is fine"
76)]
77pub fn render(
78 ctx: &mut FrameCtx<'_>,
79 state: ShortcutBarState,
80 viewport: LayoutSize,
81 is_sketch: bool,
82) -> ShortcutBarOutcome {
83 let rect = panel_rect(state.anchor, viewport);
84 let theme = ctx.theme();
85 let radius = theme.radius.sm;
86 let surface_fill = theme.colors.neutral.step(Step12::APP_BG);
87 let border = Border {
88 width: StrokeWidth::HAIRLINE,
89 color: theme.colors.neutral.step(Step12::BORDER),
90 };
91 let elevation = theme.elevation.level3;
92 let mut paints = vec![
93 WidgetPaint::Surface {
94 rect,
95 fill: surface_fill,
96 border: Some(border),
97 radius,
98 elevation: Some(elevation),
99 },
100 WidgetPaint::Label {
101 rect: LayoutRect::new(
102 LayoutPos::new(
103 LayoutPx::new(rect.origin.x.value() + PANEL_PAD_PX),
104 LayoutPx::new(rect.origin.y.value() + PANEL_PAD_PX),
105 ),
106 LayoutSize::new(
107 LayoutPx::new(rect.size.width.value() - PANEL_PAD_PX * 2.0),
108 LayoutPx::new(TITLE_HEIGHT_PX),
109 ),
110 ),
111 text: LabelText::Key(s::SHORTCUT_BAR_TITLE),
112 color: theme.colors.text_primary(),
113 role: theme.typography.label,
114 },
115 ];
116 let root_id = WidgetId::ROOT.child(WidgetKey::new("shortcut_bar"));
117 let row_origin_y = rect.origin.y.value() + PANEL_PAD_PX + TITLE_HEIGHT_PX;
118 let activated = ITEMS
119 .iter()
120 .enumerate()
121 .fold(None, |found, (i, (action, label))| {
122 let y = row_origin_y + i as f32 * (ITEM_HEIGHT_PX + ITEM_GAP_PX);
123 let row_rect = LayoutRect::new(
124 LayoutPos::new(
125 LayoutPx::new(rect.origin.x.value() + PANEL_PAD_PX),
126 LayoutPx::new(y),
127 ),
128 LayoutSize::new(
129 LayoutPx::new(rect.size.width.value() - PANEL_PAD_PX * 2.0),
130 LayoutPx::new(ITEM_HEIGHT_PX),
131 ),
132 );
133 let id = root_id.child_indexed(WidgetKey::new("item"), u64::from(action.get().get()));
134 let item_state = if item_enabled(*action, is_sketch) {
135 ButtonState::Idle
136 } else {
137 ButtonState::Disabled
138 };
139 let response = show_button(
140 ctx,
141 Button::new(id, row_rect, *label, ButtonVariant::Secondary).with_state(item_state),
142 );
143 paints.extend(response.paint);
144 found.or(response.activated.then_some(*action))
145 });
146 let esc = TakeKey::new(KeyCode::Named(NamedKey::Escape), ModifierMask::NONE);
147 let esc_dismiss = take_key(ctx.input, &[esc]).is_some();
148 let outside_click = ctx.input.buttons_pressed.contains(PointerButton::Primary)
149 && ctx
150 .input
151 .pointer
152 .is_some_and(|sample| !rect.contains(sample.position));
153 if outside_click {
154 ctx.input.buttons_pressed = ctx.input.buttons_pressed.without(PointerButton::Primary);
155 }
156 ShortcutBarOutcome {
157 paints,
158 activated,
159 dismissed: esc_dismiss || outside_click,
160 }
161}
162
163fn item_enabled(action: ActionId, is_sketch: bool) -> bool {
164 if action == ENTER_SKETCH_ACTION {
165 return !is_sketch;
166 }
167 let Some(scope) = COMMANDS
168 .iter()
169 .find(|c| c.action == action)
170 .map(|c| c.scope)
171 else {
172 return true;
173 };
174 !matches!(scope, HotkeyScope::Sketch) || is_sketch
175}