瀏覽代碼

app: add slider widget

darkfi 2 周之前
父節點
當前提交
1a3b77d6b1

+ 1 - 26
bin/app/src/app/mod.rs

@@ -29,7 +29,7 @@ use parking_lot::Mutex as SyncMutex;
 use crate::android;
 use crate::{
     db::AppDbPtr,
-    gfx::{gfxtag, EpochIndex, GraphicsEventPublisherPtr, Renderer},
+    gfx::{EpochIndex, GraphicsEventPublisherPtr, Renderer},
     prop::{PropertyAtomicGuard, PropertyFloat32, Role},
     scene::{Pimpl, SceneNodePtr},
     setting::{create_setting, Setting},
@@ -141,31 +141,6 @@ impl App {
         let atom = &mut PropertyAtomicGuard::none();
         prop.set_f32(atom, Role::App, 0, window_scale).unwrap();
 
-        // Live scale: re-apply the setting whenever the user changes it.
-        {
-            let on_modify_sub = win_scale_setting.prop().subscribe_modify();
-            let redraw = self.redraw_trigger.clone();
-            let window_scale_prop = window.get_property("scale").unwrap();
-            let live_task = self.ex.spawn(async move {
-                while let Ok((role, _action, _guard)) = on_modify_sub.receive().await {
-                    if role == Role::Internal {
-                        continue
-                    }
-                    let raw = win_scale_setting.get();
-                    let scale = if is_valid_scale(raw) {
-                        raw
-                    } else {
-                        warn!(target: "app", "Invalid win.scale: {raw}, falling back to 1.");
-                        1.
-                    };
-                    let atom = &mut redraw.make_guard(gfxtag!("App::win_scale live apply"));
-                    window_scale_prop.set_f32(atom, Role::App, 0, base_scale * scale).unwrap();
-                    i!("Applied win.scale live: {scale}");
-                }
-            });
-            self.tasks.lock().unwrap().push(live_task);
-        }
-
         #[cfg(target_os = "android")]
         {
             let insets = android::insets::get_insets();

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

@@ -197,6 +197,120 @@ pub fn create_button(name: &str) -> SceneNode {
     node
 }
 
+pub fn create_slider(name: &str) -> SceneNode {
+    let mut node = SceneNode::new(name, SceneNodeType::Slider);
+
+    let mut prop =
+        Property::new("is_active", PropertyType::Bool, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Is Active", "An active Slider 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("value", PropertyType::Float32, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Value", "Current slider value");
+    prop.set_defaults_f32(vec![0.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("min", PropertyType::Float32, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Minimum", "Minimum value");
+    prop.set_defaults_f32(vec![0.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new("max", PropertyType::Float32, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Maximum", "Maximum value");
+    prop.set_defaults_f32(vec![1.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("step", PropertyType::Float32, PropertySubType::Null, PERM_RUNTIME);
+    prop.set_ui_text("Step", "Value snap step (0 = continuous)");
+    prop.set_defaults_f32(vec![0.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("thickness", PropertyType::Float32, PropertySubType::Pixel, PERM_STYLE);
+    prop.set_ui_text("Thickness", "Track height in pixels");
+    prop.set_defaults_f32(vec![6.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("thumb_radius", PropertyType::Float32, PropertySubType::Pixel, PERM_STYLE);
+    prop.set_ui_text("Thumb Radius", "Thumb circle radius in pixels");
+    prop.set_defaults_f32(vec![10.]).unwrap();
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("track_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Track Color", "Track background color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0.3, 0.3, 0.3, 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("fill_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Fill Color", "Filled track color (RGBA)");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![0., 0.94, 1., 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("thumb_color", PropertyType::Float32, PropertySubType::Color, PERM_STYLE);
+    prop.set_ui_text("Thumb Color", "Thumb color (RGBA), used for the stepper glyphs");
+    prop.set_array_len(4);
+    prop.set_defaults_f32(vec![1., 1., 1., 1.]).unwrap();
+    prop.set_range_f32(0., 1.);
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("thumb_shape", PropertyType::VectorShape, PropertySubType::Null, PERM_STYLE);
+    prop.set_ui_text("Thumb Shape", "Origin-centered shape drawn at the thumb");
+    node.add_property(prop).unwrap();
+
+    let mut prop =
+        Property::new("show_stepper", PropertyType::Bool, PropertySubType::Null, PERM_STYLE);
+    prop.set_ui_text("Show Stepper", "Draw -/+ buttons stealing width on both track sides");
+    prop.set_defaults_bool(vec![false]).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(
+        "changing",
+        "Value updated continuously during interaction",
+        vec![("value", "Current value", CallArgType::Float32)],
+    )
+    .unwrap();
+
+    node.add_signal(
+        "changed",
+        "Value committed on release",
+        vec![("value", "Final value", CallArgType::Float32)],
+    )
+    .unwrap();
+
+    node
+}
+
 pub fn create_shortcut(name: &str) -> SceneNode {
     let mut node = SceneNode::new(name, SceneNodeType::Shortcut);
 

+ 216 - 59
bin/app/src/app/schema/settings.rs

@@ -19,21 +19,25 @@
 use crate::{
     app::{
         node::{
-            create_button, create_layer, create_singleline_edit, create_text, create_vector_art,
+            create_button, create_layer, create_singleline_edit, create_slider, create_text,
+            create_vector_art,
         },
         App,
     },
     expr::{self, Compiler},
     gfx::gfxtag,
     prop::{PropertyAtomicGuard, PropertyFloat32, PropertyStr, PropertyValue, Role},
-    scene::{SceneNodePtr, Slot},
+    scene::{SceneNode, SceneNodePtr, Slot},
     shape,
     theme::wire_color,
-    ui::{BaseEdit, BaseEditType, Button, Layer, ShapeVertex, Text, VectorArt, VectorShape},
+    ui::{
+        BaseEdit, BaseEditType, Button, Layer, ShapeVertex, Slider, Text, VectorArt, VectorShape,
+    },
     util::i18n::I18nBabelFish,
-    ExecutorPtr,
 };
 
+use darkfi_serial::deserialize;
+
 use std::{
     collections::BTreeMap,
     sync::{Arc, Mutex},
@@ -44,6 +48,13 @@ mod android_ui_consts {
     pub const SETTING_LABEL_X: f32 = 40.;
     pub const SETTING_LABEL_LINESPACE: f32 = 140.;
     pub const SETTING_LABEL_BASELINE: f32 = 82.;
+    pub const SLIDER_PAD: f32 = 20.;
+    pub const RESET_BTN_W: f32 = 70.;
+    pub const RESET_GLYPH_X: f32 = 50.;
+    pub const SLIDER_H: f32 = 52.;
+    pub const SWITCH_X_OFFSET: f32 = 50.;
+    pub const CONFIRM_X_OFFSET: f32 = 100.;
+    pub const CONFIRM_BTN_W: f32 = 200.;
     pub const SETTING_LABEL_FONTSIZE: f32 = 24.;
     pub const SETTING_EDIT_FONTSIZE: f32 = 24.;
     pub const SETTING_TITLE_X: f32 = 150.;
@@ -80,6 +91,13 @@ mod ui_consts {
     pub const SETTING_LABEL_X: f32 = 20.;
     pub const SETTING_LABEL_LINESPACE: f32 = 60.;
     pub const SETTING_LABEL_BASELINE: f32 = 37.;
+    pub const SLIDER_PAD: f32 = 10.;
+    pub const RESET_BTN_W: f32 = 35.;
+    pub const RESET_GLYPH_X: f32 = 25.;
+    pub const SLIDER_H: f32 = 26.;
+    pub const SWITCH_X_OFFSET: f32 = 25.;
+    pub const CONFIRM_X_OFFSET: f32 = 50.;
+    pub const CONFIRM_BTN_W: f32 = 100.;
     pub const SETTING_LABEL_FONTSIZE: f32 = 14.;
     pub const SETTING_EDIT_FONTSIZE: f32 = 14.;
     pub const SETTING_TITLE_X: f32 = 100.;
@@ -134,6 +152,9 @@ impl Setting {
     fn is_default(&self) -> bool {
         self.get_value() == self.get_default()
     }
+    fn is_bool(&self) -> bool {
+        matches!(self.get_value(), PropertyValue::Bool(_))
+    }
     fn reset(&self) {
         let prop = self.node.get_property("value").unwrap();
         let atom = &mut PropertyAtomicGuard::none();
@@ -141,9 +162,23 @@ impl Setting {
     }
 }
 
+#[cfg(target_os = "android")]
+fn win_base_scale() -> f32 {
+    miniquad::window::dpi_scale() / 3.5
+}
+
+#[cfg(not(target_os = "android"))]
+fn win_base_scale() -> f32 {
+    1.
+}
+
 pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
     let mut cc = Compiler::new();
     cc.add_const_f32("BORDER_RIGHT_SCALE", BORDER_RIGHT_SCALE);
+    cc.add_const_f32("SETTING_LABEL_X", SETTING_LABEL_X);
+    cc.add_const_f32("SLIDER_PAD", SLIDER_PAD);
+    cc.add_const_f32("RESET_BTN_W", RESET_BTN_W);
+    cc.add_const_f32("RESET_GLYPH_X", RESET_GLYPH_X);
     cc.add_const_f32("SEARCH_PADDING_X", SEARCH_PADDING_X);
     cc.add_const_f32("X_RATIO", 1. / 2.);
     let window_scale = PropertyFloat32::wrap(
@@ -895,7 +930,11 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         let prop = label_value_node.get_property("rect").unwrap();
         prop.set_default_f32(0, SETTING_LABEL_X).unwrap();
         prop.set_default_f32(1, 0.).unwrap();
-        prop.set_default_expr(2, cc.compile("w * X_RATIO").unwrap()).unwrap();
+        prop.set_default_expr(
+            2,
+            cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE - RESET_BTN_W - SETTING_LABEL_X").unwrap(),
+        )
+        .unwrap();
         prop.set_default_f32(3, 100.).unwrap();
         label_value_node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
         label_value_node
@@ -1139,6 +1178,54 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
             setting_layer_node.link(btn);
         }
 
+        if setting_name == "win.scale" {
+            let node = create_slider("value_slider");
+            node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+            node.set_property_f32(atom, Role::App, "min", 0.8).unwrap();
+            node.set_property_f32(atom, Role::App, "max", 1.2).unwrap();
+            node.set_property_f32(atom, Role::App, "step", 0.02).unwrap();
+            node.set_property_bool(atom, Role::App, "show_stepper", true).unwrap();
+
+            let saved = match setting_clone.get_value() {
+                PropertyValue::Float32(v) if (0.8..=1.2).contains(&v) => v,
+                _ => 1.,
+            };
+            node.set_property_f32(atom, Role::App, "value", saved).unwrap();
+
+            let prop = node.get_property("rect").unwrap();
+            prop.clone()
+                .set_expr(
+                    atom,
+                    Role::App,
+                    0,
+                    cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE + SLIDER_PAD").unwrap(),
+                )
+                .unwrap();
+            prop.set_f32(atom, Role::App, 1, (SETTING_LABEL_LINESPACE - SLIDER_H) / 2.).unwrap();
+            prop.clone()
+                .set_expr(
+                    atom,
+                    Role::App,
+                    2,
+                    cc.compile("w - (w * X_RATIO - BORDER_RIGHT_SCALE + SLIDER_PAD) - SLIDER_PAD")
+                        .unwrap(),
+                )
+                .unwrap();
+            prop.set_f32(atom, Role::App, 3, SLIDER_H).unwrap();
+            node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
+
+            let mut thumb_shape = shape::create_thumb([1., 1., 1., 1.]);
+            thumb_shape.join(shape::create_circle([0., 0.94, 1., 1.]).scaled(0.65));
+            node.set_property_shape(atom, Role::App, "thumb_shape", thumb_shape).unwrap();
+
+            spawn_win_scale_listener(app, setting.clone(), &setting_layer_node, &node);
+
+            let node = node
+                .setup(|me| Slider::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
+                .await;
+            setting_layer_node.link(node);
+        }
+
         // Is this setting the one that is currently active
         let cloned_active_setting = active_setting.clone();
         let is_active_setting = match active_setting.lock().unwrap().as_ref() {
@@ -1265,8 +1352,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                     })
                     .await;
                 setting_layer_node.link(node);
-            } else {
-                // Label showing the setting's current value
+            } else if setting_name != "win.scale" {
                 let value_node = create_text("value_label");
                 let prop = value_node.get_property("rect").unwrap();
                 prop.clone()
@@ -1313,9 +1399,9 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                 setting_layer_node.link(node);
             }
 
-            // A wide button useful to select the current setting
             let node = create_button("selector_btn");
-            node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+            node.set_property_bool(atom, Role::App, "is_active", setting_name != "win.scale")
+                .unwrap();
             let prop = node.get_property("rect").unwrap();
             prop.set_default_expr(0, cc.compile("w * X_RATIO").unwrap()).unwrap();
             prop.set_default_f32(1, 0.).unwrap();
@@ -1345,9 +1431,10 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
 
                     // Show the selected setting value label
                     // of the selected setting, if there's one
-                    let node = old_node.lookup_node("/value_label").unwrap();
-                    let text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
-                    text.set(atom, &s.value_as_string());
+                    if let Some(node) = old_node.lookup_node("/value_label") {
+                        let text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
+                        text.set(atom, &s.value_as_string());
+                    }
 
                     // Hide the selected setting editbox
                     // of the selected setting, if there's one
@@ -1511,7 +1598,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
             // Switch icon
             let node = create_vector_art("switch_btn_bg");
             let prop = node.get_property("rect").unwrap();
-            prop.set_default_expr(0, cc.compile("w - 50").unwrap()).unwrap();
+            prop.set_default_expr(0, cc.compile("w - SWITCH_X_OFFSET").unwrap()).unwrap();
             prop.set_default_f32(1, SETTING_LABEL_LINESPACE / 2.).unwrap();
             prop.set_default_f32(2, 0.).unwrap();
             prop.set_default_f32(3, 0.).unwrap();
@@ -1523,11 +1610,10 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                 .setup(|me| VectorArt::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
                 .await;
             setting_layer_node.link(node);
-        } else {
-            // Confirm button
+        } else if setting_name != "win.scale" {
             let node = create_vector_art("confirm_btn_bg");
             let prop = node.get_property("rect").unwrap();
-            prop.set_default_expr(0, cc.compile("w - 50").unwrap()).unwrap();
+            prop.set_default_expr(0, cc.compile("w - CONFIRM_X_OFFSET").unwrap()).unwrap();
             prop.set_default_f32(1, SETTING_LABEL_LINESPACE / 2.).unwrap();
             prop.set_default_f32(2, 0.).unwrap();
             prop.set_default_f32(3, 0.).unwrap();
@@ -1544,9 +1630,9 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
             let node = create_button("confirm_btn");
             node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
             let prop = node.get_property("rect").unwrap();
-            prop.set_default_expr(0, cc.compile("w - 100").unwrap()).unwrap();
+            prop.set_default_expr(0, cc.compile("w - CONFIRM_BTN_W").unwrap()).unwrap();
             prop.set_default_f32(1, 0.).unwrap();
-            prop.set_default_f32(2, 100.).unwrap();
+            prop.set_default_f32(2, CONFIRM_BTN_W).unwrap();
             prop.set_default_f32(3, SETTING_LABEL_LINESPACE).unwrap();
             node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
 
@@ -1580,17 +1666,20 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
             }
         }
 
-        // Reset icon
         let node = create_vector_art("reset_btn_bg");
         let prop = node.get_property("rect").unwrap();
-        prop.set_default_expr(0, cc.compile("w - 100").unwrap()).unwrap();
+        prop.set_default_expr(
+            0,
+            cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE - RESET_GLYPH_X").unwrap(),
+        )
+        .unwrap();
         prop.set_default_f32(1, SETTING_LABEL_LINESPACE / 2.).unwrap();
         prop.set_default_f32(2, 0.).unwrap();
         prop.set_default_f32(3, 0.).unwrap();
         node.set_property_bool(atom, Role::App, "is_visible", !setting.is_default()).unwrap();
         node.set_property_u32(atom, Role::App, "z_index", 1).unwrap();
 
-        let shape = shape::create_reset([0., 0.94, 1., 1.]).scaled(15.);
+        let shape = shape::create_reset([0., 0.94, 1., 1.]).scaled(10.);
         node.set_property_shape(atom, Role::App, "shape", shape).unwrap();
         let node = node
             .setup(|me| VectorArt::new(me, app.renderer.clone(), app.redraw_trigger.clone()))
@@ -1600,9 +1689,13 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         let node = create_button("reset_btn");
         node.set_property_bool(atom, Role::App, "is_active", !setting.is_default()).unwrap();
         let prop = node.get_property("rect").unwrap();
-        prop.set_default_expr(0, cc.compile("w - 115").unwrap()).unwrap();
+        prop.set_default_expr(
+            0,
+            cc.compile("w * X_RATIO - BORDER_RIGHT_SCALE - RESET_BTN_W").unwrap(),
+        )
+        .unwrap();
         prop.set_default_f32(1, 0.).unwrap();
-        prop.set_default_f32(2, 50.).unwrap();
+        prop.set_default_f32(2, RESET_BTN_W).unwrap();
         prop.set_default_f32(3, SETTING_LABEL_LINESPACE).unwrap();
         node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
 
@@ -1617,6 +1710,7 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
             node.register("click", slot).unwrap();
             let setting2 = setting.clone();
             let sg_root2 = setting_layer_node.clone();
+            let app_root2 = app.sg_root.clone();
             let active_setting2 = active_setting.clone();
             let editz_text2 = editz_text.clone();
             let listen_click = app.ex.spawn(async move {
@@ -1626,11 +1720,18 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
 
                     let atom = &mut PropertyAtomicGuard::none();
 
+                    if setting2.is_bool() {
+                        refresh_bool_row(&setting2, &sg_root2, atom);
+                    } else if setting2.name == "win.scale" {
+                        reset_win_scale_row(&setting2, &sg_root2, &app_root2, atom);
+                    }
+
                     // Show the selected setting value label (set its text empty)
                     // of the selected setting, if there's one
-                    let node = sg_root2.lookup_node("/value_label").unwrap();
-                    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_label") {
+                        let text = PropertyStr::wrap(&node, Role::App, "text", 0).unwrap();
+                        text.set(atom, setting2.value_as_string());
+                    }
 
                     let node = sg_root2.lookup_node("/value_editbox").unwrap();
                     node.set_property_str(atom, Role::App, "text", setting2.value_as_string())
@@ -1655,7 +1756,6 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
         let sg_root2 = app.sg_root.clone();
         let redraw2 = app.redraw_trigger.clone();
         let settings_map2 = settings_map.clone();
-        let active_setting2 = active_setting.clone();
         let is_visible = layer_node.get_property("is_visible").unwrap();
         let is_visible_sub = is_visible.subscribe_modify();
         let listen_visible = app.ex.spawn(async move {
@@ -1668,43 +1768,17 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
                     let path = format!("/window/content/settings_layer/settings/{name}");
                     let Some(row) = sg_root2.lookup_node(&path) else { continue };
                     if setting.is_bool() {
-                        let on = matches!(setting.get_value(), PropertyValue::Bool(true));
-                        row.lookup_node("/value_bg_bool_true")
-                            .unwrap()
-                            .set_property_bool(atom, Role::App, "is_visible", on)
-                            .unwrap();
-                        row.lookup_node("/value_bg_bool_false")
-                            .unwrap()
-                            .set_property_bool(atom, Role::App, "is_visible", !on)
-                            .unwrap();
-                        row.lookup_node("/bool_icon_bg_true")
-                            .unwrap()
-                            .set_property_bool(atom, Role::App, "is_visible", on)
-                            .unwrap();
-                        row.lookup_node("/bool_icon_bg_false")
-                            .unwrap()
-                            .set_property_bool(atom, Role::App, "is_visible", !on)
-                            .unwrap();
-                        let label = row.lookup_node("/value_label").unwrap();
+                        refresh_bool_row(setting, &row, atom);
+                    } else if let Some(label) = row.lookup_node("/value_label") {
                         label
                             .set_property_str(atom, Role::App, "text", setting.value_as_string())
                             .unwrap();
-                        let prop = label.get_property("text_color").unwrap();
-                        let color = if on { [0., 0.94, 1., 1.] } else { [0.9, 0.4, 0.4, 1.] };
-                        for (i, c) in color.iter().enumerate() {
-                            prop.set_f32(atom, Role::App, i, *c).unwrap();
-                        }
-                    } else {
-                        row.lookup_node("/value_label")
-                            .unwrap()
-                            .set_property_str(atom, Role::App, "text", setting.value_as_string())
-                            .unwrap();
                     }
                     refresh_setting(setting.clone(), row);
                 }
             }
         });
-        app.tasks.lock().unwrap().push(listen_visible);
+        app.tasks.lock().push(listen_visible);
     }
 
     let settings_node = app.sg_root.lookup_node("/window/content/settings_layer").unwrap();
@@ -1717,9 +1791,93 @@ pub async fn make(app: &App, window: SceneNodePtr, i18n_fish: &I18nBabelFish) {
     node.set_property_str(atom, Role::App, "text", &counter_text).unwrap();
 }
 
+fn spawn_win_scale_listener(
+    app: &App,
+    setting: Arc<Setting>,
+    row_root: &SceneNodePtr,
+    slider: &SceneNode,
+) {
+    let (slot, recvr) = Slot::new("slider_changed");
+    slider.register("changed", slot).unwrap();
+    let sg_root = app.sg_root.clone();
+    let row_root = row_root.clone();
+    let redraw = app.redraw_trigger.clone();
+    let base_scale = win_base_scale();
+    let task = app.ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            let Ok(val) = deserialize::<f32>(&data) else { continue };
+            let atom = &mut redraw.make_guard(gfxtag!("settings slider changed"));
+            if let Err(e) = setting
+                .node
+                .get_property("value")
+                .unwrap()
+                .set_f32(atom, Role::User, 0, val)
+            {
+                error!(target: "app::settings", "failed to set win.scale: {e}");
+                continue
+            }
+            let window = sg_root.lookup_node("/window").unwrap();
+            window
+                .get_property("scale")
+                .unwrap()
+                .set_f32(atom, Role::App, 0, base_scale * val)
+                .unwrap();
+            info!(target: "app::settings", "Applied win.scale live: {val}");
+            refresh_setting(setting.clone(), row_root.clone());
+        }
+    });
+    app.tasks.lock().push(task);
+}
+
+fn reset_win_scale_row(
+    setting: &Setting,
+    row_root: &SceneNodePtr,
+    app_root: &SceneNodePtr,
+    atom: &mut PropertyAtomicGuard,
+) {
+    let def = match setting.get_default() {
+        PropertyValue::Float32(v) => v.clamp(0.8, 1.2),
+        _ => 1.,
+    };
+    if let Some(slider) = row_root.lookup_node("/value_slider") {
+        slider.set_property_f32(atom, Role::App, "value", def).unwrap();
+    }
+    let window = app_root.lookup_node("/window").unwrap();
+    window
+        .get_property("scale")
+        .unwrap()
+        .set_f32(atom, Role::App, 0, win_base_scale() * def)
+        .unwrap();
+}
+fn refresh_bool_row(setting: &Setting, sn: &SceneNodePtr, atom: &mut PropertyAtomicGuard) {
+    let on = matches!(setting.get_value(), PropertyValue::Bool(true));
+    sn.lookup_node("/value_bg_bool_true")
+        .unwrap()
+        .set_property_bool(atom, Role::App, "is_visible", on)
+        .unwrap();
+    sn.lookup_node("/value_bg_bool_false")
+        .unwrap()
+        .set_property_bool(atom, Role::App, "is_visible", !on)
+        .unwrap();
+    sn.lookup_node("/bool_icon_bg_true")
+        .unwrap()
+        .set_property_bool(atom, Role::App, "is_visible", on)
+        .unwrap();
+    sn.lookup_node("/bool_icon_bg_false")
+        .unwrap()
+        .set_property_bool(atom, Role::App, "is_visible", !on)
+        .unwrap();
+    let label = sn.lookup_node("/value_label").unwrap();
+    label.set_property_str(atom, Role::App, "text", setting.value_as_string()).unwrap();
+    let prop = label.get_property("text_color").unwrap();
+    let color = if on { [0., 0.94, 1., 1.] } else { [0.9, 0.4, 0.4, 1.] };
+    for (i, c) in color.iter().enumerate() {
+        prop.set_f32(atom, Role::App, i, *c).unwrap();
+    }
+}
+
 fn refresh_setting(setting: Arc<Setting>, sn: SceneNodePtr) {
     let atom = &mut PropertyAtomicGuard::none();
-    let is_bool = matches!(setting.get_value(), PropertyValue::Bool(_));
 
     let node = sn.lookup_node("/key_label").unwrap();
     if setting.clone().is_default() {
@@ -1738,8 +1896,7 @@ fn refresh_setting(setting: Arc<Setting>, sn: SceneNodePtr) {
     let node = sn.lookup_node("/reset_btn_bg").unwrap();
     node.set_property_bool(atom, Role::App, "is_visible", !setting.clone().is_default()).unwrap();
     let node = sn.lookup_node("/reset_btn").unwrap();
-    node.set_property_bool(atom, Role::App, "is_active", !is_bool && !setting.clone().is_default())
-        .unwrap();
+    node.set_property_bool(atom, Role::App, "is_active", !setting.clone().is_default()).unwrap();
 }
 
 async fn update_setting(

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

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

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

@@ -124,6 +124,7 @@ pub enum SceneNodeType {
     PrivMsgNode = 26,
     DateMsgNode = 27,
     FileMsgNode = 28,
+    Slider = 29,
     PluginRoot = 100,
     Plugin = 101,
 }
@@ -619,6 +620,7 @@ pub enum Pimpl {
     PrivMsgNode(ui::chatview::msg::PrivMsgNodePtr),
     DateMsgNode(ui::chatview::msg::DateMsgNodePtr),
     FileMsgNode(ui::chatview::msg::FileMsgNodePtr),
+    Slider(ui::SliderPtr),
     #[cfg(feature = "enable-plugin-darkirc")]
     DarkIrc(plugin::DarkIrcPtr),
     #[cfg(feature = "enable-plugin-fud")]

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

@@ -104,3 +104,6 @@ pub use confirm::create_confirm;
 
 mod reset;
 pub use reset::create_reset;
+
+mod slider_thumb;
+pub use slider_thumb::create_thumb;

+ 68 - 0
bin/app/src/shape/slider_thumb.rs

@@ -0,0 +1,68 @@
+/* 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_thumb(color: Color) -> VectorShape {
+    VectorShape {
+        verts: vec![
+            ShapeVertex::from_xy(-0.6, -1.4, color),
+            ShapeVertex::from_xy(-0.382683, -0.92388, color),
+            ShapeVertex::from_xy(-0.507107, -0.507107, color),
+            ShapeVertex::from_xy(-0.92388, -0.382683, color),
+            ShapeVertex::from_xy(-1.0, 0.0, color),
+            ShapeVertex::from_xy(-0.92388, 0.382683, color),
+            ShapeVertex::from_xy(-0.707107, 0.707107, color),
+            ShapeVertex::from_xy(-0.382683, 0.92388, color),
+            ShapeVertex::from_xy(0.5, 1.3, color),
+            ShapeVertex::from_xy(0.382683, 0.92388, color),
+            ShapeVertex::from_xy(0.507107, 0.507107, color),
+            ShapeVertex::from_xy(0.92388, 0.382683, color),
+            ShapeVertex::from_xy(1.0, 0.0, color),
+            ShapeVertex::from_xy(0.92388, -0.382683, color),
+            ShapeVertex::from_xy(0.707107, -0.707107, color),
+            ShapeVertex::from_xy(0.382683, -0.92388, color),
+            ShapeVertex::from_xy(0.0, -0.62436, color),
+            ShapeVertex::from_xy(-0.238932, -0.576833, color),
+            ShapeVertex::from_xy(-0.441489, -0.441489, color),
+            ShapeVertex::from_xy(-0.576833, -0.238932, color),
+            ShapeVertex::from_xy(-0.62436, 0.0, color),
+            ShapeVertex::from_xy(-0.576833, 0.238932, color),
+            ShapeVertex::from_xy(-0.441489, 0.441489, color),
+            ShapeVertex::from_xy(-0.238932, 0.576833, color),
+            ShapeVertex::from_xy(0.0, 0.62436, color),
+            ShapeVertex::from_xy(0.238932, 0.576833, color),
+            ShapeVertex::from_xy(0.441489, 0.441489, color),
+            ShapeVertex::from_xy(0.576833, 0.238932, color),
+            ShapeVertex::from_xy(0.62436, 0.0, color),
+            ShapeVertex::from_xy(0.576833, -0.238932, color),
+            ShapeVertex::from_xy(0.441489, -0.441489, color),
+            ShapeVertex::from_xy(0.238932, -0.576833, color),
+        ],
+        indices: vec![
+            10, 25, 26, 2, 19, 3, 10, 27, 11, 3, 20, 4, 11, 28, 12, 4, 21, 5, 12, 29, 13, 5, 22, 6,
+            13, 30, 14, 6, 23, 7, 14, 31, 15, 7, 24, 8, 1, 16, 17, 15, 16, 0, 9, 24, 25, 2, 17, 18,
+            10, 9, 25, 2, 18, 19, 10, 26, 27, 3, 19, 20, 11, 27, 28, 4, 20, 21, 12, 28, 29, 5, 21,
+            22, 13, 29, 30, 6, 22, 23, 14, 30, 31, 7, 23, 24, 1, 0, 16, 15, 31, 16, 9, 8, 24, 2, 1,
+            17,
+        ],
+    }
+}

+ 7 - 0
bin/app/src/ui/gesture/mod.rs

@@ -251,6 +251,13 @@ impl GestureSet {
         drag: Some(DragCfg { axes: Axes::Both, direction: Direction::Any, min_travel: Some(0.) }),
     };
 
+    /// Slider: tap to jump/step, horizontal scrub.
+    pub const SLIDER: GestureSet = GestureSet {
+        tap: Some(TapCfg { axes: Axes::Both }),
+        long_press: None,
+        drag: Some(DragCfg { axes: Axes::X, direction: Direction::Horizontal, min_travel: None }),
+    };
+
     /// Whether any recognizer is configured.
     pub fn is_empty(&self) -> bool {
         self.tap.is_none() && self.long_press.is_none() && self.drag.is_none()

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

@@ -92,6 +92,8 @@ mod text;
 pub use text::{Text, TextPtr};
 mod text_scramble;
 pub use text_scramble::{TextScramble, TextScramblePtr};
+mod slider;
+pub use slider::{Slider, SliderPtr};
 mod win;
 pub use win::{Window, WindowPtr};
 
@@ -411,6 +413,7 @@ pub fn get_ui_object_ptr(node: &SceneNode3) -> Arc<dyn UIObject + Send> {
         Pimpl::PrivMsgNode(obj) => obj.clone(),
         Pimpl::DateMsgNode(obj) => obj.clone(),
         Pimpl::FileMsgNode(obj) => obj.clone(),
+        Pimpl::Slider(obj) => obj.clone(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }
@@ -433,6 +436,7 @@ pub fn get_ui_object3<'a>(node: &'a SceneNode3) -> &'a dyn UIObject {
         Pimpl::PrivMsgNode(obj) => obj.as_ref(),
         Pimpl::DateMsgNode(obj) => obj.as_ref(),
         Pimpl::FileMsgNode(obj) => obj.as_ref(),
+        Pimpl::Slider(obj) => obj.as_ref(),
         _ => panic!("unhandled type for get_ui_object: {node:?}"),
     }
 }

+ 553 - 0
bin/app/src/ui/slider.rs

@@ -0,0 +1,553 @@
+/* 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::serialize;
+use miniquad::MouseButton;
+use parking_lot::Mutex as SyncMutex;
+use rand::{rngs::OsRng, Rng};
+use std::sync::{
+    atomic::{AtomicBool, Ordering},
+    Arc,
+};
+use tracing::instrument;
+
+use crate::{
+    gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer, Vertex},
+    mesh::MeshBuilder,
+    prop::{
+        PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32, PropertyRect,
+        PropertyShape, PropertyUint32, Role,
+    },
+    scene::{Pimpl, SceneNodeWeak},
+    ExecutorPtr,
+};
+
+use super::{
+    gesture::{GestureAction, GestureSet},
+    DrawUpdate, OnModify, RedrawTrigger, UIObject, VectorShape,
+};
+
+macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::slider", $($arg)*); } }
+
+pub type SliderPtr = Arc<Slider>;
+
+pub struct Slider {
+    node: SceneNodeWeak,
+    renderer: Renderer,
+    redraw: RedrawTrigger,
+    tasks: SyncMutex<Vec<smol::Task<()>>>,
+    dc_key: u64,
+
+    rect: PropertyRect,
+    value: PropertyFloat32,
+    min: PropertyFloat32,
+    max: PropertyFloat32,
+    step: PropertyFloat32,
+    is_active: PropertyBool,
+    thickness: PropertyFloat32,
+    thumb_radius: PropertyFloat32,
+    track_color: PropertyColor,
+    fill_color: PropertyColor,
+    thumb_color: PropertyColor,
+    thumb_shape: PropertyShape,
+    show_stepper: PropertyBool,
+    z_index: PropertyUint32,
+    priority: PropertyUint32,
+    debug: PropertyBool,
+
+    /// Interactions write `value`/fire `changing` only while held
+    pointer_held: AtomicBool,
+    /// Accept a touch start: swallow the matching Up even without a scrub
+    swallow_touch: AtomicBool,
+}
+
+/// Track geometry for one draw/input pass (rect-local space; input
+/// positions must be rebased by `rect.x` first)
+struct Geometry {
+    rect: Rectangle,
+    min: f32,
+    max: f32,
+    inset: f32,
+    thickness: f32,
+    btn_w: f32,
+    track_span: f32,
+    cy: f32,
+    track_top: f32,
+}
+
+impl Slider {
+    pub async fn new(node: SceneNodeWeak, renderer: Renderer, redraw: RedrawTrigger) -> Pimpl {
+        let node_ref = &node.upgrade().unwrap();
+        let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap();
+        let value = PropertyFloat32::wrap(node_ref, Role::Internal, "value", 0).unwrap();
+        let min = PropertyFloat32::wrap(node_ref, Role::Internal, "min", 0).unwrap();
+        let max = PropertyFloat32::wrap(node_ref, Role::Internal, "max", 0).unwrap();
+        let step = PropertyFloat32::wrap(node_ref, Role::Internal, "step", 0).unwrap();
+        let is_active = PropertyBool::wrap(node_ref, Role::Internal, "is_active", 0).unwrap();
+        let thickness = PropertyFloat32::wrap(node_ref, Role::Internal, "thickness", 0).unwrap();
+        let thumb_radius =
+            PropertyFloat32::wrap(node_ref, Role::Internal, "thumb_radius", 0).unwrap();
+        let track_color = PropertyColor::wrap(node_ref, Role::Internal, "track_color").unwrap();
+        let fill_color = PropertyColor::wrap(node_ref, Role::Internal, "fill_color").unwrap();
+        let thumb_color = PropertyColor::wrap(node_ref, Role::Internal, "thumb_color").unwrap();
+        let thumb_shape = PropertyShape::wrap(node_ref, Role::Internal, "thumb_shape", 0).unwrap();
+        let show_stepper = PropertyBool::wrap(node_ref, Role::Internal, "show_stepper", 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(Self {
+            node,
+            renderer,
+            redraw,
+            tasks: SyncMutex::new(vec![]),
+            dc_key: OsRng.gen(),
+
+            rect,
+            value,
+            min,
+            max,
+            step,
+            is_active,
+            thickness,
+            thumb_radius,
+            track_color,
+            fill_color,
+            thumb_color,
+            thumb_shape,
+            show_stepper,
+            z_index,
+            priority,
+            debug,
+
+            pointer_held: AtomicBool::new(false),
+            swallow_touch: AtomicBool::new(false),
+        });
+
+        Pimpl::Slider(self_)
+    }
+
+    /// Returns `None` for degenerate track geometry
+    fn geometry(&self) -> Option<Geometry> {
+        let rect = self.rect.get();
+        let min = self.min.get();
+        let max = self.max.get();
+        if max <= min || !min.is_finite() || !max.is_finite() {
+            return None
+        }
+
+        let inset = self.thumb_radius.get().max(0.);
+        let btn_w = if self.show_stepper.get() { (2. * inset).max(30.) } else { 0. };
+        let track_span = rect.w - 2. * btn_w;
+        if track_span - 2. * inset <= 0. {
+            return None
+        }
+
+        let thickness = self.thickness.get().max(0.);
+        let cy = rect.h / 2.;
+        Some(Geometry {
+            rect,
+            min,
+            max,
+            inset,
+            thickness,
+            btn_w,
+            track_span,
+            cy,
+            track_top: cy - thickness / 2.,
+        })
+    }
+
+    /// Normalized position of `val` in `[0, 1]`
+    fn value_t(geo: &Geometry, val: f32) -> f32 {
+        ((val - geo.min) / (geo.max - geo.min)).clamp(0., 1.)
+    }
+
+    /// Local x of the thumb center for normalized position `t`
+    fn t_x(geo: &Geometry, t: f32) -> f32 {
+        geo.btn_w + geo.inset + t * (geo.track_span - 2. * geo.inset)
+    }
+
+    /// Stepper amount: `step`, else range/10
+    fn stepper_step(&self, geo: &Geometry) -> f32 {
+        let step = self.step.get();
+        if step > 0. && step.is_finite() {
+            return step
+        }
+        (geo.max - geo.min) / 10.
+    }
+
+    /// Snap `val` onto the `step` grid, clamped
+    fn snap(&self, geo: &Geometry, val: f32) -> f32 {
+        let step = self.step.get();
+        if !(step > 0. && step.is_finite()) {
+            return val.clamp(geo.min, geo.max)
+        }
+        let val = geo.min + ((val - geo.min) / step).round() * step;
+        val.clamp(geo.min, geo.max)
+    }
+
+    /// Map a parent-space x to a value (no dead zones); `None` if degenerate
+    fn map_pos_to_value(&self, geo: &Geometry, x: f32) -> Option<f32> {
+        let track_local = x - geo.rect.x - geo.btn_w;
+        let t = (track_local / geo.track_span).clamp(0., 1.);
+        if !t.is_finite() {
+            return None
+        }
+
+        Some(self.snap(geo, geo.min + t * (geo.max - geo.min)))
+    }
+
+    /// Step the value once in `dir` (-1/1) and commit immediately
+    async fn stepper_press(&self, geo: &Geometry, dir: f32) {
+        let cur = self.value.get();
+        let val = self.snap(geo, cur + self.stepper_step(geo) * dir);
+        if val == cur {
+            return
+        }
+
+        d!("stepper press: {cur} -> {val}");
+        self.set_value_internal(val, "Slider::stepper_press").await;
+        self.fire_changed(val).await;
+    }
+
+    /// Write `value` internally (echo-suppressed), then fire `changing`
+    async fn set_value_internal(&self, val: f32, tag: &'static str) {
+        let atom = &mut self.redraw.make_guard(gfxtag!(tag));
+        self.value.set(atom, val);
+
+        let node = self.node.upgrade().unwrap();
+        if let Err(e) = node.trigger("changing", serialize(&val)).await {
+            warn!(target: "ui::slider", "changing trigger failed: {e}");
+        }
+    }
+
+    /// Fire the commit signal (track release or stepper press)
+    async fn fire_changed(&self, val: f32) {
+        let node = self.node.upgrade().unwrap();
+        if let Err(e) = node.trigger("changed", serialize(&val)).await {
+            warn!(target: "ui::slider", "changed trigger failed: {e}");
+        }
+    }
+
+    /// Pointer-down at `pos`; returns whether the event is swallowed
+    async fn pointer_down(&self, pos: Point) -> bool {
+        if !self.is_active.get() || !self.rect.get().contains(pos) {
+            return false
+        }
+
+        let Some(geo) = self.geometry() else { return false };
+        self.swallow_touch.store(true, Ordering::Relaxed);
+
+        if geo.btn_w > 0. {
+            let local_x = pos.x - geo.rect.x;
+            if local_x <= geo.btn_w {
+                self.stepper_press(&geo, -1.).await;
+                return true
+            }
+            if local_x >= geo.rect.w - geo.btn_w {
+                self.stepper_press(&geo, 1.).await;
+                return true
+            }
+        }
+
+        let Some(val) = self.map_pos_to_value(&geo, pos.x) else { return true };
+
+        d!("Down: jump to {val}");
+        self.pointer_held.store(true, Ordering::Relaxed);
+        self.set_value_internal(val, "Slider::pointer_down").await;
+        true
+    }
+
+    /// Pointer-move at `pos` while scrubbing
+    async fn pointer_move(&self, pos: Point) -> bool {
+        if !self.pointer_held.load(Ordering::Relaxed) {
+            return false
+        }
+
+        let Some(geo) = self.geometry() else { return true };
+        let Some(val) = self.map_pos_to_value(&geo, pos.x) else { return true };
+
+        d!("DragMove: {val}");
+        self.set_value_internal(val, "Slider::pointer_move").await;
+        true
+    }
+
+    /// Pointer-up; commits once if a scrub was in progress
+    async fn pointer_up(&self) -> bool {
+        if !self.swallow_touch.swap(false, Ordering::Relaxed) {
+            return false
+        }
+
+        if self.pointer_held.swap(false, Ordering::Relaxed) {
+            let val = self.value.get();
+            d!("Up: commit {val}");
+            self.fire_changed(val).await;
+        }
+        true
+    }
+
+    /// Append an origin-centered `shape` at `(x, y)`, scaled by `scale`
+    fn append_shape(
+        mesh: &mut MeshBuilder,
+        shape: &VectorShape,
+        x: f32,
+        y: f32,
+        scale: f32,
+        rect: &Rectangle,
+    ) {
+        if shape.verts.is_empty() {
+            return
+        }
+        let Ok(mut verts) = shape.eval(rect.w, rect.h) else {
+            warn!(target: "ui::slider", "shape eval failure");
+            return
+        };
+        for vert in &mut verts {
+            vert.pos[0] = vert.pos[0] * scale + x;
+            vert.pos[1] = vert.pos[1] * scale + y;
+        }
+        mesh.append(verts, shape.indices.clone());
+    }
+
+    fn build_mesh(&self) -> Option<MeshBuilder> {
+        let geo = self.geometry()?;
+        let rect = geo.rect;
+
+        let track_color = self.track_color.get();
+        let fill_color = self.fill_color.get();
+        let thumb_color = self.thumb_color.get();
+
+        let mut mesh = MeshBuilder::new(gfxtag!("slider"));
+
+        mesh.draw_filled_box(
+            &Rectangle::new(geo.btn_w, geo.track_top, geo.track_span, geo.thickness),
+            track_color,
+        );
+
+        let t = Self::value_t(&geo, self.value.get());
+        let thumb_x = Self::t_x(&geo, t);
+
+        let fill_w = thumb_x - geo.btn_w;
+        if fill_w > 0. {
+            mesh.draw_filled_box(
+                &Rectangle::new(geo.btn_w, geo.track_top, fill_w, geo.thickness),
+                fill_color,
+            );
+        }
+
+        // Thumb: schema shape scaled to thumb_radius
+        let thumb_shape = self.thumb_shape.get();
+        Self::append_shape(&mut mesh, &thumb_shape, thumb_x, geo.cy, geo.inset, &rect);
+
+        if geo.btn_w > 0. {
+            let at_min = self.value.get() <= geo.min + f32::EPSILON;
+            let at_max = self.value.get() >= geo.max - f32::EPSILON;
+            let mut glyph = thumb_color;
+            let bar_h = 2.5f32;
+            let bar_w = geo.btn_w * 0.4;
+
+            // Minus
+            glyph[3] = if at_min { 0.35 } else { 1. };
+            mesh.draw_filled_box(
+                &Rectangle::new(geo.btn_w / 2. - bar_w / 2., geo.cy - bar_h / 2., bar_w, bar_h),
+                glyph,
+            );
+
+            // Plus
+            glyph[3] = if at_max { 0.35 } else { 1. };
+            let cx = rect.w - geo.btn_w / 2.;
+            mesh.draw_filled_box(
+                &Rectangle::new(cx - bar_w / 2., geo.cy - bar_h / 2., bar_w, bar_h),
+                glyph,
+            );
+            mesh.draw_filled_box(
+                &Rectangle::new(cx - bar_h / 2., geo.cy - bar_w / 2., bar_h, bar_w),
+                glyph,
+            );
+        }
+
+        debug_assert!(
+            mesh.verts.len() <= u16::MAX as usize && mesh.indices.len() <= u16::MAX as usize
+        );
+
+        Some(mesh)
+    }
+}
+
+#[async_trait]
+impl UIObject for Slider {
+    fn priority(&self) -> u32 {
+        self.priority.get()
+    }
+
+    async fn start(self: Arc<Self>, ex: ExecutorPtr) {
+        let me = Arc::downgrade(&self);
+
+        let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
+        // External writes repaint; internal ones echo-suppress
+        on_modify.when_change_external(self.value.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.min.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.max.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.step.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.thickness.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.thumb_radius.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.track_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.fill_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.thumb_color.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.thumb_shape.prop(), |self_, _| async move {
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.show_stepper.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::slider")]
+    async fn draw(
+        &self,
+        parent_rect: Rectangle,
+        atom: &mut PropertyAtomicGuard,
+    ) -> Option<DrawUpdate> {
+        if let Err(e) = self.rect.eval(atom, &parent_rect) {
+            warn!(target: "ui::slider", "Rect eval failure: {e}");
+        }
+
+        let Some(mesh) = self.build_mesh() else {
+            if self.debug.get() {
+                let rect = self.rect.get().with_zero_pos();
+                let mut mesh = MeshBuilder::new(gfxtag!("slider_debug"));
+                mesh.draw_outline(&rect, [1., 0., 0., 1.], 1.);
+                return Some(DrawUpdate {
+                    key: self.dc_key,
+                    draw_calls: vec![(
+                        self.dc_key,
+                        DrawCall::new(
+                            vec![DrawInstruction::Draw(
+                                mesh.alloc(&self.renderer).draw_untextured(),
+                            )],
+                            vec![],
+                            self.z_index.get(),
+                            "slider_debug",
+                        ),
+                    )],
+                });
+            }
+            return None
+        };
+
+        let mut instrs = vec![DrawInstruction::Move(self.rect.get().pos())];
+        instrs.push(DrawInstruction::Draw(mesh.alloc(&self.renderer).draw_untextured()));
+
+        if self.debug.get() {
+            let rect = self.rect.get().with_zero_pos();
+            let mut dbg = MeshBuilder::new(gfxtag!("slider_debug"));
+            dbg.draw_outline(&rect, [1., 0., 0., 1.], 1.);
+            instrs.push(DrawInstruction::Draw(dbg.alloc(&self.renderer).draw_untextured()));
+        }
+
+        Some(DrawUpdate {
+            key: self.dc_key,
+            draw_calls: vec![(
+                self.dc_key,
+                DrawCall::new(instrs, vec![], self.z_index.get(), "slider"),
+            )],
+        })
+    }
+
+    fn gesture_set(&self) -> GestureSet {
+        GestureSet::SLIDER
+    }
+
+    fn gesture_hit_test(&self, pos: Point) -> bool {
+        self.is_active.get() && self.rect.get().contains(pos)
+    }
+
+    async fn handle_gesture(&self, gesture: GestureAction) -> bool {
+        match gesture {
+            GestureAction::Down { pos } => self.pointer_down(pos).await,
+
+            GestureAction::DragMove { curr, .. } => self.pointer_move(curr).await,
+
+            GestureAction::Up { .. } => self.pointer_up().await,
+
+            _ => 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
+    }
+
+    async fn handle_mouse_move(&self, mouse_pos: Point) -> bool {
+        self.pointer_move(mouse_pos).await
+    }
+
+    async fn handle_mouse_btn_up(&self, _btn: MouseButton, _mouse_pos: Point) -> bool {
+        self.pointer_up().await
+    }
+}
+
+impl Drop for Slider {
+    fn drop(&mut self) {
+        self.renderer.replace_draw_calls(vec![(self.dc_key, Default::default())]);
+    }
+}
+
+impl std::fmt::Debug for Slider {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(f, "{:?}", self.node.upgrade().unwrap())
+    }
+}