nandi/jolt-nativepublic Fork 0
0fb017593acce485b16f9756141b599beb49dc76
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.

ui.rs · 333 lines · 12.2 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! The immediate-mode cursor: a stack of live [`egui::Ui`] nodes.
2//!
3//! The ABI is push/pop (`vidya_card_begin` … `vidya_card_end`) while the Rust
4//! API is closure-based (`vidya::card(ui, theme, |ui| …)`). Bridging them means
5//! holding a child `Ui` open across FFI calls, which is exactly what
6//! [`egui::Frame::begin`] / [`egui::containers::frame::Prepared::end`] and
7//! [`egui::Ui::new_child`] are for. Every node is closed into its parent, so
8//! layout ends up identical to the closure form.
9
10use egui::containers::frame::Prepared;
11use egui::{Align, Align2, Color32, FontId, Id, Layout, Rect, Sense, Ui, UiBuilder, Vec2};
12use vidya_core::Theme;
13
14/// A page taller than its viewport scrolls. `egui::ScrollArea` keeps its
15/// `begin`/`end` pair private — unlike `egui::Frame`, which is what the card
16/// node below borrows — so the page reimplements the half we need: offset the
17/// content, clip it to the viewport, and carry the offset between frames.
18#[derive(Clone, Copy, Default)]
19struct ScrollState {
20 /// How far the content is scrolled up, in points. Always >= 0.
21 offset: f32,
22 /// Flick velocity in points per second, decaying under friction.
23 vel: f32,
24 /// Content height measured when the page closed last frame.
25 content: f32,
26}
27
28impl ScrollState {
29 fn load(ctx: &egui::Context, id: Id) -> Self {
30 ctx.data(|d| d.get_temp(id)).unwrap_or_default()
31 }
32
33 fn store(self, ctx: &egui::Context, id: Id) {
34 ctx.data_mut(|d| d.insert_temp(id, self));
35 }
36}
37
38/// Pixels per second squared, and the speed below which a flick stops.
39const SCROLL_FRICTION: f32 = 1000.0;
40const SCROLL_STOP_SPEED: f32 = 20.0;
41const SCROLL_BAR_WIDTH: f32 = 6.0;
42
43enum Node {
44 /// A plain child region (root, page).
45 Region(Ui),
46 /// A framed surface (card) that paints its background on close.
47 Framed(Box<Prepared>),
48 /// A scrolling page: the content Ui plus what closing it needs.
49 Scrolled {
50 ui: Ui,
51 id: Id,
52 viewport: Rect,
53 state: ScrollState,
54 },
55}
56
57impl Node {
58 fn ui_mut(&mut self) -> &mut Ui {
59 match self {
60 Self::Region(ui) | Self::Scrolled { ui, .. } => ui,
61 Self::Framed(prepared) => &mut prepared.content_ui,
62 }
63 }
64}
65
66/// Open UI nodes for the current frame. Empty between frames.
67#[derive(Default)]
68pub struct Stack {
69 nodes: Vec<Node>,
70}
71
72impl Stack {
73 pub fn is_active(&self) -> bool {
74 !self.nodes.is_empty()
75 }
76
77 pub fn top(&mut self) -> Option<&mut Ui> {
78 self.nodes.last_mut().map(Node::ui_mut)
79 }
80
81 pub fn push_root(&mut self, ctx: &egui::Context) {
82 // The screen rect is the whole display: on Android it runs under the
83 // status bar, the nav band and — when it is up — the soft keyboard.
84 // Insetting the root here is what keeps a focused compose field above
85 // the keyboard, and it costs nothing on desktop, where the insets are
86 // zero.
87 let rect = ctx.screen_rect();
88 #[cfg(target_os = "android")]
89 let rect = {
90 // Not `sync_system_chrome_from_android`: NativeActivity does not
91 // shrink `content_rect` for the soft keyboard, so the measured
92 // insets it installs are the bare bars — and installing them
93 // suppresses the focus-based IME reserve that `system_chrome` falls
94 // back to, which is the one that actually tracks the keyboard here.
95 let chrome = vidya_core::system_chrome(ctx);
96 Rect::from_min_max(
97 egui::pos2(rect.min.x, rect.min.y + chrome.top),
98 egui::pos2(rect.max.x, (rect.max.y - chrome.bottom()).max(rect.min.y)),
99 )
100 };
101 let ui = Ui::new(
102 ctx.clone(),
103 Id::new("vidya_ffi_root"),
104 UiBuilder::new()
105 .layer_id(egui::LayerId::background())
106 .max_rect(rect)
107 .layout(Layout::top_down(Align::Min)),
108 );
109 self.nodes.push(Node::Region(ui));
110 }
111
112 /// Vertical page: page padding, optional centred max width, section gaps.
113 pub fn push_page(&mut self, theme: &Theme, max_width: f32) {
114 let Some(parent) = self.top() else {
115 return;
116 };
117 let pad = theme.spacing.page;
118 let mut rect = parent.available_rect_before_wrap().shrink(pad);
119 if max_width > 0.0 && rect.width() > max_width {
120 let cx = rect.center().x;
121 rect = Rect::from_min_max(
122 egui::pos2(cx - max_width * 0.5, rect.min.y),
123 egui::pos2(cx + max_width * 0.5, rect.max.y),
124 );
125 }
126
127 let ctx = parent.ctx().clone();
128 let id = Id::new("vidya_ffi_page_scroll");
129 let mut state = ScrollState::load(&ctx, id);
130 // Last frame's height decides whether this frame scrolls at all: the
131 // content has not been emitted yet, and an immediate-mode page cannot
132 // know its own size in advance.
133 let max_offset = (state.content - rect.height()).max(0.0);
134
135 if max_offset > 0.0 {
136 // Claim the drag BEFORE any content exists, exactly as egui's own
137 // ScrollArea does — a rect interacted after its children would
138 // steal their presses instead of scrolling past them.
139 let drag = parent.interact(rect, id.with("drag"), Sense::drag());
140 let dt = parent.input(|i| i.stable_dt).min(0.1);
141 if drag.dragged() {
142 state.offset -= parent.input(|i| i.pointer.delta().y);
143 state.vel = 0.0;
144 } else {
145 if drag.drag_stopped() {
146 state.vel = parent.input(|i| i.pointer.velocity().y);
147 }
148 if state.vel != 0.0 {
149 state.offset -= state.vel * dt;
150 let friction = SCROLL_FRICTION * dt;
151 if friction > state.vel.abs() || state.vel.abs() < SCROLL_STOP_SPEED {
152 state.vel = 0.0;
153 } else {
154 state.vel -= state.vel.signum() * friction;
155 }
156 ctx.request_repaint();
157 }
158 }
159 if parent.rect_contains_pointer(rect) {
160 state.offset -= parent.input(|i| i.smooth_scroll_delta.y);
161 }
162 } else {
163 state.vel = 0.0;
164 }
165 state.offset = state.offset.clamp(0.0, max_offset);
166
167 // The content Ui starts above the viewport by the scroll offset and is
168 // free to run as tall as it likes; the clip rect hides the overflow.
169 let content = Rect::from_min_size(
170 rect.min - Vec2::new(0.0, state.offset),
171 egui::vec2(rect.width(), f32::INFINITY),
172 );
173 let mut ui = parent.new_child(
174 UiBuilder::new()
175 .max_rect(content)
176 .layout(Layout::top_down(Align::Min)),
177 );
178 ui.set_clip_rect(rect.intersect(parent.clip_rect()));
179 ui.spacing_mut().item_spacing.y = theme.spacing.md;
180 self.nodes.push(Node::Scrolled {
181 ui,
182 id,
183 viewport: rect,
184 state,
185 });
186 }
187
188 /// Themed card surface. Mirrors `vidya::card`'s width discipline: content
189 /// fills the parent column and never overflows past it.
190 pub fn push_card(&mut self, theme: &Theme) {
191 let Some(parent) = self.top() else {
192 return;
193 };
194 let outer = parent.available_width().max(1.0);
195 parent.set_max_width(outer);
196
197 let mut prepared = theme.card_frame().begin(parent);
198 let inner = prepared.content_ui.available_width().max(1.0);
199 prepared.content_ui.set_min_width(inner);
200 prepared.content_ui.set_max_width(inner);
201 prepared.content_ui.spacing_mut().item_spacing.y = theme.spacing.sm;
202 self.nodes.push(Node::Framed(Box::new(prepared)));
203 }
204
205 /// Close the innermost node into its parent. The root is kept — only
206 /// [`Self::unwind`] retires it — so a stray `vidya_page_end` cannot leave
207 /// the frame without a cursor.
208 pub fn pop(&mut self) {
209 if self.nodes.len() > 1 {
210 self.close_one();
211 }
212 }
213
214 /// Close every open node, innermost first.
215 pub fn unwind(&mut self) {
216 while !self.nodes.is_empty() {
217 self.close_one();
218 }
219 }
220
221 fn close_one(&mut self) {
222 let Some(node) = self.nodes.pop() else {
223 return;
224 };
225 let Some(parent) = self.nodes.last_mut().map(Node::ui_mut) else {
226 return; // Root: nothing to fold into.
227 };
228 match node {
229 Node::Region(child) => {
230 parent.advance_cursor_after_rect(child.min_rect());
231 }
232 Node::Scrolled {
233 ui,
234 id,
235 viewport,
236 mut state,
237 } => {
238 state.content = ui.min_rect().height();
239 let ctx = parent.ctx().clone();
240 state.store(&ctx, id);
241 paint_scroll_bar(parent, viewport, state);
242 parent.advance_cursor_after_rect(viewport);
243 }
244 Node::Framed(prepared) => {
245 prepared.end(parent);
246 }
247 }
248 }
249}
250
251// ── Leaves ──────────────────────────────────────────────────────────────────
252
253pub fn gap(ui: &mut Ui, pixels: f32) {
254 ui.add_space(pixels);
255}
256
257pub fn separator(ui: &mut Ui) {
258 ui.separator();
259}
260
261pub fn status(ui: &mut Ui, theme: &Theme, label: &str, live: bool) {
262 ui.horizontal(|ui| {
263 vidya_core::status_dot(ui, theme, live);
264 vidya_core::body(ui, theme, label);
265 });
266}
267
268/// Single-line field over a caller-owned buffer.
269///
270/// The whole [`egui::Response`] is returned rather than just "did it change":
271/// the tree backend also needs `lost_focus` to tell Enter from a click
272/// elsewhere. Callers that only want the change flag ask it for `.changed()`.
273pub fn text_field(
274 ui: &mut Ui,
275 theme: &Theme,
276 text: &mut String,
277 placeholder: &str,
278) -> egui::Response {
279 let response = vidya_core::text_field_singleline(ui, theme, text);
280 if text.is_empty() && !placeholder.is_empty() {
281 // `text_field_singleline` has no hint-text parameter; paint one in the
282 // field's own padding so the ABI's `placeholder` still means something.
283 let rect = response.rect;
284 ui.painter().text(
285 egui::pos2(rect.left() + theme.spacing.field_pad_x, rect.center().y),
286 Align2::LEFT_CENTER,
287 placeholder,
288 FontId::proportional(theme.type_scale.body),
289 theme.palette.text_secondary,
290 );
291 }
292 response
293}
294
295/// Checkbox over a caller-owned value. Returns the value after input.
296pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: bool, label: &str) -> (bool, bool) {
297 let mut value = checked;
298 let response = vidya_core::checkbox(ui, theme, &mut value, label);
299 (value, response.changed())
300}
301
302/// Button kinds, matching `VidyaButtonKind`.
303pub fn button(ui: &mut Ui, theme: &Theme, label: &str, kind: i32) -> bool {
304 match kind {
305 1 => vidya_core::primary_button(ui, theme, label),
306 2 => vidya_core::destructive_button(ui, theme, label),
307 _ => vidya_core::button(ui, theme, label),
308 }
309 .clicked()
310}
311
312/// A thin overlay bar on the viewport's right edge, drawn only when the page
313/// actually overflows. egui paints its own bars from private code, so this is
314/// the one piece of `ScrollArea` that has to be redrawn by hand.
315fn paint_scroll_bar(ui: &Ui, viewport: Rect, state: ScrollState) {
316 let max_offset = (state.content - viewport.height()).max(0.0);
317 if max_offset <= 0.0 {
318 return;
319 }
320 let visible = (viewport.height() / state.content).clamp(0.0, 1.0);
321 let height = (viewport.height() * visible).max(24.0);
322 let travel = viewport.height() - height;
323 let top = viewport.min.y + travel * (state.offset / max_offset).clamp(0.0, 1.0);
324 let bar = Rect::from_min_size(
325 egui::pos2(viewport.max.x - SCROLL_BAR_WIDTH, top),
326 egui::vec2(SCROLL_BAR_WIDTH, height),
327 );
328 ui.painter().rect_filled(
329 bar,
330 SCROLL_BAR_WIDTH * 0.5,
331 Color32::from_rgba_unmultiplied(94, 92, 100, 136),
332 );
333}