tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 124Markdown 82TOML 78Shell 12149 Definitions

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/shortcuts-help/src/lib.rs Rust · 282 lines · 8.4 KB History Who touched what raw

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

1//! `llimphi-widget-shortcuts-help` — overlay de atajos de teclado.
2//!
3//! Convención "press ? for help": cuando el usuario aprieta `?`,
4//! aparece un panel centrado con todos los atajos del contexto actual
5//! agrupados por categoría. Cualquier tecla cierra (la app maneja eso).
6//!
7//! La app construye un `ShortcutsHelpSpec` con grupos y entries, lo
8//! guarda en su modelo cuando se abre, y lo devuelve desde
9//! `view_overlay`.
10
11#![forbid(unsafe_code)]
12
13use llimphi_ui::llimphi_layout::taffy::{
14    prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
15    AlignItems, JustifyContent, Rect,
16};
17use llimphi_ui::llimphi_raster::peniko::Color;
18use llimphi_ui::llimphi_text::Alignment;
19use llimphi_ui::View;
20use llimphi_theme::{alpha, radius, Theme};
21use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
22
23/// Paleta del overlay.
24#[derive(Debug, Clone, Copy)]
25pub struct ShortcutsHelpPalette {
26    pub scrim: Color,
27    /// Firma del panel (gradient + hairline accent en top edge).
28    pub panel: PanelStyle,
29    pub border: Color,
30    pub fg_title: Color,
31    pub fg_group: Color,
32    pub fg_desc: Color,
33    pub fg_key: Color,
34    pub bg_key: Color,
35}
36
37impl ShortcutsHelpPalette {
38    pub fn from_theme(t: &Theme) -> Self {
39        Self {
40            scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM),
41            panel: PanelStyle::from_theme_large(t),
42            border: t.border,
43            fg_title: t.fg_text,
44            fg_group: t.accent,
45            fg_desc: t.fg_text,
46            fg_key: t.fg_text,
47            bg_key: t.bg_button,
48        }
49    }
50}
51
52/// Una entrada de atajo: combinación de teclas + descripción de qué hace.
53#[derive(Debug, Clone)]
54pub struct ShortcutEntry {
55    /// La combinación tal como aparece (ej. `"Ctrl+S"`, `"⌘K ⌘P"`, `"?"`).
56    pub keys: String,
57    pub description: String,
58}
59
60impl ShortcutEntry {
61    pub fn new(keys: impl Into<String>, description: impl Into<String>) -> Self {
62        Self { keys: keys.into(), description: description.into() }
63    }
64}
65
66/// Grupo de atajos con un título (ej. "Edición", "Navegación").
67#[derive(Debug, Clone)]
68pub struct ShortcutGroup {
69    pub title: String,
70    pub entries: Vec<ShortcutEntry>,
71}
72
73impl ShortcutGroup {
74    pub fn new(title: impl Into<String>, entries: Vec<ShortcutEntry>) -> Self {
75        Self { title: title.into(), entries }
76    }
77}
78
79/// Spec completo del overlay.
80pub struct ShortcutsHelpSpec<Msg: Clone + 'static> {
81    pub title: String,
82    pub groups: Vec<ShortcutGroup>,
83    pub viewport: (f32, f32),
84    pub on_dismiss: Msg,
85    pub palette: ShortcutsHelpPalette,
86}
87
88const PANEL_W: f32 = 480.0;
89const TITLE_FONT: f32 = 16.0;
90const GROUP_FONT: f32 = 11.5;
91const ENTRY_FONT: f32 = 12.0;
92const ENTRY_H: f32 = 22.0;
93const GROUP_H: f32 = 24.0;
94const TITLE_H: f32 = 40.0;
95const PAD: f32 = 20.0;
96
97pub fn shortcuts_help_view<Msg: Clone + 'static>(spec: ShortcutsHelpSpec<Msg>) -> View<Msg> {
98    let ShortcutsHelpSpec { title, groups, viewport, on_dismiss, palette } = spec;
99
100    // Altura del panel — suma de header + grupos.
101    let body_h: f32 = groups
102        .iter()
103        .map(|g| GROUP_H + g.entries.len() as f32 * ENTRY_H + 8.0)
104        .sum();
105    let panel_h = (TITLE_H + body_h + PAD * 2.0).min(viewport.1 - 32.0);
106    let panel_w = PANEL_W.min(viewport.0 - 32.0);
107    let x = ((viewport.0 - panel_w) * 0.5).max(0.0);
108    let y = ((viewport.1 - panel_h) * 0.5).max(0.0);
109
110    let header = View::new(Style {
111        size: Size {
112            width: percent(1.0_f32),
113            height: length(TITLE_H),
114        },
115        align_items: Some(AlignItems::Center),
116        flex_shrink: 0.0,
117        ..Default::default()
118    })
119    .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start);
120
121    let mut body_children: Vec<View<Msg>> = Vec::with_capacity(groups.len() * 6);
122    for group in &groups {
123        body_children.push(group_header_view(&group.title, &palette));
124        for entry in &group.entries {
125            body_children.push(entry_view(entry, &palette));
126        }
127    }
128    let body = View::new(Style {
129        flex_direction: FlexDirection::Column,
130        size: Size {
131            width: percent(1.0_f32),
132            height: auto(),
133        },
134        flex_grow: 1.0,
135        ..Default::default()
136    })
137    .children(body_children);
138
139    let panel = View::new(Style {
140        position: Position::Absolute,
141        inset: Rect {
142            left: length(x),
143            top: length(y),
144            right: auto(),
145            bottom: auto(),
146        },
147        size: Size {
148            width: length(panel_w),
149            height: length(panel_h),
150        },
151        flex_direction: FlexDirection::Column,
152        padding: Rect {
153            left: length(PAD),
154            right: length(PAD),
155            top: length(PAD),
156            bottom: length(PAD),
157        },
158        ..Default::default()
159    })
160    .paint_with(panel_signature_painter(palette.panel))
161    .radius(palette.panel.radius)
162    .clip(true)
163    .children(vec![header, body]);
164
165    View::new(Style {
166        size: Size {
167            width: percent(1.0_f32),
168            height: percent(1.0_f32),
169        },
170        ..Default::default()
171    })
172    .fill(palette.scrim)
173    .on_click(on_dismiss)
174    .children(vec![panel])
175}
176
177fn group_header_view<Msg: Clone + 'static>(
178    title: &str,
179    palette: &ShortcutsHelpPalette,
180) -> View<Msg> {
181    View::new(Style {
182        size: Size {
183            width: percent(1.0_f32),
184            height: length(GROUP_H),
185        },
186        padding: Rect {
187            left: length(0.0_f32),
188            right: length(0.0_f32),
189            top: length(8.0_f32),
190            bottom: length(2.0_f32),
191        },
192        align_items: Some(AlignItems::Center),
193        flex_shrink: 0.0,
194        ..Default::default()
195    })
196    .text_aligned(
197        title.to_uppercase(),
198        GROUP_FONT,
199        palette.fg_group,
200        Alignment::Start,
201    )
202}
203
204fn entry_view<Msg: Clone + 'static>(
205    entry: &ShortcutEntry,
206    palette: &ShortcutsHelpPalette,
207) -> View<Msg> {
208    let desc = View::new(Style {
209        size: Size {
210            width: percent(1.0_f32),
211            height: percent(1.0_f32),
212        },
213        flex_grow: 1.0,
214        align_items: Some(AlignItems::Center),
215        ..Default::default()
216    })
217    .text_aligned(
218        entry.description.clone(),
219        ENTRY_FONT,
220        palette.fg_desc,
221        Alignment::Start,
222    );
223
224    let key_radius = radius::XS;
225    let keys = View::new(Style {
226        size: Size {
227            width: length(140.0_f32),
228            height: length(ENTRY_H - 6.0),
229        },
230        align_items: Some(AlignItems::Center),
231        justify_content: Some(JustifyContent::FlexEnd),
232        padding: Rect {
233            left: length(8.0_f32),
234            right: length(8.0_f32),
235            top: length(0.0_f32),
236            bottom: length(0.0_f32),
237        },
238        flex_shrink: 0.0,
239        ..Default::default()
240    })
241    .fill(palette.bg_key)
242    .radius(key_radius)
243    .paint_with(move |scene, _ts, rect| {
244        // Gloss superior — el chip de teclado se lee como tecla con
245        // luz cayendo desde el top, no como rect plano. Mismo patrón
246        // que button (P6) — todo chip clicable o tipo-tecla comparte
247        // la firma.
248        use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
249        use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
250        if rect.w <= 0.0 || rect.h <= 0.0 {
251            return;
252        }
253        let x0 = rect.x as f64;
254        let y0 = rect.y as f64;
255        let x1 = (rect.x + rect.w) as f64;
256        let y1 = (rect.y + rect.h) as f64;
257        let y_mid = y0 + (y1 - y0) * 0.5;
258        let rr = RoundedRect::new(x0, y0, x1, y1, key_radius);
259        let top = Color::from_rgba8(255, 255, 255, 28);
260        let bot = Color::from_rgba8(255, 255, 255, 0);
261        let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
262            .with_stops([top, bot].as_slice());
263        scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
264    })
265    .text_aligned(entry.keys.clone(), ENTRY_FONT - 1.0, palette.fg_key, Alignment::End);
266
267    View::new(Style {
268        flex_direction: FlexDirection::Row,
269        size: Size {
270            width: percent(1.0_f32),
271            height: length(ENTRY_H),
272        },
273        align_items: Some(AlignItems::Center),
274        gap: Size {
275            width: length(10.0_f32),
276            height: length(0.0_f32),
277        },
278        flex_shrink: 0.0,
279        ..Default::default()
280    })
281    .children(vec![desc, keys])
282}