/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
use super::long_press_timeout;
use async_trait::async_trait;
use atomic_float::AtomicF32;
use darkfi::system::CondVar;
use darkfi_serial::{serialize, Decodable};
use miniquad::{MouseButton, TouchPhase};
use parking_lot::Mutex as SyncMutex;
use rand::{rngs::OsRng, Rng};
use std::{
collections::{HashMap, HashSet, VecDeque},
io::Read,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Weak,
},
};
use crate::{
gfx::{gfxtag, DrawCall, DrawInstruction, DrawMesh, Point, Rectangle, Renderer, Vertex},
mesh::MeshBuilder,
prop::{
PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32, PropertyPtr,
PropertyRect, PropertyUint32, Role,
},
scene::{MethodCallSub, Pimpl, SceneNodeWeak},
text, ExecutorPtr,
};
use super::{DrawUpdate, OnModify, RedrawTrigger, UIObject};
mod shape;
const EPSILON: f32 = 0.001;
const BIG_EPSILON: f32 = 0.05;
const LONG_PRESS_EPSILON: f32 = 5.0;
#[cfg(target_os = "android")]
const MENU_ICON_OFFSET: f32 = 55.;
#[cfg(not(target_os = "android"))]
const MENU_ICON_OFFSET: f32 = 24.;
macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::menu", $($arg)*); } }
macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::menu", $($arg)*); } }
#[derive(Clone)]
struct TouchInfo {
start_scroll: f32,
start_pos: Point,
start_instant: std::time::Instant,
samples: VecDeque<(std::time::Instant, f32)>,
last_instant: std::time::Instant,
last_pos: Point,
}
#[derive(Clone)]
struct MouseClickInfo {
start_pos: Point,
start_instant: std::time::Instant,
}
#[derive(Clone)]
struct DragInfo {
item_idx: usize,
insert_idx: usize,
}
impl TouchInfo {
fn new(start_scroll: f32, pos: Point) -> Self {
Self {
start_scroll,
start_pos: pos,
start_instant: std::time::Instant::now(),
samples: VecDeque::from([(std::time::Instant::now(), pos.y)]),
last_instant: std::time::Instant::now(),
last_pos: pos,
}
}
fn push_sample(&mut self, y: f32) {
self.samples.push_back((std::time::Instant::now(), y));
while let Some((instant, _)) = self.samples.front() {
if instant.elapsed().as_micros() <= 40_000 {
break
}
self.samples.pop_front();
}
}
fn first_sample(&self) -> Option<(f32, f32)> {
self.samples.front().map(|(t, s)| (t.elapsed().as_micros() as f32 / 1000., *s))
}
}
pub type MenuPtr = Arc
;
pub struct Menu {
node: SceneNodeWeak,
/// Weak self-reference so handlers can spawn detached tasks.
me: Weak,
ex: ExecutorPtr,
renderer: Renderer,
redraw: RedrawTrigger,
tasks: SyncMutex>>,
root_dc_key: u64,
content_dc_key: u64,
bg_dc_key: u64,
is_visible: PropertyBool,
rect: PropertyRect,
scroll: AtomicF32,
z_index: PropertyUint32,
priority: PropertyUint32,
items: PropertyPtr,
font_size: PropertyFloat32,
padding: PropertyPtr,
handle_padding: PropertyFloat32,
text_color: PropertyColor,
bg_color: PropertyColor,
sep_size: PropertyFloat32,
sep_color: PropertyColor,
role1_color: PropertyColor,
role1_group: PropertyPtr,
role2_color: PropertyColor,
role2_group: PropertyPtr,
fade_zone: PropertyFloat32,
window_scale: PropertyFloat32,
mouse_pos: SyncMutex,
touch_info: SyncMutex>,
mouse_click_info: SyncMutex >,
drag_info: SyncMutex >,
long_press_task: SyncMutex >>,
scroll_start_accel: PropertyFloat32,
scroll_resist: PropertyFloat32,
overscroll: PropertyFloat32,
motion_cv: Arc,
speed: AtomicF32,
is_edit_mode: AtomicBool,
parent_rect: SyncMutex>,
saved_items: SyncMutex >>,
/// Opaque per-item text instructions, indexed by item position.
item_instrs: SyncMutex>>>,
/// Content instructions tagged with the scroll offset they were
/// assembled for. `None` means stale.
content_cache: SyncMutex)>>,
/// Viewport-anchored background meshes: opaque part plus fade
/// gradient. `None` means stale.
bg_meshes: SyncMutex )>>,
}
impl Menu {
pub async fn new(
node: SceneNodeWeak,
window_scale: PropertyFloat32,
renderer: Renderer,
redraw: RedrawTrigger,
ex: ExecutorPtr,
) -> Pimpl {
let node_ref = &node.upgrade().unwrap();
let is_visible = PropertyBool::wrap(node_ref, Role::Internal, "is_visible", 0).unwrap();
let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap();
let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap();
let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
let items = node_ref.get_property("items").expect("Menu::items");
let font_size = PropertyFloat32::wrap(node_ref, Role::Internal, "font_size", 0).unwrap();
let padding = node_ref.get_property("padding").expect("Menu::padding");
let handle_padding =
PropertyFloat32::wrap(node_ref, Role::Internal, "handle_padding", 0).unwrap();
let text_color = PropertyColor::wrap(node_ref, Role::Internal, "text_color").unwrap();
let bg_color = PropertyColor::wrap(node_ref, Role::Internal, "bg_color").unwrap();
let sep_size = PropertyFloat32::wrap(node_ref, Role::Internal, "sep_size", 0).unwrap();
let sep_color = PropertyColor::wrap(node_ref, Role::Internal, "sep_color").unwrap();
let role1_color = PropertyColor::wrap(node_ref, Role::Internal, "role1_color").unwrap();
let role1_group = node_ref.get_property("role1_group").unwrap();
let role2_color = PropertyColor::wrap(node_ref, Role::Internal, "role2_color").unwrap();
let role2_group = node_ref.get_property("role2_group").unwrap();
let fade_zone = PropertyFloat32::wrap(node_ref, Role::Internal, "fade_zone", 0).unwrap();
let scroll_start_accel =
PropertyFloat32::wrap(node_ref, Role::Internal, "scroll_start_accel", 0).unwrap();
let scroll_resist =
PropertyFloat32::wrap(node_ref, Role::Internal, "scroll_resist", 0).unwrap();
let overscroll = PropertyFloat32::wrap(node_ref, Role::Internal, "overscroll", 0).unwrap();
let motion_cv = Arc::new(CondVar::new());
let self_ = Arc::new_cyclic(|me| Self {
node: node.clone(),
me: me.clone(),
ex,
renderer: renderer.clone(),
redraw,
tasks: SyncMutex::new(vec![]),
root_dc_key: OsRng.gen(),
content_dc_key: OsRng.gen(),
bg_dc_key: OsRng.gen(),
is_visible,
rect,
scroll: AtomicF32::new(0.),
z_index,
priority,
items,
font_size,
padding,
handle_padding,
text_color,
bg_color,
sep_size,
sep_color,
role1_color,
role1_group,
role2_color,
role2_group,
fade_zone,
window_scale,
mouse_pos: SyncMutex::new(Point::new(0., 0.)),
touch_info: SyncMutex::new(None),
mouse_click_info: SyncMutex::new(None),
drag_info: SyncMutex::new(None),
long_press_task: SyncMutex::new(None),
scroll_start_accel,
scroll_resist,
overscroll,
motion_cv,
speed: AtomicF32::new(0.),
is_edit_mode: AtomicBool::new(false),
parent_rect: SyncMutex::new(None),
saved_items: SyncMutex::new(None),
item_instrs: SyncMutex::new(vec![]),
content_cache: SyncMutex::new(None),
bg_meshes: SyncMutex::new(None),
});
Pimpl::Menu(self_)
}
/// Invalidate all cached draw artifacts. Locks are taken one at a
/// time, never nested, so this cannot deadlock against `draw()`.
fn invalidate_draw(&self) {
*self.item_instrs.lock() = vec![];
*self.content_cache.lock() = None;
*self.bg_meshes.lock() = None;
}
/// Height of a single item
fn get_item_height(&self) -> f32 {
self.font_size.get() + self.padding.get_f32(1).unwrap() * 2.0
}
/// Save the current menu items layout
fn save_items_layout(&self) {
let items = self.items.get_str_vec().unwrap();
*self.saved_items.lock() = Some(items);
}
/// Height of the content without the overscroll
fn content_height(&self) -> f32 {
self.items.get_len() as f32 * self.get_item_height()
}
fn get_selected_item_index(&self, click_y: f32) -> Option {
let rect = self.rect.get();
let scroll = self.scroll.load(Ordering::Relaxed);
// Scroll is positive value so to translate click into content, we must add the scroll.
let content_y = click_y + scroll - rect.y;
if content_y < 0. || content_y > self.content_height() {
return None
}
let item_height = self.get_item_height();
Some((content_y / item_height) as usize)
}
async fn handle_selection(&self, item_idx: usize) {
if item_idx < self.items.get_len() {
let node = self.node.upgrade().unwrap();
let item_name = self.items.get_str(item_idx).unwrap();
let data = serialize(&item_name);
node.trigger("select", data).await.unwrap();
}
}
async fn handle_interaction(
&self,
pos: Point,
is_tap: bool,
is_long_press_tap: bool,
elapsed_ms: u128,
) {
let is_long_press = is_long_press_tap && elapsed_ms >= long_press_timeout() as u128;
if is_long_press {
if !self.is_edit_mode.load(Ordering::Relaxed) {
self.save_items_layout();
self.is_edit_mode.store(true, Ordering::Release);
let node = self.node.upgrade().unwrap();
node.trigger("edit_active", vec![]).await.unwrap();
self.invalidate_draw();
self.redraw.trigger();
}
} else if is_tap {
let is_edit_mode = self.is_edit_mode.load(Ordering::Relaxed);
if is_edit_mode {
let font_size = self.font_size.get();
let x_half_size = font_size * 0.85;
let x_center = MENU_ICON_OFFSET + font_size * 0.4;
if let Some(item_idx) = self.get_selected_item_index(pos.y) {
let item_name = self.items.get_str(item_idx).unwrap();
let x_min = x_center - x_half_size;
let x_max = x_center + x_half_size;
if pos.x >= x_min && pos.x <= x_max {
info!(target: "app::menu", "X clicked for item: {item_name}");
let atom = &mut self.redraw.make_guard(gfxtag!("Menu::delete_item"));
self.items.remove_str(atom, Role::App, item_idx).unwrap();
} else {
self.handle_selection(item_idx).await;
}
}
} else if let Some(item_idx) = self.get_selected_item_index(pos.y) {
self.handle_selection(item_idx).await;
}
}
}
/// Fade alpha for a content-space y position at the given scroll:
/// 1 above the fade zone, decreasing linearly to 0 at the bottom
/// edge of the viewport.
fn fade_factor(&self, rect: &Rectangle, content_y: f32, scroll: f32) -> f32 {
let fade_distance = self.fade_zone.get();
if fade_distance <= EPSILON {
return 1.0
}
let viewport_y = content_y - scroll;
let fade_zone_start = rect.h - fade_distance;
if viewport_y <= fade_zone_start {
return 1.0
}
1.0 - ((viewport_y - fade_zone_start) / fade_distance).clamp(0.0, 1.0)
}
/// Render a single item's text as draw instructions with the given
/// color.
fn make_text_instrs(
&self,
item_text: &str,
color: crate::mesh::Color,
rect: &Rectangle,
font_size: f32,
window_scale: f32,
padding_x: f32,
) -> Vec {
let layout = text::make_layout(
item_text,
color,
font_size,
1.0,
window_scale,
Some(rect.w - padding_x * 2.),
&[],
);
text::render_layout(&layout, &self.renderer, gfxtag!("menu_text"))
}
/// Assemble the content draw instructions for the given scroll
/// offset. Items away from the fade zone reuse cached opaque text
/// instructions; items intersecting it are rebuilt with faded
/// colors. Separators are baked into one mesh with per-separator
/// alpha at absolute content positions.
fn assemble_content(&self, rect: &Rectangle, scroll: f32) -> Vec {
let mut instrs = vec![];
let item_height = self.get_item_height();
let font_size = self.font_size.get();
let padding_x = self.padding.get_f32(0).unwrap();
let padding_y = self.padding.get_f32(1).unwrap();
let handle_padding = self.handle_padding.get();
let text_color = self.text_color.get();
let role1_color = self.role1_color.get();
let role2_color = self.role2_color.get();
let sep_size = self.sep_size.get();
let sep_color = self.sep_color.get();
let window_scale = self.window_scale.get();
let role1_set: HashSet =
self.role1_group.get_str_vec().unwrap_or_default().into_iter().collect();
let role2_set: HashSet =
self.role2_group.get_str_vec().unwrap_or_default().into_iter().collect();
let num_items = self.items.get_len();
// Get items and reorder if dragging
let items_list = {
let mut items = vec![];
for idx in 0..num_items {
items.push(self.items.get_str(idx).unwrap());
}
if let Some(ref drag_info) = self.drag_info.lock().as_ref() {
if drag_info.item_idx != drag_info.insert_idx {
let item = items.remove(drag_info.item_idx);
items.insert(drag_info.insert_idx, item);
}
}
items
};
// Single separator mesh covering all separators, each faded by
// its on-screen position. Drawn first while the cursor sits at
// the content origin, so baked positions map directly.
if num_items > 1 {
let mut sep_builder = MeshBuilder::new(gfxtag!("menu_sep"));
let uv = [0., 0.];
for idx in 0..num_items - 1 {
let y = (idx + 1) as f32 * item_height;
let factor = self.fade_factor(rect, y, scroll);
if factor <= 0.0 {
continue
}
let color = [sep_color[0], sep_color[1], sep_color[2], sep_color[3] * factor];
sep_builder.append(
vec![
Vertex { pos: [0., y], color, uv },
Vertex { pos: [rect.w, y], color, uv },
Vertex { pos: [0., y + sep_size], color, uv },
Vertex { pos: [rect.w, y + sep_size], color, uv },
],
vec![0, 2, 1, 1, 2, 3],
);
}
let sep_mesh = sep_builder.alloc(&self.renderer).draw_untextured();
instrs.push(DrawInstruction::Draw(sep_mesh));
}
let is_edit_mode = self.is_edit_mode.load(Ordering::Relaxed);
let edit_offset = if is_edit_mode { handle_padding } else { 0.0 };
let mut edit_instrs = vec![];
if is_edit_mode {
let item_center_y = item_height / 2.0;
let x_center = MENU_ICON_OFFSET + font_size * 0.4;
let rhs = rect.w - MENU_ICON_OFFSET - font_size * 0.56;
edit_instrs.push(DrawInstruction::Move(Point::new(x_center, item_center_y)));
edit_instrs.push(DrawInstruction::Draw(shape::make_x(&self.renderer, font_size)));
edit_instrs.push(DrawInstruction::Move(Point::new(-x_center, -item_center_y)));
edit_instrs.push(DrawInstruction::Move(Point::new(rhs, item_center_y)));
edit_instrs.push(DrawInstruction::Draw(shape::make_hammy(&self.renderer, font_size)));
edit_instrs.push(DrawInstruction::Move(Point::new(-rhs, -item_center_y)));
}
let mut item_instrs = self.item_instrs.lock();
if item_instrs.len() != num_items {
item_instrs.clear();
item_instrs.resize(num_items, None);
}
for idx in 0..num_items {
let item_text = items_list[idx].clone();
let base_color = if role2_set.contains(&item_text) {
role2_color
} else if role1_set.contains(&item_text) {
role1_color
} else {
text_color
};
let factor = self.fade_factor(rect, idx as f32 * item_height, scroll);
if factor <= 0.0 {
continue
}
instrs.append(&mut edit_instrs.clone());
// Use a fraction of edit_offset for the label position to reduce gap from X icon
let label_edit_offset = edit_offset * 0.62;
instrs
.push(DrawInstruction::Move(Point::new(padding_x + label_edit_offset, padding_y)));
if factor >= 1.0 {
if item_instrs[idx].is_none() {
item_instrs[idx] = Some(self.make_text_instrs(
&item_text,
base_color,
rect,
font_size,
window_scale,
padding_x,
));
}
instrs.extend(item_instrs[idx].clone().unwrap());
} else {
let mut faded = base_color;
faded[3] *= factor;
instrs.extend(self.make_text_instrs(
&item_text,
faded,
rect,
font_size,
window_scale,
padding_x,
));
}
instrs.push(DrawInstruction::Move(Point::new(
-padding_x - label_edit_offset,
font_size + padding_y,
)));
}
instrs
}
/// Build the viewport-anchored background meshes: an opaque quad
/// above the fade zone and a gradient quad fading to fully
/// transparent at the bottom edge, so the parent background shows
/// through.
fn make_bg_meshes(&self, rect: &Rectangle) -> (DrawMesh, Option) {
let fade_distance = self.fade_zone.get();
let content_height = self.content_height();
let bg_color = self.bg_color.get();
let fade_top = if fade_distance > EPSILON { rect.h - fade_distance } else { rect.h };
let main_bottom = fade_top.min(content_height).min(rect.h);
let mut main_builder = MeshBuilder::new(gfxtag!("menu_bg"));
if main_bottom > 0. {
main_builder.draw_filled_box(&Rectangle::new(0., 0., rect.w, main_bottom), bg_color);
}
let main_mesh = main_builder.alloc(&self.renderer).draw_untextured();
let fade_mesh = if fade_distance > EPSILON && content_height > fade_top {
let fade_bottom = rect.h.min(content_height);
Some(shape::make_fade_mesh(&self.renderer, rect.w, fade_top, fade_bottom, bg_color))
} else {
None
};
(main_mesh, fade_mesh)
}
fn scrollview(&self, scroll: f32) {
let item_height = self.get_item_height();
let num_items = self.items.get_len() as f32;
let content_height = num_items * item_height;
let rect = self.rect.get();
let overscroll = self.overscroll.get();
let max_scroll = (overscroll + content_height - rect.h).max(0.);
let scroll = scroll.clamp(0., max_scroll);
self.scroll.store(scroll, Ordering::Relaxed);
}
fn start_scroll(&self, delta: f32) {
let accel = self.scroll_start_accel.get();
self.speed.store(delta * accel, Ordering::Relaxed);
self.motion_cv.notify();
}
async fn handle_movement(&self) {
let resist = self.scroll_resist.get();
loop {
let mut speed = self.speed.load(Ordering::Relaxed);
if speed.abs() < EPSILON {
break
}
while speed.abs() >= EPSILON {
speed = self.speed.load(Ordering::Relaxed);
let scroll = self.scroll.load(Ordering::Relaxed);
self.scrollview(scroll + speed);
self.redraw.trigger();
speed *= resist;
self.speed.store(speed, Ordering::Relaxed);
darkfi::system::msleep(16).await;
}
self.speed.store(0., Ordering::Relaxed);
break
}
}
fn end_touch_phase(&self, touch_y: f32) {
let touch_info = std::mem::take(&mut *self.touch_info.lock());
let info = touch_info.unwrap();
if let Some((dt, _)) = info.first_sample() {
if dt > EPSILON {
let velocity = (touch_y - info.start_pos.y) / dt;
self.start_scroll(-velocity);
}
}
}
/// Cancels edit mode changes, reverting any modifications made during edit mode
async fn process_cancel_method(me: &Weak, sub: &MethodCallSub) -> bool {
let Ok(method_call) = sub.receive().await else {
d!("Event relayer closed");
return false
};
d!("method called: cancel({method_call:?})");
assert!(method_call.send_res.is_none());
let Some(self_) = me.upgrade() else {
d!("Self destroyed");
return true
};
let atom = &mut self_.redraw.make_guard(gfxtag!("Menu::cancel_edit"));
// Restore the saved items
// It must exist otherwise theres a logic err
let saved_items = self_.saved_items.lock().take().unwrap();
self_.items.set_str_vec(atom, Role::App, saved_items).unwrap();
// Exit edit mode
self_.is_edit_mode.store(false, Ordering::Release);
self_.invalidate_draw();
true
}
/// Accepts edit mode changes, finalizing any modifications made during edit mode
async fn process_done_method(me: &Weak, sub: &MethodCallSub) -> bool {
let Ok(method_call) = sub.receive().await else {
d!("Event relayer closed");
return false
};
d!("method called: done({method_call:?})");
assert!(method_call.send_res.is_none());
let Some(self_) = me.upgrade() else {
d!("Self destroyed");
return true
};
// Calculate deleted items by diffing saved and current items
let saved_items = self_.saved_items.lock().take().unwrap();
let current_items = self_.items.get_str_vec().unwrap();
let deleted_items: Vec =
saved_items.into_iter().filter(|item| !current_items.contains(item)).collect();
// Send the edit_done signal with deleted items
let node = self_.node.upgrade().unwrap();
let data = serialize(&deleted_items);
node.trigger("edit_done", data).await.unwrap();
self_.is_edit_mode.store(false, Ordering::Release);
self_.invalidate_draw();
self_.redraw.trigger();
true
}
}
#[async_trait]
impl UIObject for Menu {
fn priority(&self) -> u32 {
self.priority.get()
}
async fn start(self: Arc, ex: ExecutorPtr) {
let me = self.me.clone();
let node_ref = &self.node.upgrade().unwrap();
let me2 = me.clone();
let cv = self.motion_cv.clone();
let motion_task = ex.spawn(async move {
loop {
cv.wait().await;
let Some(self_) = me2.upgrade() else {
t!("Menu destroyed before motion_task stopped");
break;
};
self_.handle_movement().await;
cv.reset();
}
});
let method_sub = node_ref.subscribe_method_call("cancel_edit").unwrap();
let me2 = me.clone();
let cancel_task =
ex.spawn(async move { while Self::process_cancel_method(&me2, &method_sub).await {} });
let method_sub = node_ref.subscribe_method_call("done_edit").unwrap();
let me2 = me.clone();
let done_task =
ex.spawn(async move { while Self::process_done_method(&me2, &method_sub).await {} });
let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
on_modify.when_change_external(self.items.clone(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.font_size.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.padding.clone(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.text_color.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.bg_color.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.sep_size.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.sep_color.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.fade_zone.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.role1_color.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.role1_group.clone(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.role2_color.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.role2_group.clone(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
on_modify.when_change_external(self.window_scale.prop(), |self_, _| async move {
self_.invalidate_draw();
self_.redraw.trigger();
});
let mut tasks = vec![motion_task, cancel_task, done_task];
tasks.append(&mut on_modify.tasks);
*self.tasks.lock() = tasks;
}
fn stop(&self) {
*self.tasks.lock() = vec![];
self.invalidate_draw();
}
async fn draw(
&self,
parent_rect: Rectangle,
atom: &mut PropertyAtomicGuard,
) -> Option {
*self.parent_rect.lock() = Some(parent_rect);
// Rect property is its own memo: compare before/after eval.
let prev_rect = self.rect.get();
self.rect.eval(atom, &parent_rect).ok()?;
let rect = self.rect.get();
let rect_changed = rect != prev_rect;
// The root shell is cheap: re-emit it every pass with the
// current scroll so scroll pokes don't invalidate content.
let scroll = self.scroll.load(Ordering::Relaxed);
// Background meshes are viewport-anchored, so they only depend
// on the rect and content height, never on scroll.
let mut bg = self.bg_meshes.lock();
if bg.is_none() || rect_changed {
*bg = Some(self.make_bg_meshes(&rect));
}
let bg_meshes = bg.clone();
drop(bg);
// Content is reassembled when stale or when the scroll moved:
// only fade-zone items and the separator mesh are rebuilt, the
// rest reuse cached instructions.
let mut cache = self.content_cache.lock();
let stale = match cache.as_ref() {
Some((cached_scroll, _)) => *cached_scroll != scroll,
None => true,
};
if stale || rect_changed {
*cache = Some((scroll, self.assemble_content(&rect, scroll)));
}
let instrs = cache.as_ref().unwrap().1.clone();
drop(cache);
let mut root_dcs = vec![];
let mut draw_calls = vec![(
self.root_dc_key,
DrawCall {
instrs: vec![
DrawInstruction::ApplyView(rect),
DrawInstruction::Move(Point::new(0., -scroll)),
],
dcs: vec![],
z_index: self.z_index.get(),
debug_str: "menu_root",
},
)];
if let Some((main_mesh, fade_mesh)) = bg_meshes {
root_dcs.push(self.bg_dc_key);
let mut bg_instrs = vec![
DrawInstruction::Move(Point::new(0., scroll)),
DrawInstruction::Draw(main_mesh),
];
if let Some(fade_mesh) = fade_mesh {
bg_instrs.push(DrawInstruction::Draw(fade_mesh));
}
draw_calls.push((
self.bg_dc_key,
DrawCall { instrs: bg_instrs, dcs: vec![], z_index: 0, debug_str: "menu_bg" },
));
}
root_dcs.push(self.content_dc_key);
draw_calls[0].1.dcs = root_dcs;
draw_calls.push((
self.content_dc_key,
DrawCall { instrs, dcs: vec![], z_index: 1, debug_str: "menu_content" },
));
Some(DrawUpdate { key: self.root_dc_key, draw_calls })
}
async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
if btn != MouseButton::Left {
return false
}
let rect = self.rect.get();
if !rect.contains(mouse_pos) {
return false
}
let is_edit_mode = self.is_edit_mode.load(Ordering::Relaxed);
if is_edit_mode {
let font_size = self.font_size.get();
let hammy_half_size = font_size * 1.4;
let hammy_center = rect.w - MENU_ICON_OFFSET - font_size * 0.56;
let hammy_min = hammy_center - hammy_half_size;
let hammy_max = hammy_center + hammy_half_size;
if mouse_pos.x >= hammy_min && mouse_pos.x <= hammy_max {
if let Some(item_idx) = self.get_selected_item_index(mouse_pos.y) {
*self.drag_info.lock() = Some(DragInfo { item_idx, insert_idx: item_idx });
info!(target: "app::menu", "Dragging item: {}", item_idx);
}
}
}
*self.mouse_click_info.lock() =
Some(MouseClickInfo { start_pos: mouse_pos, start_instant: std::time::Instant::now() });
// Spawn a task to detect long press
let me = self.me.clone();
let start_pos = mouse_pos;
let ex = self.ex.clone();
let long_press_task = ex.spawn(async move {
darkfi::system::msleep(long_press_timeout() as u64).await;
let Some(arc_self) = me.upgrade() else { return };
let current_mouse_pos = arc_self.mouse_pos.lock().clone();
let click_info = arc_self.mouse_click_info.lock().clone();
// Check if button is still held and movement is within threshold
if let Some(info) = click_info {
let movement_dist = ((current_mouse_pos.x - start_pos.x).powi(2) +
(current_mouse_pos.y - start_pos.y).powi(2))
.sqrt();
if movement_dist < LONG_PRESS_EPSILON {
// Long press detected, trigger edit mode
arc_self.save_items_layout();
arc_self.is_edit_mode.store(true, Ordering::Release);
let node = arc_self.node.upgrade().unwrap();
node.trigger("edit_active", vec![]).await.unwrap();
arc_self.invalidate_draw();
arc_self.redraw.trigger();
}
}
});
*self.long_press_task.lock() = Some(long_press_task);
false
}
async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
if btn != MouseButton::Left {
return false
}
// Apply drag reorder if we were dragging
let drag = self.drag_info.lock().take();
if let Some(drag_info) = drag {
if drag_info.item_idx != drag_info.insert_idx {
let item = self.items.get_str(drag_info.item_idx).unwrap();
let atom = &mut self.redraw.make_guard(gfxtag!("Menu::reorder_item"));
self.items.remove_str(atom, Role::App, drag_info.item_idx).unwrap();
let insert_idx = drag_info.insert_idx;
self.items.insert_str(atom, Role::App, insert_idx, &item).unwrap();
info!(target: "app::menu", "Reordered item {} to {}", drag_info.item_idx, insert_idx);
}
return true
}
// Cancel the long press detection task
let task = self.long_press_task.lock().take();
if let Some(task) = task {
task.cancel().await;
}
let click_info = self.mouse_click_info.lock().take();
let Some(info) = click_info else { return false };
let is_click = (mouse_pos.y - info.start_pos.y).abs() < BIG_EPSILON;
let movement_dist = ((mouse_pos.x - info.start_pos.x).powi(2) +
(mouse_pos.y - info.start_pos.y).powi(2))
.sqrt();
let is_long_press_tap = movement_dist < LONG_PRESS_EPSILON;
let elapsed = info.start_instant.elapsed().as_millis();
self.handle_interaction(mouse_pos, is_click, is_long_press_tap, elapsed).await;
true
}
async fn handle_mouse_wheel(&self, wheel_pos: Point) -> bool {
let rect = self.rect.get();
let mouse_pos = self.mouse_pos.lock().clone();
if !rect.contains(mouse_pos) {
return false
}
self.start_scroll(-wheel_pos.y);
true
}
async fn handle_mouse_move(&self, mouse_pos: Point) -> bool {
*self.mouse_pos.lock() = mouse_pos;
let mut should_redraw = false;
if self.drag_info.lock().is_some() {
if let Some(insert_idx) = self.get_selected_item_index(mouse_pos.y) {
let mut drag = self.drag_info.lock();
if let Some(d) = drag.as_mut() {
if d.insert_idx != insert_idx {
d.insert_idx = insert_idx;
info!(target: "app::menu", "insert_idx changed to: {}", insert_idx);
should_redraw = true;
}
}
}
}
if should_redraw {
self.invalidate_draw();
self.redraw.trigger();
}
false
}
fn handle_touch_sync(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
if id != 0 {
return false
}
match phase {
TouchPhase::Started => {
let rect = self.rect.get();
if !rect.contains(touch_pos) {
*self.touch_info.lock() = None;
return false
}
let is_edit_mode = self.is_edit_mode.load(Ordering::Relaxed);
if is_edit_mode {
let font_size = self.font_size.get();
let hammy_half_size = font_size * 2.0;
let hammy_center = rect.w - MENU_ICON_OFFSET - font_size * 0.56;
let hammy_min = hammy_center - hammy_half_size;
let hammy_max = hammy_center + hammy_half_size;
if touch_pos.x >= hammy_min && touch_pos.x <= hammy_max {
if let Some(item_idx) = self.get_selected_item_index(touch_pos.y) {
*self.drag_info.lock() =
Some(DragInfo { item_idx, insert_idx: item_idx });
info!(target: "app::menu", "Dragging item: {}", item_idx);
}
}
}
*self.touch_info.lock() =
Some(TouchInfo::new(self.scroll.load(Ordering::Relaxed), touch_pos));
// Spawn a task to detect long press while the touch is
// still held, mirroring the mouse path.
let me = self.me.clone();
let start_pos = touch_pos;
let ex = self.ex.clone();
let long_press_task = ex.spawn(async move {
darkfi::system::msleep(long_press_timeout() as u64).await;
let Some(arc_self) = me.upgrade() else { return };
let touch_info = arc_self.touch_info.lock().clone();
let Some(info) = touch_info else { return };
let movement_dist = ((info.last_pos.x - start_pos.x).powi(2) +
(info.last_pos.y - start_pos.y).powi(2))
.sqrt();
if movement_dist < LONG_PRESS_EPSILON &&
!arc_self.is_edit_mode.load(Ordering::Relaxed)
{
arc_self.save_items_layout();
arc_self.is_edit_mode.store(true, Ordering::Release);
let node = arc_self.node.upgrade().unwrap();
node.trigger("edit_active", vec![]).await.unwrap();
arc_self.invalidate_draw();
arc_self.redraw.trigger();
}
});
*self.long_press_task.lock() = Some(long_press_task);
true
}
TouchPhase::Moved => {
let mut should_redraw = false;
if self.drag_info.lock().is_some() {
if let Some(insert_idx) = self.get_selected_item_index(touch_pos.y) {
let mut drag = self.drag_info.lock();
if let Some(d) = drag.as_mut() {
if d.insert_idx != insert_idx {
d.insert_idx = insert_idx;
info!(target: "app::menu", "insert_idx changed to: {}", insert_idx);
should_redraw = true;
}
}
}
}
if should_redraw {
self.invalidate_draw();
self.redraw.trigger();
}
let scroll = {
let mut touch_info = self.touch_info.lock();
let Some(info) = &mut *touch_info else { return false };
info.last_pos = touch_pos;
info.push_sample(touch_pos.y);
let last_elapsed = info.last_instant.elapsed().as_millis();
if last_elapsed <= 20 {
return true
}
info.last_instant = std::time::Instant::now();
let dist = touch_pos.y - info.start_pos.y;
if dist.abs() < BIG_EPSILON {
return true
}
info.start_scroll - dist
};
self.scrollview(scroll);
self.redraw.trigger();
true
}
// Use async handler instead
TouchPhase::Ended | TouchPhase::Cancelled => false,
}
}
async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
if id != 0 {
return false
}
match phase {
// Should be handled by handle_touch_sync
TouchPhase::Started | TouchPhase::Moved => false,
TouchPhase::Ended | TouchPhase::Cancelled => {
// Cancel the long press detection task
let task = self.long_press_task.lock().take();
if let Some(task) = task {
task.cancel().await;
}
let drag = self.drag_info.lock().take();
if let Some(drag_info) = drag {
if drag_info.item_idx != drag_info.insert_idx {
let item = self.items.get_str(drag_info.item_idx).unwrap();
let atom = &mut self.redraw.make_guard(gfxtag!("Menu::reorder_item"));
self.items.remove_str(atom, Role::App, drag_info.item_idx).unwrap();
let insert_idx = drag_info.insert_idx;
self.items.insert_str(atom, Role::App, insert_idx, &item).unwrap();
info!(target: "app::menu", "Reordered item {} to {}", drag_info.item_idx, insert_idx);
}
return true
}
let (is_tap, is_long_press_tap, elapsed) = {
let touch_info = self.touch_info.lock();
let Some(info) = &*touch_info else { return true };
let is_tap = (touch_pos.y - info.start_pos.y).abs() < BIG_EPSILON;
let movement_dist = ((touch_pos.x - info.start_pos.x).powi(2) +
(touch_pos.y - info.start_pos.y).powi(2))
.sqrt();
let is_long_press_tap = movement_dist < LONG_PRESS_EPSILON;
let elapsed = info.start_instant.elapsed().as_millis();
(is_tap, is_long_press_tap, elapsed)
};
self.handle_interaction(touch_pos, is_tap, is_long_press_tap, elapsed).await;
self.end_touch_phase(touch_pos.y);
true
}
}
}
}