From 843911e94b6934cd59bb7ca56f79a294e7ca042a Mon Sep 17 00:00:00 2001 From: jkds Date: Sun, 25 Jan 2026 09:35:40 +0100 Subject: [PATCH] app/menu: long click hold show buttons to edit entries --- bin/app/src/app/node.rs | 6 ++ bin/app/src/app/schema/menu.rs | 4 + bin/app/src/ui/{menu.rs => menu/mod.rs} | 126 +++++++++++++++++++----- bin/app/src/ui/menu/shape.rs | 108 ++++++++++++++++++++ 4 files changed, 222 insertions(+), 22 deletions(-) rename bin/app/src/ui/{menu.rs => menu/mod.rs} (83%) create mode 100644 bin/app/src/ui/menu/shape.rs diff --git a/bin/app/src/app/node.rs b/bin/app/src/app/node.rs index c5e662878..0b676b77d 100644 --- a/bin/app/src/app/node.rs +++ b/bin/app/src/app/node.rs @@ -627,6 +627,12 @@ pub fn create_menu(name: &str) -> SceneNode { prop.set_range_f32(0., f32::MAX); node.add_property(prop).unwrap(); + let mut prop = Property::new("handle_padding", PropertyType::Float32, PropertySubType::Pixel); + prop.set_ui_text("Handle Padding", "X handle padding from left edge"); + prop.set_defaults_f32(vec![14.0]).unwrap(); + prop.set_range_f32(0., f32::MAX); + node.add_property(prop).unwrap(); + let mut prop = Property::new("text_color", PropertyType::Float32, PropertySubType::Color); prop.set_ui_text("Text Color", "Text color (RGBA)"); prop.set_array_len(4); diff --git a/bin/app/src/app/schema/menu.rs b/bin/app/src/app/schema/menu.rs index 5df407292..67b394de5 100644 --- a/bin/app/src/app/schema/menu.rs +++ b/bin/app/src/app/schema/menu.rs @@ -43,6 +43,7 @@ mod android_ui_consts { pub const CHANNEL_LABEL_LINESPACE: f32 = 140.; pub const CHANNEL_LABEL_FONTSIZE: f32 = 44.; pub const MENU_SEP_SIZE: f32 = 3.; + pub const MENU_HANDLE_PAD: f32 = 100.; pub const VERBLOCK_SCALE: f32 = 150.; pub const VERBLOCK_X: f32 = 180.; pub const VERBLOCK_Y: f32 = 80.; @@ -68,6 +69,7 @@ mod ui_consts { pub const CHANNEL_LABEL_LINESPACE: f32 = 60.; pub const CHANNEL_LABEL_FONTSIZE: f32 = 22.; pub const MENU_SEP_SIZE: f32 = 1.; + pub const MENU_HANDLE_PAD: f32 = 50.; pub const VERBLOCK_SCALE: f32 = 80.; pub const VERBLOCK_X: f32 = 110.; pub const VERBLOCK_Y: f32 = 50.; @@ -221,6 +223,8 @@ pub async fn make(app: &App, content: SceneNodePtr, i18n_fish: &I18nBabelFish) { prop.set_f32(atom, Role::App, 0, CHANNEL_LABEL_X).unwrap(); prop.set_f32(atom, Role::App, 1, CHANNEL_LABEL_LINESPACE / 2.).unwrap(); + node.set_property_f32(atom, Role::App, "handle_padding", MENU_HANDLE_PAD).unwrap(); + let prop = node.get_property("items").unwrap(); for channel in CHANNELS { let label = "#".to_string() + channel; diff --git a/bin/app/src/ui/menu.rs b/bin/app/src/ui/menu/mod.rs similarity index 83% rename from bin/app/src/ui/menu.rs rename to bin/app/src/ui/menu/mod.rs index a591c2981..6280ba9ad 100644 --- a/bin/app/src/ui/menu.rs +++ b/bin/app/src/ui/menu/mod.rs @@ -45,8 +45,11 @@ use crate::{ use super::{DrawUpdate, OnModify, UIObject}; +mod shape; + const EPSILON: f32 = 0.001; const BIG_EPSILON: f32 = 0.05; +const LONG_PRESS_EPSILON: f32 = 5.0; macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::menu", $($arg)*); } } @@ -59,22 +62,28 @@ enum ItemStatus { #[derive(Clone)] struct TouchInfo { start_scroll: f32, - start_y: f32, + start_pos: Point, start_instant: std::time::Instant, samples: VecDeque<(std::time::Instant, f32)>, last_instant: std::time::Instant, last_y: f32, } +#[derive(Clone)] +struct MouseClickInfo { + start_pos: Point, + start_instant: std::time::Instant, +} + impl TouchInfo { - fn new(start_scroll: f32, y: f32) -> Self { + fn new(start_scroll: f32, pos: Point) -> Self { Self { start_scroll, - start_y: y, + start_pos: pos, start_instant: std::time::Instant::now(), - samples: VecDeque::from([(std::time::Instant::now(), y)]), + samples: VecDeque::from([(std::time::Instant::now(), pos.y)]), last_instant: std::time::Instant::now(), - last_y: y, + last_y: pos.y, } } @@ -112,6 +121,7 @@ pub struct Menu { font_size: PropertyFloat32, padding: PropertyPtr, + handle_padding: PropertyFloat32, text_color: PropertyColor, bg_color: PropertyColor, sep_size: PropertyFloat32, @@ -122,10 +132,12 @@ pub struct Menu { mouse_pos: SyncMutex, touch_info: SyncMutex>, + mouse_click_info: SyncMutex>, scroll_start_accel: PropertyFloat32, scroll_resist: PropertyFloat32, motion_cv: Arc, speed: AtomicF32, + is_edit_mode: AtomicBool, parent_rect: SyncMutex>, item_states: SyncMutex>, @@ -146,6 +158,8 @@ impl Menu { 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(); @@ -174,6 +188,7 @@ impl Menu { items, font_size, padding, + handle_padding, text_color, bg_color, sep_size, @@ -183,10 +198,12 @@ impl Menu { window_scale, mouse_pos: SyncMutex::new(Point::new(0., 0.)), touch_info: SyncMutex::new(None), + mouse_click_info: SyncMutex::new(None), scroll_start_accel, scroll_resist, motion_cv, speed: AtomicF32::new(0.), + is_edit_mode: AtomicBool::new(false), parent_rect: SyncMutex::new(None), item_states: SyncMutex::new(HashMap::new()), }); @@ -229,6 +246,26 @@ impl Menu { } } + async fn handle_interaction( + &self, + y: f32, + is_tap: bool, + is_long_press_tap: bool, + elapsed_ms: u128, + ) { + let is_long_press = is_long_press_tap && elapsed_ms >= 500; + + if is_long_press { + self.is_edit_mode.store(true, Ordering::Release); + let atom = &mut self.renderer.make_guard(gfxtag!("Menu::long_press")); + self.redraw(atom); + } else if is_tap { + if let Some(item_idx) = self.get_selected_item_index(y) { + self.handle_selection(item_idx).await; + } + } + } + fn get_draw_calls( &self, atom: &mut PropertyAtomicGuard, @@ -244,6 +281,7 @@ impl Menu { 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 active_color = self.active_color.get(); let alert_color = self.alert_color.get(); @@ -269,6 +307,25 @@ impl Menu { let sep_mesh = sep_mesh.alloc(&self.renderer).draw_untextured(); let item_states = self.item_states.lock(); + let is_edit_mode = self.is_edit_mode.load(Ordering::Relaxed); + let edit_offset = if is_edit_mode { 100.0 } else { 0.0 }; + + // Create X mesh for edit mode + let x_mesh = + if is_edit_mode { Some(shape::make_x(&self.renderer, font_size)) } else { None }; + + let mut edit_instrs = vec![]; + if is_edit_mode { + let item_center_y = item_height / 2.0; + edit_instrs.push(DrawInstruction::Move(Point::new(handle_padding, item_center_y))); + edit_instrs.push(DrawInstruction::Draw(shape::make_x(&self.renderer, font_size))); + edit_instrs.push(DrawInstruction::Move(Point::new(-handle_padding, -item_center_y))); + + let rhs = rect.w - handle_padding; + 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))); + } for idx in 0..num_items { let item_text = self.items.get_str(idx).unwrap(); @@ -279,6 +336,8 @@ impl Menu { _ => text_color, }; + instrs.append(&mut edit_instrs.clone()); + // Draw text let layout = text::make_layout( &item_text, @@ -292,9 +351,12 @@ impl Menu { let text_instr = text::render_layout(&layout, &self.renderer, gfxtag!("menu_text")); - instrs.push(DrawInstruction::Move(Point::new(padding_x, padding_y))); + instrs.push(DrawInstruction::Move(Point::new(padding_x + edit_offset, padding_y))); instrs.extend(text_instr); - instrs.push(DrawInstruction::Move(Point::new(-padding_x, font_size + padding_y))); + instrs.push(DrawInstruction::Move(Point::new( + -padding_x - edit_offset, + font_size + padding_y, + ))); // Draw separator (except for last item) if idx < num_items - 1 { @@ -405,7 +467,7 @@ impl Menu { if let Some((dt, _)) = info.first_sample() { if dt > EPSILON { - let velocity = (touch_y - info.start_y) / dt; + let velocity = (touch_y - info.start_pos.y) / dt; self.start_scroll(-velocity); } } @@ -559,12 +621,30 @@ impl UIObject for Menu { return false } - if let Some(item_idx) = self.get_selected_item_index(mouse_pos.y) { - self.handle_selection(item_idx).await; - true - } else { - false + *self.mouse_click_info.lock() = + Some(MouseClickInfo { start_pos: mouse_pos, start_instant: std::time::Instant::now() }); + + false + } + + async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool { + if btn != MouseButton::Left { + return false } + + 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.y, is_click, is_long_press_tap, elapsed).await; + + true } async fn handle_mouse_wheel(&self, wheel_pos: Point) -> bool { @@ -604,7 +684,7 @@ impl UIObject for Menu { } *self.touch_info.lock() = - Some(TouchInfo::new(self.scroll.load(Ordering::Relaxed), touch_pos.y)); + Some(TouchInfo::new(self.scroll.load(Ordering::Relaxed), touch_pos)); true } @@ -622,7 +702,7 @@ impl UIObject for Menu { } info.last_instant = std::time::Instant::now(); - let dist = touch_pos.y - info.start_y; + let dist = touch_pos.y - info.start_pos.y; if dist.abs() < BIG_EPSILON { return true } @@ -650,18 +730,20 @@ impl UIObject for Menu { TouchPhase::Started | TouchPhase::Moved => false, TouchPhase::Ended | TouchPhase::Cancelled => { - let is_tap = { + let (is_tap, is_long_press_tap, elapsed) = { let touch_info = self.touch_info.lock(); let Some(info) = &*touch_info else { return true }; - (touch_pos.y - info.start_y).abs() < BIG_EPSILON + 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) }; - if is_tap { - if let Some(item_idx) = self.get_selected_item_index(touch_pos.y) { - self.handle_selection(item_idx).await; - } - } + self.handle_interaction(touch_pos.y, is_tap, is_long_press_tap, elapsed).await; self.end_touch_phase(touch_pos.y); true diff --git a/bin/app/src/ui/menu/shape.rs b/bin/app/src/ui/menu/shape.rs new file mode 100644 index 000000000..8a37230a8 --- /dev/null +++ b/bin/app/src/ui/menu/shape.rs @@ -0,0 +1,108 @@ +/* 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 crate::{ + gfx::{gfxtag, DrawMesh, Renderer, Vertex}, + mesh::{Color, MeshBuilder}, +}; + +const X_COLOR: Color = [1.0, 0.0, 0.0, 1.0]; + +pub fn make_x(renderer: &Renderer, font_size: f32) -> DrawMesh { + let x_size = font_size * 0.8; + let half_size = x_size / 2.0; + let thickness = 2.0; + let half_thick = thickness / 2.0; + + let mut mesh = MeshBuilder::new(gfxtag!("menu_x")); + + // First diagonal line (top-left to bottom-right) + // Diagonal from (-half_size, -half_size) to (half_size, half_size) + // Normal vector is (1, -1) normalized + let mut diag_start = [-half_size - half_thick, -half_size + half_thick]; + let mut diag_end = [half_size - half_thick, half_size + half_thick]; + let mut diag_start2 = [-half_size + half_thick, -half_size - half_thick]; + let mut diag_end2 = [half_size + half_thick, half_size - half_thick]; + + let verts1 = vec![ + Vertex { pos: diag_start, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_start2, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end2, color: X_COLOR, uv: [0., 0.] }, + ]; + mesh.append(verts1, vec![0, 2, 1, 1, 2, 3]); + + // Second diagonal line (bottom-left to top-right) + // Diagonal from (-half_size, half_size) to (half_size, -half_size) + diag_start[0] *= -1.; + diag_end[0] *= -1.; + diag_start2[0] *= -1.; + diag_end2[0] *= -1.; + + let verts2 = vec![ + Vertex { pos: diag_start, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_start2, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end2, color: X_COLOR, uv: [0., 0.] }, + ]; + mesh.append(verts2, vec![0, 2, 1, 1, 2, 3]); + + mesh.alloc(renderer).draw_untextured() +} + +pub fn make_hammy(renderer: &Renderer, font_size: f32) -> DrawMesh { + let x_size = font_size * 0.8; + let half_size = x_size / 2.0; + let thickness = 2.0; + let half_thick = thickness / 2.0; + + let mut mesh = MeshBuilder::new(gfxtag!("menu_x")); + + // First diagonal line (top-left to bottom-right) + // Diagonal from (-half_size, -half_size) to (half_size, half_size) + // Normal vector is (1, -1) normalized + let mut diag_start = [-half_size - half_thick, -half_size + half_thick]; + let mut diag_end = [half_size - half_thick, half_size + half_thick]; + let mut diag_start2 = [-half_size + half_thick, -half_size - half_thick]; + let mut diag_end2 = [half_size + half_thick, half_size - half_thick]; + + let verts1 = vec![ + Vertex { pos: diag_start, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_start2, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end2, color: X_COLOR, uv: [0., 0.] }, + ]; + mesh.append(verts1, vec![0, 2, 1, 1, 2, 3]); + + // Second diagonal line (bottom-left to top-right) + // Diagonal from (-half_size, half_size) to (half_size, -half_size) + diag_start[0] *= -1.; + diag_end[0] *= -1.; + diag_start2[0] *= -1.; + diag_end2[0] *= -1.; + + let verts2 = vec![ + Vertex { pos: diag_start, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_start2, color: X_COLOR, uv: [0., 0.] }, + Vertex { pos: diag_end2, color: X_COLOR, uv: [0., 0.] }, + ]; + mesh.append(verts2, vec![0, 2, 1, 1, 2, 3]); + + mesh.alloc(renderer).draw_untextured() +}