app/menu: long click hold show buttons to edit entries

This commit is contained in:
jkds
2026-01-25 09:35:40 +01:00
parent 689212f6a4
commit 843911e94b
4 changed files with 222 additions and 22 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<Point>,
touch_info: SyncMutex<Option<TouchInfo>>,
mouse_click_info: SyncMutex<Option<MouseClickInfo>>,
scroll_start_accel: PropertyFloat32,
scroll_resist: PropertyFloat32,
motion_cv: Arc<CondVar>,
speed: AtomicF32,
is_edit_mode: AtomicBool,
parent_rect: SyncMutex<Option<Rectangle>>,
item_states: SyncMutex<HashMap<String, ItemStatus>>,
@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}