darkfi 1 неделя назад
Родитель
Сommit
784ab3c5f3

BIN
bin/app/data/sfx/tick.oga


+ 113 - 0
bin/app/src/app/node.rs

@@ -311,6 +311,119 @@ pub fn create_slider(name: &str) -> SceneNode {
     node
 }
 
+pub fn create_dropdown(name: &str) -> SceneNode {
+    let mut node = SceneNode::new(name, SceneNodeType::Dropdown);
+
+    let mut prop =
+        Property::new("is_active", PropertyType::Bool, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Is Active", "An active Dropdown can be interacted with");
+    prop.set_defaults_bool(vec![false]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "rect",
+        PropertyType::Float32,
+        PropertySubType::Pixel,
+        PropertyPermission::default(),
+    );
+    prop.set_array_len(4);
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("items", PropertyType::Str, PropertySubType::Null, PERM_APP);
+    prop.set_ui_text("Items", "Selectable items");
+    prop.set_unbounded();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("selected", PropertyType::Uint32, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Selected", "Index of the selected item");
+    prop.set_defaults_u32(vec![0]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("item_height", PropertyType::Float32, PropertySubType::Pixel, PERM_STYLE);
+    prop.set_ui_text("Item Height", "Height of one list row in pixels");
+    prop.set_defaults_f32(vec![40.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("list_width", PropertyType::Float32, PropertySubType::Pixel, PERM_STYLE);
+    prop.set_ui_text("List Width", "Open list width in pixels (0 = match the box width)");
+    prop.set_defaults_f32(vec![0.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("box_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Box Color", "Closed box and list background color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0., 0.12, 0.08, 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("outline_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Outline Color", "Box and list outline color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0.08, 0.68, 0.72, 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("selected_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Selected Color", "Selected item highlight color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0.08, 0.68, 0.72, 0.25]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("hover_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Hover Color", "Hovered item highlight color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0., 0.22, 0.16, 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("text_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Text Color", "Item label color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0.90, 0.90, 0.90, 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("font_size", PropertyType::Float32, PropertySubType::Pixel, PERM_STYLE);
+    prop.set_ui_text("Font Size", "Item label font size in pixels");
+    prop.set_defaults_f32(vec![14.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("z_index", PropertyType::Uint32, PropertySubType::Null, PERM_APP);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("priority", PropertyType::Uint32, PropertySubType::Null, PERM_APP);
+    node.add_property(prop).unwrap();
+
+    let prop = Property::new("debug", PropertyType::Bool, PropertySubType::Null, PERM_APP);
+    node.add_property(prop).unwrap();
+
+    node.add_signal("opened", "The list was opened", vec![]).unwrap();
+    node.add_signal("closed", "The list was closed", vec![]).unwrap();
+
+    node.add_signal(
+        "selection_changed",
+        "An item was selected",
+        vec![
+            ("index", "Selected item index", CallArgType::Uint32),
+            ("item", "Selected item", CallArgType::Str),
+        ],
+    )
+    .unwrap();
+
+    node
+}
+
 pub fn create_shortcut(name: &str) -> SceneNode {
     let mut node = SceneNode::new(name, SceneNodeType::Shortcut);
 

+ 78 - 81
bin/app/src/app/schema/settings.rs

@@ -19,8 +19,8 @@
 use crate::{
     app::{
         node::{
-            create_button, create_layer, create_singleline_edit, create_slider, create_text,
-            create_vector_art,
+            create_button, create_dropdown, create_layer, create_singleline_edit, create_slider,
+            create_text, create_vector_art,
         },
         App,
     },
@@ -34,12 +34,13 @@ use crate::{
     shape,
     theme::wire_color,
     ui::{
-        BaseEdit, BaseEditType, Button, Layer, ShapeVertex, Slider, Text, VectorArt, VectorShape,
+        BaseEdit, BaseEditType, Button, Dropdown, Layer, ShapeVertex, Slider, Text, VectorArt,
+        VectorShape,
     },
     util::i18n::I18nBabelFish,
 };
 
-use darkfi_serial::deserialize;
+use darkfi_serial::{deserialize, Decodable};
 
 use std::{
     collections::BTreeMap,
@@ -135,8 +136,6 @@ impl Setting {
     fn value_as_string(&self) -> String {
         match &self.prop.get_value(0).ok().unwrap() {
             PropertyValue::Str(s) => s.clone(),
-            // Enum values render as their item name (e.g. "scifi",
-            // "tcp") instead of "unknown".
             PropertyValue::Enum(s) => s.clone(),
             PropertyValue::Uint32(i) => i.to_string(),
             PropertyValue::Bool(b) => {
@@ -150,7 +149,8 @@ impl Setting {
             _ => "unknown".to_string(),
         }
     }
-    fn get_value(&self) -> PropertyValue {        self.prop.get_value(0).ok().unwrap()
+    fn get_value(&self) -> PropertyValue {
+        self.prop.get_value(0).ok().unwrap()
     }
     fn get_default(&self) -> PropertyValue {
         let def = self.prop.defaults.lock().unwrap()[0].clone();
@@ -505,8 +505,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         // Set the `rect` property for each found node
         for (i, node) in found_nodes.iter().enumerate() {
             let prop = node.get_property("rect").unwrap();
-            let y =
-                i as f32 * SETTING_LABEL_LINESPACE + 2. * SEARCH_BAR_Y;
+            let y = i as f32 * SETTING_LABEL_LINESPACE + 2. * SEARCH_BAR_Y;
             prop.set_default_f32(1, y).unwrap();
         }
 
@@ -670,7 +669,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
     layer_node.link(settings_layer_node.clone());
 
     // Iterate over the map and process each setting
-    for setting in settings_map.values() {
+    for (row_idx, setting) in settings_map.values().enumerate() {
         let setting_clone = setting.clone();
         let setting_name = setting_clone.name.clone();
         let is_bool = matches!(setting_clone.get_value(), PropertyValue::Bool(_));
@@ -688,6 +687,9 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         prop.set_default_f32(3, SETTING_LABEL_LINESPACE).unwrap();
         setting_layer_node.set_property_bool(atom, Role::App, "is_visible", true).unwrap();
         setting_layer_node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
+        setting_layer_node
+            .set_property_u32(atom, Role::App, "priority", (settings_map.len() - row_idx) as u32)
+            .unwrap();
         let setting_layer_node = setting_layer_node
             .setup(|me| Layer::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
             .await;
@@ -932,7 +934,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         .unwrap();
         prop.set_default_f32(3, 100.).unwrap();
         label_value_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-            prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
+        prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
         label_value_node
             .set_property_f32(atom, Role::App, "font_size", SETTING_LABEL_FONTSIZE)
             .unwrap();
@@ -1067,21 +1069,36 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
             setting_layer_node.link(node);
         } else {
             editz_text = None;
-            // Enum settings render as a cycle-on-tap row: tapping the
-            // value advances to the next item. Written as Role::User so
-            // the Setting pimpl persists it; the theme engine's watcher
-            // live-switches on `theme` changes.
             let value_prop = setting_clone.prop.clone();
             let items = value_prop.enum_items.clone().unwrap_or_default();
+            let current = match setting_clone.get_value() {
+                PropertyValue::Enum(cur) => cur,
+                _ => String::new(),
+            };
+            let selected = items.iter().position(|item| *item == current).unwrap_or(0);
 
-            let node = create_text("enum_value_label");
+            let node = create_dropdown("value_dropdown");
+            node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+            {
+                let prop = node.get_property("items").unwrap();
+                prop.set_str_vec(
+                    atom,
+                    Role::App,
+                    items.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
+                )
+                .unwrap();
+            }
+            node.set_property_u32(atom, Role::App, "selected", selected as u32).unwrap();
+            node.set_property_f32(atom, Role::App, "item_height", SETTING_LABEL_LINESPACE).unwrap();
+            node.set_property_f32(atom, Role::App, "list_width", 0.).unwrap();
+            node.set_property_f32(atom, Role::App, "font_size", SETTING_EDIT_FONTSIZE).unwrap();
             let prop = node.get_property("rect").unwrap();
             prop.clone()
                 .set_expr(
                     atom,
                     Role::App,
                     0,
-                    cc.compile("w * X_RATIO + BORDER_RIGHT_SCALE").unwrap(),
+                    cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE").unwrap(),
                 )
                 .unwrap();
             prop.set_default_f32(1, 0.).unwrap();
@@ -1090,82 +1107,45 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                     atom,
                     Role::App,
                     2,
-                    cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE").unwrap(),
+                    cc.compile("w * (1-X_RATIO) + BORDER_RIGHT_SCALE").unwrap(),
                 )
                 .unwrap();
             prop.set_default_f32(3, SETTING_LABEL_LINESPACE).unwrap();
-                    prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
-            node.get_property("font_size")
-                .unwrap()
-                .set_default_f32(0, SETTING_EDIT_FONTSIZE)
-                .unwrap();
-            node.get_property("text_color")
-                .unwrap()
-                .set_default_f32_multi(&[0.92, 0.92, 0.92, 1.])
-                .unwrap();
-            node.set_property_str(atom, Role::App, "text", setting_clone.value_as_string())
-                .unwrap();
-            node.set_property_enum(atom, Role::App, "text_align", "start").unwrap();
             node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
             let node = node
                 .setup(|me| {
-                    Text::new(
+                    Dropdown::new(
                         me,
-                        window_scale.clone(),
                         app.renderer.clone(),
-                        i18n_fish.clone(),
                         app.redraw_trigger.clone(),
+                        window_scale.clone(),
                     )
                 })
                 .await;
             setting_layer_node.link(node.clone());
 
-            // Cycle button overlaying the value area
-            let btn = create_button("enum_cycle_btn");
-            let prop = btn.get_property("rect").unwrap();
-            prop.clone()
-                .set_expr(
-                    atom,
-                    Role::App,
-                    0,
-                    cc.compile("w * X_RATIO + BORDER_RIGHT_SCALE").unwrap(),
-                )
-                .unwrap();
-            prop.set_default_f32(1, 0.).unwrap();
-            prop.clone()
-                .set_expr(
-                    atom,
-                    Role::App,
-                    2,
-                    cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE").unwrap(),
-                )
-                .unwrap();
-            prop.set_default_f32(3, SETTING_LABEL_LINESPACE).unwrap();
-            btn.set_property_bool(atom, Role::App, "is_active", true).unwrap();
-            let (slot, recvr) = Slot::new("enum_cycle");
-            btn.register("click", slot).unwrap();
+            let (slot, selection_recvr) = Slot::new("dropdown_selection");
+            node.register("selection_changed", slot).unwrap();
             let redraw = app.redraw_trigger.clone();
-            let label = node.get_property("text").unwrap();
             let value_prop2 = value_prop.clone();
-            let items2 = items.clone();
             let setting2 = setting.clone();
             let row_root2 = setting_layer_node.clone();
-            let cycle_task = app.ex.spawn(async move {
-                while let Ok(_) = recvr.recv().await {
-                    let current = label.get_str(0).unwrap_or_default();
-                    let Some(pos) = items2.iter().position(|i| *i == current) else { continue };
-                    let next = items2[(pos + 1) % items2.len()].clone();
-                    let atom = &mut redraw.make_guard(gfxtag!("enum cycle"));
-                    value_prop2.set_enum(atom, Role::User, 0, next.clone()).unwrap();
-                    label.set_str(atom, Role::App, 0, next).unwrap();
+            let select_task = app.ex.spawn(async move {
+                while let Ok(data) = selection_recvr.recv().await {
+                    let Some((idx, item)) = decode_selection_payload(&data) else {
+                        error!(target: "app::settings", "dropdown: bad selection payload");
+                        continue
+                    };
+                    let _ = idx;
+                    let atom = &mut redraw.make_guard(gfxtag!("dropdown selection"));
+                    if let Err(err) = value_prop2.set_enum(atom, Role::User, 0, item) {
+                        error!(target: "app::settings", "dropdown: failed to set enum: {err}");
+                        continue
+                    }
                     refresh_setting(setting2.clone(), row_root2.clone());
                 }
             });
-            setting_layer_node.push_task(cycle_task);
-            let btn = btn
-                .setup(|me| Button::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
-                .await;
-            setting_layer_node.link(btn);
+            setting_layer_node.push_task(select_task);
         }
 
         if setting_name == "win.scale" {
@@ -1306,7 +1286,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                     .unwrap();
                 prop.set_default_f32(3, 100.).unwrap();
                 value_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-                            prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
+                prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
                 value_node
                     .set_property_f32(atom, Role::App, "font_size", SETTING_LABEL_FONTSIZE)
                     .unwrap();
@@ -1357,7 +1337,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                     .unwrap();
                 prop.set_default_f32(3, 100.).unwrap();
                 value_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
-                            prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
+                prop.set_default_f32(1, SETTING_LABEL_Y).unwrap();
                 value_node
                     .set_property_f32(atom, Role::App, "font_size", SETTING_LABEL_FONTSIZE)
                     .unwrap();
@@ -1719,9 +1699,8 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                         text.set(atom, setting2.value_as_string());
                     }
 
-                    if let Some(node) = sg_root2.lookup_node("/enum_value_label") {
-                        let text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
-                        text.set(atom, setting2.value_as_string());
+                    if let Some(node) = sg_root2.lookup_node("/value_dropdown") {
+                        sync_dropdown(&node, &setting2, atom);
                     }
 
                     if let Some(node) = sg_root2.lookup_node("/value_editbox") {
@@ -1761,10 +1740,8 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                     let Some(row) = sg_root2.lookup_node(&path) else { continue };
                     if setting.is_bool() {
                         refresh_bool_row(setting, &row, atom);
-                    } else if let Some(label) = row.lookup_node("/enum_value_label") {
-                            label
-                            .set_property_str(atom, Role::App, "text", setting.value_as_string())
-                            .unwrap();
+                    } else if let Some(dropdown) = row.lookup_node("/value_dropdown") {
+                        sync_dropdown(&dropdown, setting, atom);
                     } else if let Some(label) = row.lookup_node("/value_label") {
                         label
                             .set_property_str(atom, Role::App, "text", setting.value_as_string())
@@ -1820,6 +1797,26 @@ fn spawn_win_scale_listener(
     app.tasks.lock().push(task);
 }
 
+fn sync_dropdown(dropdown: &SceneNodePtr, setting: &Setting, atom: &mut PropertyAtomicGuard) {
+    let items = setting.prop.enum_items.clone().unwrap_or_default();
+    let current = match setting.get_value() {
+        PropertyValue::Enum(cur) => cur,
+        _ => return,
+    };
+    let selected = items.iter().position(|item| *item == current).unwrap_or(0) as u32;
+
+    let prop = dropdown.get_property("items").unwrap();
+    prop.set_str_vec(atom, Role::App, items).unwrap();
+    dropdown.set_property_u32(atom, Role::App, "selected", selected).unwrap();
+}
+
+fn decode_selection_payload(data: &[u8]) -> Option<(u32, String)> {
+    let mut cur = std::io::Cursor::new(data);
+    let idx = u32::decode(&mut cur).ok()?;
+    let item = String::decode(&mut cur).ok()?;
+    Some((idx, item))
+}
+
 fn reset_win_scale_row(
     setting: &Setting,
     row_root: &SceneNodePtr,

+ 2 - 1
bin/app/src/net.rs

@@ -55,7 +55,8 @@ fn stop_ui_subtree(node: &SceneNodePtr) {
             Pimpl::Shortcut(_) |
             Pimpl::Menu(_) |
             Pimpl::TokenTable(_) |
-            Pimpl::Slider(_)
+            Pimpl::Slider(_) |
+            Pimpl::Dropdown(_)
     ) {
         get_ui_object3(node).stop();
     }

+ 2 - 0
bin/app/src/scene.rs

@@ -125,6 +125,7 @@ pub enum SceneNodeType {
     DateMsgNode = 27,
     FileMsgNode = 28,
     Slider = 29,
+    Dropdown = 30,
     PluginRoot = 100,
     Plugin = 101,
 }
@@ -621,6 +622,7 @@ pub enum Pimpl {
     DateMsgNode(ui::chatview::msg::DateMsgNodePtr),
     FileMsgNode(ui::chatview::msg::FileMsgNodePtr),
     Slider(ui::SliderPtr),
+    Dropdown(ui::DropdownPtr),
     #[cfg(feature = "enable-plugin-darkirc")]
     DarkIrc(plugin::DarkIrcPtr),
     #[cfg(feature = "enable-plugin-fud")]

+ 11 - 0
bin/app/src/sfx.rs

@@ -44,6 +44,7 @@ macro_rules! e { ($($arg:tt)*) => { error!(target: "app::sfx", $($arg)*); } }
 static CLICK_OGA: &[u8] = include_bytes!("../data/sfx/click.oga");
 static COMMUP_OGA: &[u8] = include_bytes!("../data/sfx/commup.oga");
 static CLOAK_OGA: &[u8] = include_bytes!("../data/sfx/cloak.oga");
+static TICK_OGA: &[u8] = include_bytes!("../data/sfx/tick.oga");
 
 /// Idle window after the last sound before the stream is paused.
 /// Pausing flushes buffered audio, so it must exceed the duration of
@@ -62,6 +63,8 @@ struct Sfx {
     commup: rodio::buffer::SamplesBuffer,
     /// Used for showing p2p overlay
     cloak: rodio::buffer::SamplesBuffer,
+    /// Dropdown item hover blip
+    tick: rodio::buffer::SamplesBuffer,
     /// Signals playback activity to the idle reaper thread
     #[cfg(any(target_os = "android", feature = "emulate-android"))]
     activity: mpsc::Sender<()>,
@@ -84,6 +87,7 @@ fn init() -> Result<Sfx, Box<dyn std::error::Error>> {
     let click = rodio::Decoder::try_from(Cursor::new(CLICK_OGA))?.record();
     let commup = rodio::Decoder::try_from(Cursor::new(COMMUP_OGA))?.record();
     let cloak = rodio::Decoder::try_from(Cursor::new(CLOAK_OGA))?.record();
+    let tick = rodio::Decoder::try_from(Cursor::new(TICK_OGA))?.record();
 
     // Leaving the sound mixer on in Android will just hold a wakelock
     // and drain battery. It is playing a silent sound so we must pause
@@ -97,6 +101,7 @@ fn init() -> Result<Sfx, Box<dyn std::error::Error>> {
         click,
         commup,
         cloak,
+        tick,
         #[cfg(any(target_os = "android", feature = "emulate-android"))]
         activity,
     })
@@ -192,3 +197,9 @@ pub fn play_cloak() {
         sfx.play(&sfx.cloak);
     }
 }
+
+pub fn play_tick() {
+    if let Some(sfx) = &*SFX {
+        sfx.play(&sfx.tick);
+    }
+}

+ 107 - 0
bin/app/src/shape/dropdown_arrow.rs

@@ -0,0 +1,107 @@
+/* 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::{
+    mesh::Color,
+    ui::{ShapeVertex, VectorShape},
+};
+pub fn create_dropdown_arrow(color: Color) -> VectorShape {
+    VectorShape {
+        verts: vec![
+            ShapeVertex::from_xy(0.0530483, 0.176771, color),
+            ShapeVertex::from_xy(0.5, -0.300827, color),
+            ShapeVertex::from_xy(0.122327, 0.0792875, color),
+            ShapeVertex::from_xy(0.228135, -2.28135e-06, color),
+            ShapeVertex::from_xy(0.31053, -0.0339292, color),
+            ShapeVertex::from_xy(0.472996, -0.226559, color),
+            ShapeVertex::from_xy(-0.000909804, 0.300827, color),
+            ShapeVertex::from_xy(0.0, 0.0221565, color),
+            ShapeVertex::from_xy(0.0, 0.242554, color),
+            ShapeVertex::from_xy(0.0, 0.189644, color),
+            ShapeVertex::from_xy(0.0, 0.136879, color),
+            ShapeVertex::from_xy(0.0, 0.0712836, color),
+            ShapeVertex::from_xy(0.0146983, 0.25979, color),
+            ShapeVertex::from_xy(0.145499, -0.104961, color),
+            ShapeVertex::from_xy(0.0321256, 0.166834, color),
+            ShapeVertex::from_xy(0.115049, 0.0547958, color),
+            ShapeVertex::from_xy(0.0988191, 0.032815, color),
+            ShapeVertex::from_xy(0.149904, -0.049154, color),
+            ShapeVertex::from_xy(0.269698, -0.134017, color),
+            ShapeVertex::from_xy(0.26857, -0.0899332, color),
+            ShapeVertex::from_xy(0.410895, -0.132188, color),
+            ShapeVertex::from_xy(0.368609, -0.142256, color),
+            ShapeVertex::from_xy(0.350214, -0.162341, color),
+            ShapeVertex::from_xy(0.0646034, 0.0193792, color),
+            ShapeVertex::from_xy(0.088266, -0.0413194, color),
+            ShapeVertex::from_xy(0.0, -0.144118, color),
+            ShapeVertex::from_xy(0.0406537, -0.0917327, color),
+            ShapeVertex::from_xy(0.0, -0.107919, color),
+            ShapeVertex::from_xy(0.0433827, -0.175769, color),
+            ShapeVertex::from_xy(0.0652148, -0.156854, color),
+            ShapeVertex::from_xy(0.0, -0.0344233, color),
+            ShapeVertex::from_xy(0.01214, -0.0589844, color),
+            ShapeVertex::from_xy(0.0212367, -0.213065, color),
+            ShapeVertex::from_xy(0.0, -0.275832, color),
+            ShapeVertex::from_xy(0.0988725, -0.135209, color),
+            ShapeVertex::from_xy(0.0276039, -0.110951, color),
+            ShapeVertex::from_xy(0.0194171, -0.140176, color),
+            ShapeVertex::from_xy(-0.0530483, 0.176771, color),
+            ShapeVertex::from_xy(-0.5, -0.300827, color),
+            ShapeVertex::from_xy(-0.122327, 0.0792875, color),
+            ShapeVertex::from_xy(-0.228135, -2.28135e-06, color),
+            ShapeVertex::from_xy(-0.31053, -0.0339292, color),
+            ShapeVertex::from_xy(-0.472996, -0.226559, color),
+            ShapeVertex::from_xy(0.000909804, 0.300827, color),
+            ShapeVertex::from_xy(-0.0146983, 0.25979, color),
+            ShapeVertex::from_xy(-0.145499, -0.104961, color),
+            ShapeVertex::from_xy(-0.0321256, 0.166834, color),
+            ShapeVertex::from_xy(-0.115049, 0.0547958, color),
+            ShapeVertex::from_xy(-0.0988191, 0.032815, color),
+            ShapeVertex::from_xy(-0.149904, -0.049154, color),
+            ShapeVertex::from_xy(-0.269698, -0.134017, color),
+            ShapeVertex::from_xy(-0.26857, -0.0899332, color),
+            ShapeVertex::from_xy(-0.410895, -0.132188, color),
+            ShapeVertex::from_xy(-0.368609, -0.142256, color),
+            ShapeVertex::from_xy(-0.350214, -0.162341, color),
+            ShapeVertex::from_xy(-0.0646034, 0.0193792, color),
+            ShapeVertex::from_xy(-0.088266, -0.0413194, color),
+            ShapeVertex::from_xy(-0.0406537, -0.0917327, color),
+            ShapeVertex::from_xy(-0.0433827, -0.175769, color),
+            ShapeVertex::from_xy(-0.0652148, -0.156854, color),
+            ShapeVertex::from_xy(-0.01214, -0.0589844, color),
+            ShapeVertex::from_xy(-0.0212367, -0.213065, color),
+            ShapeVertex::from_xy(-0.0988725, -0.135209, color),
+            ShapeVertex::from_xy(-0.0276039, -0.110951, color),
+            ShapeVertex::from_xy(-0.0194171, -0.140176, color),
+        ],
+        indices: vec![
+            19, 13, 17, 0, 14, 12, 2, 15, 14, 3, 16, 15, 4, 17, 16, 16, 11, 10, 15, 10, 9, 14, 9,
+            8, 12, 8, 6, 23, 7, 11, 4, 21, 19, 18, 21, 22, 22, 5, 1, 21, 20, 5, 24, 17, 13, 23, 16,
+            17, 36, 28, 32, 27, 36, 25, 31, 27, 30, 25, 32, 33, 26, 35, 31, 35, 29, 36, 19, 18, 13,
+            0, 2, 14, 2, 3, 15, 3, 4, 16, 4, 19, 17, 16, 23, 11, 15, 16, 10, 14, 15, 9, 12, 14, 8,
+            23, 24, 7, 4, 20, 21, 18, 19, 21, 22, 21, 5, 24, 23, 17, 36, 29, 28, 27, 35, 36, 31,
+            35, 27, 25, 36, 32, 26, 34, 35, 35, 34, 29, 51, 49, 45, 37, 44, 46, 39, 46, 47, 40, 47,
+            48, 41, 48, 49, 48, 10, 11, 47, 9, 10, 46, 8, 9, 44, 43, 8, 55, 11, 7, 41, 51, 53, 50,
+            54, 53, 54, 38, 42, 53, 42, 52, 56, 45, 49, 55, 49, 48, 64, 61, 58, 27, 25, 64, 60, 30,
+            27, 25, 33, 61, 57, 60, 63, 63, 64, 59, 51, 45, 50, 37, 46, 39, 39, 47, 40, 40, 48, 41,
+            41, 49, 51, 48, 11, 55, 47, 10, 48, 46, 9, 47, 44, 8, 46, 55, 7, 56, 41, 53, 52, 50,
+            53, 51, 54, 42, 53, 56, 49, 55, 64, 58, 59, 27, 64, 63, 60, 27, 63, 25, 61, 64, 57, 63,
+            62, 63, 59, 62,
+        ],
+    }
+}

+ 3 - 0
bin/app/src/shape/mod.rs

@@ -72,6 +72,9 @@ pub use down_bgtab::create_down_bgtab;
 mod down_arrow;
 pub use down_arrow::create_down_arrow;
 
+mod dropdown_arrow;
+pub use dropdown_arrow::create_dropdown_arrow;
+
 mod settings;
 pub use settings::create_settings;
 

+ 611 - 0
bin/app/src/ui/dropdown.rs

@@ -0,0 +1,611 @@
+/* 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 async_trait::async_trait;
+use darkfi_serial::Encodable;
+use miniquad::MouseButton;
+use parking_lot::Mutex as SyncMutex;
+use rand::{rngs::OsRng, Rng};
+use std::{
+    future::Future,
+    sync::{
+        atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering},
+        Arc, Weak,
+    },
+};
+use tracing::instrument;
+
+use crate::{
+    gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer},
+    mesh::MeshBuilder,
+    prop::{
+        PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32, PropertyPtr,
+        PropertyRect, PropertyUint32, Role,
+    },
+    scene::{Pimpl, SceneNodeWeak},
+    sfx, shape, text, ExecutorPtr,
+};
+
+use super::{
+    gesture::{GestureAction, GestureSet},
+    DrawUpdate, OnModify, RedrawTrigger, UIObject,
+};
+
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::dropdown", $($arg)*); } }
+macro_rules! w { ($($arg:tt)*) => { warn!(target: "ui::dropdown", $($arg)*); } }
+
+pub type DropdownPtr = Arc<Dropdown>;
+
+const OUTLINE_W: f32 = 1.;
+const PAD_X: f32 = 12.;
+const ARROW_PAD: f32 = 10.;
+const FADE_MS: u64 = 150;
+const FADE_STEPS: u32 = 50;
+
+pub struct Dropdown {
+    node: SceneNodeWeak,
+    renderer: Renderer,
+    redraw: RedrawTrigger,
+    tasks: SyncMutex<Vec<smol::Task<()>>>,
+    dc_key: u64,
+    me: Weak<Self>,
+
+    rect: PropertyRect,
+    items: PropertyPtr,
+    selected: PropertyUint32,
+    item_height: PropertyFloat32,
+    list_width: PropertyFloat32,
+    box_color: PropertyColor,
+    outline_color: PropertyColor,
+    selected_color: PropertyColor,
+    hover_color: PropertyColor,
+    text_color: PropertyColor,
+    font_size: PropertyFloat32,
+    is_active: PropertyBool,
+    z_index: PropertyUint32,
+    priority: PropertyUint32,
+    debug: PropertyBool,
+    window_scale: PropertyFloat32,
+
+    is_open: AtomicBool,
+    /// Fade step count 0..=FADE_STEPS; draw maps it to [0, 1]
+    fade: AtomicU32,
+    /// Hovered list row. `usize::MAX` = no hover
+    hover: AtomicUsize,
+    ex: SyncMutex<Option<ExecutorPtr>>,
+    /// Text layouts keyed by the items string they were built from
+    layout_cache: SyncMutex<Option<(String, f32, Vec<text::TextLayout>)>>,
+}
+
+impl Dropdown {
+    pub async fn new(
+        node: SceneNodeWeak,
+        renderer: Renderer,
+        redraw: RedrawTrigger,
+        window_scale: PropertyFloat32,
+    ) -> Pimpl {
+        let node_ref = &node.upgrade().unwrap();
+        let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap();
+        let items = node_ref.get_property("items").unwrap();
+        let selected = PropertyUint32::wrap(node_ref, Role::Internal, "selected", 0).unwrap();
+        let item_height =
+            PropertyFloat32::wrap(node_ref, Role::Internal, "item_height", 0).unwrap();
+        let list_width = PropertyFloat32::wrap(node_ref, Role::Internal, "list_width", 0).unwrap();
+        let box_color = PropertyColor::wrap(node_ref, Role::Internal, "box_color").unwrap();
+        let outline_color = PropertyColor::wrap(node_ref, Role::Internal, "outline_color").unwrap();
+        let selected_color =
+            PropertyColor::wrap(node_ref, Role::Internal, "selected_color").unwrap();
+        let hover_color = PropertyColor::wrap(node_ref, Role::Internal, "hover_color").unwrap();
+        let text_color = PropertyColor::wrap(node_ref, Role::Internal, "text_color").unwrap();
+        let font_size = PropertyFloat32::wrap(node_ref, Role::Internal, "font_size", 0).unwrap();
+        let is_active = PropertyBool::wrap(node_ref, Role::Internal, "is_active", 0).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 debug = PropertyBool::wrap(node_ref, Role::Internal, "debug", 0).unwrap();
+
+        let self_ = Arc::new_cyclic(|me| Self {
+            node,
+            renderer,
+            redraw,
+            tasks: SyncMutex::new(vec![]),
+            dc_key: OsRng.gen(),
+            me: me.clone(),
+
+            rect,
+            items,
+            selected,
+            item_height,
+            list_width,
+            box_color,
+            outline_color,
+            selected_color,
+            hover_color,
+            text_color,
+            font_size,
+            is_active,
+            z_index,
+            priority,
+            debug,
+
+            window_scale,
+
+            is_open: AtomicBool::new(false),
+            fade: AtomicU32::new(0),
+            hover: AtomicUsize::new(usize::MAX),
+            ex: SyncMutex::new(None),
+            layout_cache: SyncMutex::new(None),
+        });
+
+        Pimpl::Dropdown(self_)
+    }
+
+    /// The item labels
+    fn items(&self) -> Vec<String> {
+        self.items.get_str_vec().unwrap_or_default()
+    }
+
+    /// The selected index, clamped into `[0, items.len())`
+    fn selected(&self, len: usize) -> Option<usize> {
+        if len == 0 {
+            return None
+        }
+        Some((self.selected.get() as usize).min(len - 1))
+    }
+
+    fn geometry(&self) -> Option<Geometry> {
+        let rect = self.rect.get();
+        if rect.w <= 0. || rect.h <= 0. {
+            return None
+        }
+        let item_height = self.item_height.get();
+        if item_height <= 0. || !item_height.is_finite() {
+            return None
+        }
+        let list_w = self.list_width.get();
+        let list_w = if list_w > 0. && list_w.is_finite() { list_w } else { rect.w };
+        let len = self.items().len();
+        Some(Geometry { rect, item_height, list_w, list_len: len })
+    }
+
+    /// List row index at widget-local `local`, if any
+    fn item_at(geo: &Geometry, local: Point) -> Option<usize> {
+        if local.x < 0. || local.x > geo.list_w {
+            return None
+        }
+        let rel_y = local.y - geo.rect.h;
+        if rel_y < 0. {
+            return None
+        }
+        let idx = (rel_y / geo.item_height).floor() as usize;
+        (idx < geo.list_len).then_some(idx)
+    }
+
+    /// Select `idx` and fire `selection_changed`
+    async fn select(&self, idx: usize, item: String) {
+        let atom = &mut self.redraw.make_guard(gfxtag!("dropdown select"));
+        self.selected.set(atom, idx as u32);
+
+        let mut data = vec![];
+        (idx as u32).encode(&mut data).unwrap();
+        item.encode(&mut data).unwrap();
+        let node = self.node.upgrade().unwrap();
+        if let Err(e) = node.trigger("selection_changed", data).await {
+            w!("selection_changed trigger failed: {e}");
+        }
+    }
+
+    /// Spawn a fade task; weak self cancels it if the widget drops
+    fn spawn(&self, fut: impl Future<Output = ()> + Send + 'static) {
+        let Some(ex) = self.ex.lock().clone() else { return };
+        let task = ex.spawn(fut);
+        self.tasks.lock().push(task);
+    }
+
+    /// Open: fade the list in
+    fn open(&self) {
+        self.is_open.store(true, Ordering::Relaxed);
+        self.hover.store(usize::MAX, Ordering::Relaxed);
+        let node = self.node.upgrade().unwrap();
+        smol::block_on(node.trigger("opened", vec![])).ok();
+        let me = self.me.clone();
+        let redraw = self.redraw.clone();
+        self.spawn(async move {
+            for step in 1..=FADE_STEPS {
+                let Some(this) = me.upgrade() else { return };
+                this.fade.store(step, Ordering::Relaxed);
+                redraw.trigger();
+                darkfi::system::msleep(FADE_MS / FADE_STEPS as u64).await;
+            }
+        });
+        self.redraw.trigger();
+    }
+
+    /// Close: fade the list out, then flip closed
+    fn close(&self) {
+        self.hover.store(usize::MAX, Ordering::Relaxed);
+        let me = self.me.clone();
+        let redraw = self.redraw.clone();
+        self.spawn(async move {
+            let start_step = match me.upgrade() {
+                Some(this) => this.fade.load(Ordering::Relaxed),
+                None => return,
+            };
+            for step in (0..start_step).rev() {
+                let Some(this) = me.upgrade() else { return };
+                this.fade.store(step, Ordering::Relaxed);
+                redraw.trigger();
+                darkfi::system::msleep(FADE_MS / FADE_STEPS as u64).await;
+            }
+            let Some(this) = me.upgrade() else { return };
+            this.is_open.store(false, Ordering::Relaxed);
+            let node = this.node.upgrade().unwrap();
+            node.trigger("closed", vec![]).await.ok();
+            redraw.trigger();
+        });
+    }
+
+    /// Pointer-down, returns whether the event is claimed
+    async fn pointer_down(&self, pos: Point) -> bool {
+        if !self.is_active.get() {
+            return false
+        }
+        let Some(geo) = self.geometry() else { return false };
+
+        let was_open = self.is_open.load(Ordering::Relaxed);
+        let rect = geo.rect;
+        let local = Point::new(pos.x - rect.x, pos.y - rect.y);
+
+        if was_open {
+            if rect.contains(pos) {
+                d!("close (box tap)");
+                self.close();
+                return true
+            }
+            if let Some(idx) = Self::item_at(&geo, local) {
+                let items = self.items();
+                let item = items[idx].clone();
+                d!("select {idx} ({item})");
+                if self.selected.get() != idx as u32 {
+                    self.select(idx, item).await;
+                }
+                self.close();
+                return true
+            }
+            self.close();
+            return false
+        }
+
+        if rect.contains(pos) {
+            d!("open");
+            self.open();
+            return true
+        }
+
+        false
+    }
+
+    /// Build (or reuse) one text layout per item
+    fn layouts(&self, items: &[String], font_size: f32) -> Vec<text::TextLayout> {
+        let joined = items.join("\u{1f}");
+        let window_scale = self.window_scale.get();
+        let text_color = self.text_color.get();
+
+        {
+            let cache = self.layout_cache.lock();
+            if let Some((key, key_size, layouts)) = cache.as_ref() {
+                if key == &joined && *key_size == font_size && layouts.len() == items.len() {
+                    return layouts.clone()
+                }
+            }
+        }
+
+        let layouts: Vec<text::TextLayout> = items
+            .iter()
+            .map(|item| {
+                text::make_layout2(
+                    item,
+                    text_color,
+                    font_size,
+                    1.,
+                    window_scale,
+                    None,
+                    &[],
+                    &[],
+                    parley::Alignment::Start,
+                    parley::OverflowWrap::Normal,
+                )
+            })
+            .collect();
+
+        *self.layout_cache.lock() = Some((joined, font_size, layouts.clone()));
+        layouts
+    }
+
+    /// The open list as a deferred overlay (`SetPos` anchor, `SetAlpha`
+    /// fade, drawn above sibling rows)
+    fn build_list_instrs(&self, geo: &Geometry, renderer: &Renderer) -> Vec<DrawInstruction> {
+        let items = self.items();
+        if items.is_empty() {
+            return vec![]
+        }
+
+        let rect = geo.rect;
+        let item_h = geo.item_height;
+        let list_w = geo.list_w;
+        let list_h = items.len() as f32 * item_h;
+        let box_color = self.box_color.get();
+        let outline_color = self.outline_color.get();
+        let selected_color = self.selected_color.get();
+        let hover_color = self.hover_color.get();
+        let selected = self.selected(items.len());
+        let hover = self.hover.load(Ordering::Relaxed);
+        let alpha = self.fade.load(Ordering::Relaxed) as f32 / FADE_STEPS as f32;
+
+        let mut list_instrs = vec![DrawInstruction::Move(Point::new(rect.x, rect.y + rect.h))];
+
+        let mut mesh = MeshBuilder::new(gfxtag!("dropdown_list"));
+        mesh.draw_filled_box(&Rectangle::new(0., 0., list_w, list_h), box_color);
+        mesh.draw_outline(&Rectangle::new(0., 0., list_w, list_h), outline_color, OUTLINE_W);
+        if let Some(sel) = selected {
+            mesh.draw_filled_box(
+                &Rectangle::new(0., sel as f32 * item_h, list_w, item_h),
+                selected_color,
+            );
+        }
+        if hover != usize::MAX && Some(hover) != selected && (hover as u64) < items.len() as u64 {
+            let inset = OUTLINE_W;
+            mesh.draw_filled_box(
+                &Rectangle::new(
+                    inset,
+                    hover as f32 * item_h + inset,
+                    list_w - 2. * inset,
+                    item_h - 2. * inset,
+                ),
+                hover_color,
+            );
+        }
+        list_instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
+
+        let font_size = self.font_size.get();
+        let layouts = self.layouts(&items, font_size);
+        let mut cur = Point::new(0., 0.);
+        for (idx, layout) in layouts.iter().enumerate() {
+            let want = Point::new(PAD_X, idx as f32 * item_h + (item_h - layout.height()) / 2.);
+            list_instrs.push(DrawInstruction::Move(Point::new(want.x - cur.x, want.y - cur.y)));
+            cur = want;
+            let mut txt = text::render_layout(layout, renderer, gfxtag!("dropdown_item"));
+            list_instrs.append(&mut txt);
+        }
+
+        vec![
+            DrawInstruction::SetPos(Point::new(0., 0.)),
+            DrawInstruction::SetAlpha(alpha),
+            DrawInstruction::Overlay(list_instrs),
+        ]
+    }
+
+    fn build_mesh(&self) -> Option<MeshBuilder> {
+        let geo = self.geometry()?;
+        let rect = geo.rect;
+        let mut mesh = MeshBuilder::new(gfxtag!("dropdown"));
+
+        let arrow_color = [0., 0.94, 1., 1.];
+        let arrow_shape = shape::create_dropdown_arrow(arrow_color);
+        let arrow_size = rect.h * 0.5;
+        let cx = rect.w - arrow_size / 2. - ARROW_PAD;
+        let cy = rect.h / 2.;
+        let flip = if self.is_open.load(Ordering::Relaxed) { -1. } else { 1. };
+        if !arrow_shape.verts.is_empty() {
+            if let Ok(mut verts) = arrow_shape.eval(rect.w, rect.h) {
+                for vert in &mut verts {
+                    vert.pos[0] = vert.pos[0] * arrow_size + cx;
+                    vert.pos[1] = vert.pos[1] * arrow_size * flip + cy;
+                }
+                mesh.append(verts, arrow_shape.indices.clone());
+            }
+        }
+
+        Some(mesh)
+    }
+}
+
+struct Geometry {
+    rect: Rectangle,
+    item_height: f32,
+    list_w: f32,
+    list_len: usize,
+}
+
+#[async_trait]
+impl UIObject for Dropdown {
+    fn priority(&self) -> u32 {
+        self.priority.get()
+    }
+
+    async fn start(self: Arc<Self>, ex: ExecutorPtr) {
+        let me = Arc::downgrade(&self);
+        *self.ex.lock() = Some(ex.clone());
+
+        let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
+        on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.items.clone(), |self_, _| async move {
+            self_.layout_cache.lock().take();
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.selected.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.item_height.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.list_width.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.box_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.outline_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.selected_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.hover_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.text_color.prop(), |self_, _| async move {
+            self_.layout_cache.lock().take();
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.font_size.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.is_active.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.z_index.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.debug.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+
+        *self.tasks.lock() = on_modify.tasks;
+    }
+
+    fn stop(&self) {
+        self.tasks.lock().clear();
+    }
+
+    #[instrument(target = "ui::dropdown")]
+    async fn draw(
+        &self,
+        parent_rect: Rectangle,
+        atom: &mut PropertyAtomicGuard,
+    ) -> Option<DrawUpdate> {
+        if let Err(e) = self.rect.eval(atom, &parent_rect) {
+            w!("Rect eval failure: {e}");
+        }
+
+        let rect = self.rect.get();
+        let mut instrs = vec![DrawInstruction::Move(rect.pos())];
+
+        if let Some(mesh) = self.build_mesh() {
+            instrs.push(DrawInstruction::Draw(mesh.alloc(&self.renderer).draw_untextured()));
+        }
+
+        if self.geometry().is_some() {
+            let items = self.items();
+            if let Some(sel) = self.selected(items.len()) {
+                let layout = self.layouts(std::slice::from_ref(&items[sel]), self.font_size.get());
+                let y = (rect.h - layout[0].height()) / 2.;
+                instrs.push(DrawInstruction::Move(Point::new(PAD_X, y)));
+                let mut txt =
+                    text::render_layout(&layout[0], &self.renderer, gfxtag!("dropdown_label"));
+                instrs.append(&mut txt);
+            }
+        }
+
+        if self.debug.get() {
+            let mut dbg = MeshBuilder::new(gfxtag!("dropdown_debug"));
+            dbg.draw_outline(&rect.with_zero_pos(), [1., 0., 0., 1.], 1.);
+            instrs.push(DrawInstruction::Draw(dbg.alloc(&self.renderer).draw_untextured()));
+        }
+
+        if self.is_open.load(Ordering::Relaxed) {
+            if let Some(geo) = self.geometry() {
+                instrs.append(&mut self.build_list_instrs(&geo, &self.renderer));
+            }
+        }
+
+        Some(DrawUpdate {
+            key: self.dc_key,
+            draw_calls: vec![(
+                self.dc_key,
+                DrawCall::new(instrs, vec![], self.z_index.get(), "dropdown"),
+            )],
+        })
+    }
+
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::TAP
+    }
+
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        if !self.is_active.get() {
+            return false
+        }
+        let Some(geo) = self.geometry() else { return false };
+        if geo.rect.contains(pos) {
+            return true
+        }
+        if !self.is_open.load(Ordering::Relaxed) {
+            return false
+        }
+        let local = Point::new(pos.x - geo.rect.x, pos.y - geo.rect.y);
+        Self::item_at(&geo, local).is_some()
+    }
+
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        match gesture {
+            GestureAction::Down { pos } => self.pointer_down(pos).await,
+            GestureAction::Tap { .. } => true,
+            GestureAction::Up { .. } => false,
+            _ => false,
+        }
+    }
+
+    async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
+        if btn != MouseButton::Left {
+            return false
+        }
+        self.pointer_down(mouse_pos).await
+    }
+
+    /// Track the hovered list element and play the hover blip on change
+    async fn handle_mouse_move(&self, mouse_pos: Point) -> bool {
+        if !self.is_open.load(Ordering::Relaxed) {
+            return false
+        }
+        let Some(geo) = self.geometry() else { return false };
+        let local = Point::new(mouse_pos.x - geo.rect.x, mouse_pos.y - geo.rect.y);
+        let idx = Self::item_at(&geo, local).unwrap_or(usize::MAX);
+        let prev = self.hover.swap(idx, Ordering::Relaxed);
+        if prev != idx {
+            if idx != usize::MAX {
+                sfx::play_tick();
+            }
+            self.redraw.trigger();
+        }
+        false
+    }
+}
+
+impl Drop for Dropdown {
+    fn drop(&mut self) {
+        self.renderer.replace_draw_calls(vec![(self.dc_key, Default::default())]);
+    }
+}
+
+impl std::fmt::Debug for Dropdown {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(f, "{:?}", self.node.upgrade().unwrap())
+    }
+}

+ 4 - 0
bin/app/src/ui/mod.rs

@@ -94,6 +94,8 @@ mod text_scramble;
 pub use text_scramble::{TextScramble, TextScramblePtr};
 mod slider;
 pub use slider::{Slider, SliderPtr};
+mod dropdown;
+pub use dropdown::{Dropdown, DropdownPtr};
 mod win;
 pub use win::{Window, WindowPtr};
 
@@ -414,6 +416,7 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::DateMsgNode(obj) => obj.clone(),
         Pimpl::FileMsgNode(obj) => obj.clone(),
         Pimpl::Slider(obj) => obj.clone(),
+        Pimpl::Dropdown(obj) => obj.clone(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }
@@ -437,6 +440,7 @@ pub fn get_ui_object3<'a>(node: &'a SceneNode3) -> &'a dyn UIObject {
         Pimpl::DateMsgNode(obj) => obj.as_ref(),
         Pimpl::FileMsgNode(obj) => obj.as_ref(),
         Pimpl::Slider(obj) => obj.as_ref(),
+        Pimpl::Dropdown(obj) => obj.as_ref(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }