nandi/jolt-nativepublic Fork 0
8e8cd5192dc161b423c0ee5dd41a7a058b24b409
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Bring vidya in cfd3e36 · on 8e8cd5192dc161b423c0ee5dd41a7a058b24b409 · nandi · 19d ago
ui.rs · 333 lines · 12.2 KBRust Blame HistoryRaw
  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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//! The immediate-mode cursor: a stack of live [`egui::Ui`] nodes.
//!
//! The ABI is push/pop (`vidya_card_begin` … `vidya_card_end`) while the Rust
//! API is closure-based (`vidya::card(ui, theme, |ui| …)`). Bridging them means
//! holding a child `Ui` open across FFI calls, which is exactly what
//! [`egui::Frame::begin`] / [`egui::containers::frame::Prepared::end`] and
//! [`egui::Ui::new_child`] are for. Every node is closed into its parent, so
//! layout ends up identical to the closure form.

use egui::containers::frame::Prepared;
use egui::{Align, Align2, Color32, FontId, Id, Layout, Rect, Sense, Ui, UiBuilder, Vec2};
use vidya_core::Theme;

/// A page taller than its viewport scrolls. `egui::ScrollArea` keeps its
/// `begin`/`end` pair private — unlike `egui::Frame`, which is what the card
/// node below borrows — so the page reimplements the half we need: offset the
/// content, clip it to the viewport, and carry the offset between frames.
#[derive(Clone, Copy, Default)]
struct ScrollState {
    /// How far the content is scrolled up, in points. Always >= 0.
    offset: f32,
    /// Flick velocity in points per second, decaying under friction.
    vel: f32,
    /// Content height measured when the page closed last frame.
    content: f32,
}

impl ScrollState {
    fn load(ctx: &egui::Context, id: Id) -> Self {
        ctx.data(|d| d.get_temp(id)).unwrap_or_default()
    }

    fn store(self, ctx: &egui::Context, id: Id) {
        ctx.data_mut(|d| d.insert_temp(id, self));
    }
}

/// Pixels per second squared, and the speed below which a flick stops.
const SCROLL_FRICTION: f32 = 1000.0;
const SCROLL_STOP_SPEED: f32 = 20.0;
const SCROLL_BAR_WIDTH: f32 = 6.0;

enum Node {
    /// A plain child region (root, page).
    Region(Ui),
    /// A framed surface (card) that paints its background on close.
    Framed(Box<Prepared>),
    /// A scrolling page: the content Ui plus what closing it needs.
    Scrolled {
        ui: Ui,
        id: Id,
        viewport: Rect,
        state: ScrollState,
    },
}

impl Node {
    fn ui_mut(&mut self) -> &mut Ui {
        match self {
            Self::Region(ui) | Self::Scrolled { ui, .. } => ui,
            Self::Framed(prepared) => &mut prepared.content_ui,
        }
    }
}

/// Open UI nodes for the current frame. Empty between frames.
#[derive(Default)]
pub struct Stack {
    nodes: Vec<Node>,
}

impl Stack {
    pub fn is_active(&self) -> bool {
        !self.nodes.is_empty()
    }

    pub fn top(&mut self) -> Option<&mut Ui> {
        self.nodes.last_mut().map(Node::ui_mut)
    }

    pub fn push_root(&mut self, ctx: &egui::Context) {
        // The screen rect is the whole display: on Android it runs under the
        // status bar, the nav band and — when it is up — the soft keyboard.
        // Insetting the root here is what keeps a focused compose field above
        // the keyboard, and it costs nothing on desktop, where the insets are
        // zero.
        let rect = ctx.screen_rect();
        #[cfg(target_os = "android")]
        let rect = {
            // Not `sync_system_chrome_from_android`: NativeActivity does not
            // shrink `content_rect` for the soft keyboard, so the measured
            // insets it installs are the bare bars — and installing them
            // suppresses the focus-based IME reserve that `system_chrome` falls
            // back to, which is the one that actually tracks the keyboard here.
            let chrome = vidya_core::system_chrome(ctx);
            Rect::from_min_max(
                egui::pos2(rect.min.x, rect.min.y + chrome.top),
                egui::pos2(rect.max.x, (rect.max.y - chrome.bottom()).max(rect.min.y)),
            )
        };
        let ui = Ui::new(
            ctx.clone(),
            Id::new("vidya_ffi_root"),
            UiBuilder::new()
                .layer_id(egui::LayerId::background())
                .max_rect(rect)
                .layout(Layout::top_down(Align::Min)),
        );
        self.nodes.push(Node::Region(ui));
    }

    /// Vertical page: page padding, optional centred max width, section gaps.
    pub fn push_page(&mut self, theme: &Theme, max_width: f32) {
        let Some(parent) = self.top() else {
            return;
        };
        let pad = theme.spacing.page;
        let mut rect = parent.available_rect_before_wrap().shrink(pad);
        if max_width > 0.0 && rect.width() > max_width {
            let cx = rect.center().x;
            rect = Rect::from_min_max(
                egui::pos2(cx - max_width * 0.5, rect.min.y),
                egui::pos2(cx + max_width * 0.5, rect.max.y),
            );
        }

        let ctx = parent.ctx().clone();
        let id = Id::new("vidya_ffi_page_scroll");
        let mut state = ScrollState::load(&ctx, id);
        // Last frame's height decides whether this frame scrolls at all: the
        // content has not been emitted yet, and an immediate-mode page cannot
        // know its own size in advance.
        let max_offset = (state.content - rect.height()).max(0.0);

        if max_offset > 0.0 {
            // Claim the drag BEFORE any content exists, exactly as egui's own
            // ScrollArea does — a rect interacted after its children would
            // steal their presses instead of scrolling past them.
            let drag = parent.interact(rect, id.with("drag"), Sense::drag());
            let dt = parent.input(|i| i.stable_dt).min(0.1);
            if drag.dragged() {
                state.offset -= parent.input(|i| i.pointer.delta().y);
                state.vel = 0.0;
            } else {
                if drag.drag_stopped() {
                    state.vel = parent.input(|i| i.pointer.velocity().y);
                }
                if state.vel != 0.0 {
                    state.offset -= state.vel * dt;
                    let friction = SCROLL_FRICTION * dt;
                    if friction > state.vel.abs() || state.vel.abs() < SCROLL_STOP_SPEED {
                        state.vel = 0.0;
                    } else {
                        state.vel -= state.vel.signum() * friction;
                    }
                    ctx.request_repaint();
                }
            }
            if parent.rect_contains_pointer(rect) {
                state.offset -= parent.input(|i| i.smooth_scroll_delta.y);
            }
        } else {
            state.vel = 0.0;
        }
        state.offset = state.offset.clamp(0.0, max_offset);

        // The content Ui starts above the viewport by the scroll offset and is
        // free to run as tall as it likes; the clip rect hides the overflow.
        let content = Rect::from_min_size(
            rect.min - Vec2::new(0.0, state.offset),
            egui::vec2(rect.width(), f32::INFINITY),
        );
        let mut ui = parent.new_child(
            UiBuilder::new()
                .max_rect(content)
                .layout(Layout::top_down(Align::Min)),
        );
        ui.set_clip_rect(rect.intersect(parent.clip_rect()));
        ui.spacing_mut().item_spacing.y = theme.spacing.md;
        self.nodes.push(Node::Scrolled {
            ui,
            id,
            viewport: rect,
            state,
        });
    }

    /// Themed card surface. Mirrors `vidya::card`'s width discipline: content
    /// fills the parent column and never overflows past it.
    pub fn push_card(&mut self, theme: &Theme) {
        let Some(parent) = self.top() else {
            return;
        };
        let outer = parent.available_width().max(1.0);
        parent.set_max_width(outer);

        let mut prepared = theme.card_frame().begin(parent);
        let inner = prepared.content_ui.available_width().max(1.0);
        prepared.content_ui.set_min_width(inner);
        prepared.content_ui.set_max_width(inner);
        prepared.content_ui.spacing_mut().item_spacing.y = theme.spacing.sm;
        self.nodes.push(Node::Framed(Box::new(prepared)));
    }

    /// Close the innermost node into its parent. The root is kept — only
    /// [`Self::unwind`] retires it — so a stray `vidya_page_end` cannot leave
    /// the frame without a cursor.
    pub fn pop(&mut self) {
        if self.nodes.len() > 1 {
            self.close_one();
        }
    }

    /// Close every open node, innermost first.
    pub fn unwind(&mut self) {
        while !self.nodes.is_empty() {
            self.close_one();
        }
    }

    fn close_one(&mut self) {
        let Some(node) = self.nodes.pop() else {
            return;
        };
        let Some(parent) = self.nodes.last_mut().map(Node::ui_mut) else {
            return; // Root: nothing to fold into.
        };
        match node {
            Node::Region(child) => {
                parent.advance_cursor_after_rect(child.min_rect());
            }
            Node::Scrolled {
                ui,
                id,
                viewport,
                mut state,
            } => {
                state.content = ui.min_rect().height();
                let ctx = parent.ctx().clone();
                state.store(&ctx, id);
                paint_scroll_bar(parent, viewport, state);
                parent.advance_cursor_after_rect(viewport);
            }
            Node::Framed(prepared) => {
                prepared.end(parent);
            }
        }
    }
}

// ── Leaves ──────────────────────────────────────────────────────────────────

pub fn gap(ui: &mut Ui, pixels: f32) {
    ui.add_space(pixels);
}

pub fn separator(ui: &mut Ui) {
    ui.separator();
}

pub fn status(ui: &mut Ui, theme: &Theme, label: &str, live: bool) {
    ui.horizontal(|ui| {
        vidya_core::status_dot(ui, theme, live);
        vidya_core::body(ui, theme, label);
    });
}

/// Single-line field over a caller-owned buffer.
///
/// The whole [`egui::Response`] is returned rather than just "did it change":
/// the tree backend also needs `lost_focus` to tell Enter from a click
/// elsewhere. Callers that only want the change flag ask it for `.changed()`.
pub fn text_field(
    ui: &mut Ui,
    theme: &Theme,
    text: &mut String,
    placeholder: &str,
) -> egui::Response {
    let response = vidya_core::text_field_singleline(ui, theme, text);
    if text.is_empty() && !placeholder.is_empty() {
        // `text_field_singleline` has no hint-text parameter; paint one in the
        // field's own padding so the ABI's `placeholder` still means something.
        let rect = response.rect;
        ui.painter().text(
            egui::pos2(rect.left() + theme.spacing.field_pad_x, rect.center().y),
            Align2::LEFT_CENTER,
            placeholder,
            FontId::proportional(theme.type_scale.body),
            theme.palette.text_secondary,
        );
    }
    response
}

/// Checkbox over a caller-owned value. Returns the value after input.
pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: bool, label: &str) -> (bool, bool) {
    let mut value = checked;
    let response = vidya_core::checkbox(ui, theme, &mut value, label);
    (value, response.changed())
}

/// Button kinds, matching `VidyaButtonKind`.
pub fn button(ui: &mut Ui, theme: &Theme, label: &str, kind: i32) -> bool {
    match kind {
        1 => vidya_core::primary_button(ui, theme, label),
        2 => vidya_core::destructive_button(ui, theme, label),
        _ => vidya_core::button(ui, theme, label),
    }
    .clicked()
}

/// A thin overlay bar on the viewport's right edge, drawn only when the page
/// actually overflows. egui paints its own bars from private code, so this is
/// the one piece of `ScrollArea` that has to be redrawn by hand.
fn paint_scroll_bar(ui: &Ui, viewport: Rect, state: ScrollState) {
    let max_offset = (state.content - viewport.height()).max(0.0);
    if max_offset <= 0.0 {
        return;
    }
    let visible = (viewport.height() / state.content).clamp(0.0, 1.0);
    let height = (viewport.height() * visible).max(24.0);
    let travel = viewport.height() - height;
    let top = viewport.min.y + travel * (state.offset / max_offset).clamp(0.0, 1.0);
    let bar = Rect::from_min_size(
        egui::pos2(viewport.max.x - SCROLL_BAR_WIDTH, top),
        egui::vec2(SCROLL_BAR_WIDTH, height),
    );
    ui.painter().rect_filled(
        bar,
        SCROLL_BAR_WIDTH * 0.5,
        Color32::from_rgba_unmultiplied(94, 92, 100, 136),
    );
}