mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
refactor(ui-egui): split the top bar and slider widget
This commit is contained in:
+206
-176
@@ -51,185 +51,202 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if app.integrated_titlebar {
|
||||
window_handle(ui, bar);
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("view.leftPanel", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() {
|
||||
let _ = app.run("view.back", json!({}));
|
||||
}
|
||||
icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward"));
|
||||
if !app.native_menu {
|
||||
// no native menu bar (web, Windows, Linux): menus in the top bar
|
||||
ui.add_space(10.0);
|
||||
let room = search_left - ui.cursor().left() - 16.0;
|
||||
crate::menubar::show_in_window(app, ui, room);
|
||||
}
|
||||
});
|
||||
// search field (centred on the window)
|
||||
let id = crate::text_field::id("field:search");
|
||||
if std::mem::take(&mut app.ui.focus_search) {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
nav_buttons(app, ui, search_left);
|
||||
search_field(app, ui, &t, sr);
|
||||
filter_button(app, ui, &t, sr);
|
||||
let x = right_icons(app, ui, &t, full);
|
||||
appearance_button(app, ui, &t, full, x);
|
||||
});
|
||||
}
|
||||
|
||||
/// Sidebar toggle, back/forward and, without a native menu bar, the in-window menus.
|
||||
fn nav_buttons(app: &mut LightcraftApp, ui: &mut egui::Ui, search_left: f32) {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel"))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("view.leftPanel", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() {
|
||||
let _ = app.run("view.back", json!({}));
|
||||
}
|
||||
icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward"));
|
||||
if !app.native_menu {
|
||||
// no native menu bar (web, Windows, Linux): menus in the top bar
|
||||
ui.add_space(10.0);
|
||||
let room = search_left - ui.cursor().left() - 16.0;
|
||||
crate::menubar::show_in_window(app, ui, room);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The centred search field with its placeholder and focus ring.
|
||||
fn search_field(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) {
|
||||
// search field (centred on the window)
|
||||
let id = crate::text_field::id("field:search");
|
||||
if std::mem::take(&mut app.ui.focus_search) {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
ui.painter().rect(
|
||||
sr,
|
||||
4.0,
|
||||
if focused { t.canvas } else { t.field },
|
||||
Stroke::new(1.0, if focused { t.accent } else { t.field_border }),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let empty = app.ui.search.is_empty();
|
||||
if empty && !focused {
|
||||
let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim);
|
||||
let w = g.size().x + 24.0;
|
||||
let x0 = sr.center().x - w / 2.0;
|
||||
paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim);
|
||||
child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
}
|
||||
let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search)
|
||||
.label(crate::i18n::tr("Search Photos"))
|
||||
.frame(false)
|
||||
.width(sr.width() - 20.0)
|
||||
.font(t.font(13.5))
|
||||
.text_color(t.text)
|
||||
.show(&mut child)
|
||||
.response;
|
||||
if resp.changed() {
|
||||
let q = app.ui.search.clone();
|
||||
let _ = app.run("library.filter", json!({"text": q}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter icon beside the search field, with the active-filter badge.
|
||||
fn filter_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, sr: Rect) {
|
||||
// filter icon right of the search field
|
||||
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
|
||||
let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click());
|
||||
crate::widgets::announce(&fresp, crate::i18n::tr("Filters"));
|
||||
register(ui, "icon:filter", fr);
|
||||
let filtering = app.session.filter != Default::default() || app.ui.filter_bar;
|
||||
paint(
|
||||
ui.painter(),
|
||||
fr.shrink(6.0),
|
||||
Icon::Filter,
|
||||
if filtering {
|
||||
t.accent
|
||||
} else if fresp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
},
|
||||
);
|
||||
// badge: how many filters are on, even with the filter bar closed
|
||||
let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len();
|
||||
if active > 0 {
|
||||
let c = fr.right_top() + vec2(-3.0, 8.0);
|
||||
ui.painter().circle_filled(c, 7.0, t.accent);
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas);
|
||||
}
|
||||
let fresp = fresp.on_hover_text(if active > 0 {
|
||||
crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active)
|
||||
} else {
|
||||
crate::i18n::tr("Filter bar").into()
|
||||
});
|
||||
if fresp.clicked() {
|
||||
let _ = app.run("view.filterBar", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-side action icons; returns the x where the next icon would go.
|
||||
fn right_icons(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect) -> f32 {
|
||||
// right icons
|
||||
let mut x = full.right() - 18.0;
|
||||
// saving is failing: the cloud icon turns into a warning until a save succeeds
|
||||
let unsaved = app.session.unsaved().map(|(n, e)| unsaved_tip(n, e));
|
||||
let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed");
|
||||
// Import Photos… beside Export, always one click away, with the key it is bound to now
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") {
|
||||
Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)),
|
||||
None => crate::i18n::tr("Import Photos…").to_string(),
|
||||
};
|
||||
for (id, icon, tip, cmd) in [
|
||||
("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"),
|
||||
("cloud", Icon::Cloud, cloud_tip, ""),
|
||||
("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"),
|
||||
("share", Icon::Share, "Export", "dialog.export"),
|
||||
("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"),
|
||||
("bell", Icon::Bell, "Activity", "panel.activity"),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip));
|
||||
crate::widgets::announce(&resp, crate::i18n::tr(tip));
|
||||
register(ui, format!("icon:{id}"), r);
|
||||
let warn = id == "cloud" && unsaved.is_some();
|
||||
let colour = if warn {
|
||||
t.caution
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
};
|
||||
paint(ui.painter(), r.shrink(5.0), icon, colour);
|
||||
if warn {
|
||||
let c = r.right_top() + vec2(-5.0, 6.0);
|
||||
ui.painter().circle_filled(c, 6.0, t.reject);
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas);
|
||||
register(ui, "indicator:unsaved", r);
|
||||
}
|
||||
// a refusal says why (Import while a Lightroom catalog is coming in)
|
||||
if resp.clicked()
|
||||
&& !cmd.is_empty()
|
||||
&& let Err(e) = app.run(cmd, json!({}))
|
||||
{
|
||||
app.toast_error(ui.ctx(), e);
|
||||
}
|
||||
x -= 40.0;
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// The appearance icon at `x`, with its theme-favourites menu.
|
||||
fn appearance_button(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, full: Rect, x: f32) {
|
||||
// Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode
|
||||
let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode);
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode));
|
||||
let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone());
|
||||
crate::widgets::announce(&resp, tip.clone());
|
||||
register(ui, "icon:appearance", r);
|
||||
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.appearance", json!({}));
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| {
|
||||
let settings = ui.button(crate::i18n::tr("Appearance Settings…"));
|
||||
register(ui, "menu:appearanceSettings", settings.rect);
|
||||
if settings.clicked() {
|
||||
let _ = app.run("app.settings", json!({"tab": "interface"}));
|
||||
ui.close();
|
||||
}
|
||||
let favorites = app.ui.settings.favorite_themes.clone();
|
||||
for dark in [false, true] {
|
||||
let group: Vec<_> =
|
||||
crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect();
|
||||
if !group.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
ui.painter().rect(
|
||||
sr,
|
||||
4.0,
|
||||
if focused { t.canvas } else { t.field },
|
||||
Stroke::new(1.0, if focused { t.accent } else { t.field_border }),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
let mut child =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let empty = app.ui.search.is_empty();
|
||||
if empty && !focused {
|
||||
let g = child.painter().layout_no_wrap(crate::i18n::tr("Search Photos").into(), t.font(13.5), t.text_dim);
|
||||
let w = g.size().x + 24.0;
|
||||
let x0 = sr.center().x - w / 2.0;
|
||||
paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim);
|
||||
child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
}
|
||||
let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search)
|
||||
.label(crate::i18n::tr("Search Photos"))
|
||||
.frame(false)
|
||||
.width(sr.width() - 20.0)
|
||||
.font(t.font(13.5))
|
||||
.text_color(t.text)
|
||||
.show(&mut child)
|
||||
.response;
|
||||
if resp.changed() {
|
||||
let q = app.ui.search.clone();
|
||||
let _ = app.run("library.filter", json!({"text": q}));
|
||||
}
|
||||
// filter icon right of the search field
|
||||
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
|
||||
let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click());
|
||||
crate::widgets::announce(&fresp, crate::i18n::tr("Filters"));
|
||||
register(ui, "icon:filter", fr);
|
||||
let filtering = app.session.filter != Default::default() || app.ui.filter_bar;
|
||||
paint(
|
||||
ui.painter(),
|
||||
fr.shrink(6.0),
|
||||
Icon::Filter,
|
||||
if filtering {
|
||||
t.accent
|
||||
} else if fresp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
},
|
||||
);
|
||||
// badge: how many filters are on, even with the filter bar closed
|
||||
let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len();
|
||||
if active > 0 {
|
||||
let c = fr.right_top() + vec2(-3.0, 8.0);
|
||||
ui.painter().circle_filled(c, 7.0, t.accent);
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas);
|
||||
}
|
||||
let fresp = fresp.on_hover_text(if active > 0 {
|
||||
crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active)
|
||||
} else {
|
||||
crate::i18n::tr("Filter bar").into()
|
||||
});
|
||||
if fresp.clicked() {
|
||||
let _ = app.run("view.filterBar", json!({}));
|
||||
}
|
||||
// right icons
|
||||
let mut x = full.right() - 18.0;
|
||||
// saving is failing: the cloud icon turns into a warning until a save succeeds
|
||||
let unsaved = app.session.unsaved().map(|(n, e)| {
|
||||
crate::i18n::tr_format!(
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "it" } else { "them" },
|
||||
e = e,
|
||||
n = n
|
||||
)
|
||||
});
|
||||
let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed");
|
||||
// Import Photos… beside Export, always one click away, with the key it is bound to now
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") {
|
||||
Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)),
|
||||
None => crate::i18n::tr("Import Photos…").to_string(),
|
||||
};
|
||||
for (id, icon, tip, cmd) in [
|
||||
("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"),
|
||||
("cloud", Icon::Cloud, cloud_tip, ""),
|
||||
("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"),
|
||||
("share", Icon::Share, "Export", "dialog.export"),
|
||||
("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"),
|
||||
("bell", Icon::Bell, "Activity", "panel.activity"),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::unique(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip));
|
||||
crate::widgets::announce(&resp, crate::i18n::tr(tip));
|
||||
register(ui, format!("icon:{id}"), r);
|
||||
let warn = id == "cloud" && unsaved.is_some();
|
||||
let colour = if warn {
|
||||
t.caution
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
};
|
||||
paint(ui.painter(), r.shrink(5.0), icon, colour);
|
||||
if warn {
|
||||
let c = r.right_top() + vec2(-5.0, 6.0);
|
||||
ui.painter().circle_filled(c, 6.0, t.reject);
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas);
|
||||
register(ui, "indicator:unsaved", r);
|
||||
}
|
||||
// a refusal says why (Import while a Lightroom catalog is coming in)
|
||||
if resp.clicked()
|
||||
&& !cmd.is_empty()
|
||||
&& let Err(e) = app.run(cmd, json!({}))
|
||||
{
|
||||
app.toast_error(ui.ctx(), e);
|
||||
}
|
||||
x -= 40.0;
|
||||
}
|
||||
// Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode
|
||||
let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode);
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode));
|
||||
let resp = ui.interact(r, egui::Id::unique(("top", "appearance")), Sense::click()).on_hover_text(tip.clone());
|
||||
crate::widgets::announce(&resp, tip.clone());
|
||||
register(ui, "icon:appearance", r);
|
||||
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.appearance", json!({}));
|
||||
}
|
||||
crate::widgets::context_menu(&resp, |ui| {
|
||||
let settings = ui.button(crate::i18n::tr("Appearance Settings…"));
|
||||
register(ui, "menu:appearanceSettings", settings.rect);
|
||||
if settings.clicked() {
|
||||
let _ = app.run("app.settings", json!({"tab": "interface"}));
|
||||
for kind in group {
|
||||
let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label()));
|
||||
register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("view.theme", json!({"theme": kind.id()}));
|
||||
ui.close();
|
||||
}
|
||||
let favorites = app.ui.settings.favorite_themes.clone();
|
||||
for dark in [false, true] {
|
||||
let group: Vec<_> =
|
||||
crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect();
|
||||
if !group.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
for kind in group {
|
||||
let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label()));
|
||||
register(ui, format!("menu:themeFavorite-{}", kind.id()), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("view.theme", json!({"theme": kind.id()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = Align2::CENTER_CENTER;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The appearance button's icon and the mode's name for its tooltip.
|
||||
@@ -241,3 +258,16 @@ pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static st
|
||||
AppearanceMode::Dark => (Icon::Moon, "Dark Mode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tooltip for the warning cloud icon: `n` changes held in memory because saving failed with `e`.
|
||||
fn unsaved_tip(n: usize, e: &str) -> String {
|
||||
let s = crate::i18n::plural_s(n);
|
||||
let it = if n == 1 { "it" } else { "them" };
|
||||
crate::i18n::tr_format!(
|
||||
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.",
|
||||
s,
|
||||
it,
|
||||
e = e,
|
||||
n = n
|
||||
)
|
||||
}
|
||||
|
||||
+323
-234
@@ -398,25 +398,15 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
let pad_r = 22.0;
|
||||
let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0));
|
||||
let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0));
|
||||
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
|
||||
let id = ui.scope_id().with(spec.id);
|
||||
let g = SliderLayout { spec, value, enabled, id, track_rect, value_rect };
|
||||
let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() });
|
||||
let wheel_id = id.with("wheel-rem");
|
||||
let (wheel_armed, pointer_in_row) = update_wheel_arming(ui, id, &resp, row, enabled);
|
||||
// screen readers: a slider named after its control, with its value
|
||||
let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));
|
||||
// and its range, step and the value as shown ("+0.50", "5600")
|
||||
let shown = shown_value(spec, value);
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| {
|
||||
node.set_min_numeric_value(spec.min);
|
||||
node.set_max_numeric_value(spec.max);
|
||||
node.set_numeric_value_step(spec.step);
|
||||
node.set_value(shown);
|
||||
if enabled {
|
||||
node.add_action(egui::accesskit::Action::Increment);
|
||||
node.add_action(egui::accesskit::Action::Decrement);
|
||||
}
|
||||
});
|
||||
describe_slider(ui, &g, &resp);
|
||||
// the name: double-click it to reset, as the thumb (a disabled slider's name is only text)
|
||||
let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() });
|
||||
// say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do
|
||||
@@ -427,246 +417,41 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
register(ui, format!("slider:{}", spec.id), track_rect);
|
||||
// the value: click it to type an exact one, drag it sideways to scrub (over the label, so it
|
||||
// takes the click)
|
||||
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() });
|
||||
crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it")));
|
||||
register(ui, format!("sliderValue:{}", spec.id), value_rect);
|
||||
// say that an exact value can be typed here (issue #534: an exact crop angle was hard to find)
|
||||
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp };
|
||||
let typing_id = id.with("typing");
|
||||
// Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit.
|
||||
let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id));
|
||||
let mut typing: Option<Draft> = ui.data(|m| m.get_temp(g.typing_id()));
|
||||
// A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead
|
||||
// of rounding back to the same step every frame.
|
||||
let scrub_id = id.with("scrub-pos");
|
||||
let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None };
|
||||
let mut scrub_started = false;
|
||||
if enabled
|
||||
&& scrub.is_none()
|
||||
&& value_resp.dragged()
|
||||
&& let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos()))
|
||||
&& (p.x - origin.x).abs() >= SCRUB_START
|
||||
{
|
||||
// a press on the value that moves sideways scrubs: the edit the press opened is dropped
|
||||
typing = None;
|
||||
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
|
||||
ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
|
||||
// the travel so far counts, so the value doesn't lag the pointer by the start distance
|
||||
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
|
||||
scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0));
|
||||
scrub_started = true;
|
||||
}
|
||||
let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(g.scrub_id())) } else { None };
|
||||
let scrub_started = begin_scrub(ui, &g, &value_resp, &mut scrub, &mut typing);
|
||||
if scrub.is_none() {
|
||||
ui.data_mut(|m| m.remove::<f64>(scrub_id));
|
||||
}
|
||||
let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
|
||||
// Focus on the owned primary press before fast typing; retain the readout's click activation.
|
||||
if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) {
|
||||
typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed));
|
||||
}
|
||||
let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
|
||||
// A release over the value keeps the field the press opened, however long the button was held
|
||||
// (a press held past egui's click time ends without a click). A release elsewhere cancels the
|
||||
// gesture, and a press that scrubs already dropped the field above.
|
||||
let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| value_rect.contains(p));
|
||||
if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() {
|
||||
typing = None;
|
||||
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
|
||||
ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
|
||||
ui.data_mut(|m| m.remove::<f64>(g.scrub_id()));
|
||||
}
|
||||
update_typing_gesture(ui, &g, &value_resp, &mut typing);
|
||||
let mut out = SliderOut::default();
|
||||
// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`)
|
||||
let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width();
|
||||
let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width()));
|
||||
// the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest
|
||||
// of the track, which is clicked and dragged, gets no tooltip)
|
||||
let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0;
|
||||
if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) {
|
||||
if enabled && !resp.dragged() && resp.hover_pos().is_some_and(|p| g.on_thumb(p)) {
|
||||
resp.clone().on_hover_text(reset_hint.as_str());
|
||||
}
|
||||
let mut v = value;
|
||||
if let Some(mut pos) = scrub {
|
||||
// scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel
|
||||
// over the track's width (a tenth with Shift), on the control's steps
|
||||
if !scrub_started && value_resp.dragged() {
|
||||
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
|
||||
let dx = ui.input(|i| i.pointer.delta().x);
|
||||
pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0);
|
||||
}
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max);
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
}
|
||||
out.drag_started = scrub_started;
|
||||
if value_resp.drag_stopped() {
|
||||
out.drag_stopped = true;
|
||||
ui.data_mut(|m| m.remove::<f64>(scrub_id));
|
||||
} else {
|
||||
ui.data_mut(|m| m.insert_temp(scrub_id, pos));
|
||||
}
|
||||
} else if let Some((mut text, frames, pressed)) = typing.take() {
|
||||
// Keep the owned opening release focused without restarting its draft or selection.
|
||||
// egui gives that release's click to the readout, so the field under it gives up the focus
|
||||
// whatever frame it lands on (issue #1362): the field takes it back after drawing too.
|
||||
let opening_release = pressed && value_released;
|
||||
if opening_release {
|
||||
ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
|
||||
}
|
||||
let pressed = pressed && !value_released;
|
||||
// a child over the value, so the field never moves the rows around it
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
// the old value is selected when the field takes the focus, so typing replaces it
|
||||
let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text)
|
||||
.font(t.font(12.5))
|
||||
.horizontal_align(egui::Align::RIGHT)
|
||||
.width(value_rect.width())
|
||||
.margin(egui::Margin::ZERO)
|
||||
.select_on_focus(true)
|
||||
.begin_edit(frames == 0)
|
||||
.show(&mut child);
|
||||
if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) {
|
||||
field.response.request_focus();
|
||||
let frames = frames.saturating_add(1);
|
||||
typing = Some((text.clone(), frames, pressed));
|
||||
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed)));
|
||||
} else if field.ending.is_some() || !field.editing || !enabled {
|
||||
// Esc (or the slider turning off) keeps the old value; Return or clicking away applies
|
||||
let cancelled = !enabled || field.cancelled();
|
||||
if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
v = nv;
|
||||
}
|
||||
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
|
||||
} else {
|
||||
typing = Some((text.clone(), frames, pressed));
|
||||
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed)));
|
||||
}
|
||||
if let Some(pos) = scrub {
|
||||
scrub_value(ui, &g, &value_resp, pos, scrub_started, &mut out);
|
||||
} else if let Some(draft) = typing.take() {
|
||||
typing = edit_value(ui, &t, &g, draft, &mut out);
|
||||
} else if resp.double_clicked() || label_resp.double_clicked() {
|
||||
out.reset = true;
|
||||
out.value = Some(spec.default);
|
||||
v = spec.default;
|
||||
} else {
|
||||
if resp.drag_started() {
|
||||
out.drag_started = true;
|
||||
}
|
||||
if (resp.dragged() || resp.drag_started())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let fine = ui.input(|i| i.modifiers.shift);
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = if fine {
|
||||
// a tenth of the pointer's movement along the track; the unrounded position is kept, so a
|
||||
// slow drag adds up instead of rounding back to the same step every frame
|
||||
let fine_id = id.with("fine-pos");
|
||||
let dx = ui.input(|i| i.pointer.delta().x);
|
||||
let kept = ui.data(|m| m.get_temp::<f64>(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0);
|
||||
let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0);
|
||||
ui.data_mut(|m| m.insert_temp(fine_id, pos));
|
||||
spec.value_at(pos)
|
||||
} else {
|
||||
from_x(p.x)
|
||||
};
|
||||
let nv = ((nv / step).round() * step).clamp(spec.min, spec.max);
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
// a click on the thumb leaves it there, so the first click of a double-click on it isn't a
|
||||
// history step of its own before the reset
|
||||
&& !on_thumb(p)
|
||||
{
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max);
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
// a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down)
|
||||
let (inc, dec) = ui.input(|i| {
|
||||
i.events.iter().fold((false, false), |(inc, dec), e| match e {
|
||||
egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => {
|
||||
(inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement)
|
||||
}
|
||||
_ => (inc, dec),
|
||||
})
|
||||
});
|
||||
// Arrow keys work with either a hovered or a focused slider.
|
||||
if enabled && out.value.is_none() && typing.is_none() && (pointer_in_row || resp.has_focus() || inc || dec) {
|
||||
let (up, down, shift) = ui.input_mut(|i| {
|
||||
let shift = i.modifiers.shift;
|
||||
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
|
||||
(i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift)
|
||||
});
|
||||
let (up, down) = (up || inc, down || dec);
|
||||
if up || down {
|
||||
let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 });
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
v = nv;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under
|
||||
// the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it.
|
||||
// Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel
|
||||
// leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does.
|
||||
let side_id = id.with("side-rem");
|
||||
let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y));
|
||||
if !enabled || typing.is_some() || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() {
|
||||
ui.data_mut(|m| m.remove::<f32>(side_id));
|
||||
} else {
|
||||
// the vertical part would scroll the panel and move the slider out from under the pointer
|
||||
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||||
let mut remainder = ui.data(|m| m.get_temp::<f32>(side_id)).unwrap_or(0.0);
|
||||
let steps = wheel_nudges(&mut remainder, dx);
|
||||
ui.data_mut(|m| m.insert_temp(side_id, remainder));
|
||||
let nv = nudged(spec, value, steps);
|
||||
if steps != 0.0 && (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
v = nv;
|
||||
}
|
||||
}
|
||||
// Wheel nudges require an explicitly selected slider, unlike regular panel scrolling.
|
||||
// Consume only the vertical component so a trackpad's horizontal scroll still works.
|
||||
// (`wheel_armed` is cleared above with its remainder whenever focus or the pointer leaves.)
|
||||
if wheel_armed && enabled && typing.is_none() && out.value.is_none() && pointer_in_row {
|
||||
let delta = ui.input_mut(|i| {
|
||||
let dy = i.smooth_scroll_delta.y;
|
||||
if dy != 0.0 {
|
||||
i.smooth_scroll_delta.y = 0.0;
|
||||
}
|
||||
dy
|
||||
});
|
||||
if delta != 0.0 {
|
||||
let mut remainder = ui.data(|m| m.get_temp::<f32>(wheel_id)).unwrap_or(0.0);
|
||||
let steps = wheel_nudges(&mut remainder, delta);
|
||||
ui.data_mut(|m| m.insert_temp(wheel_id, remainder));
|
||||
if steps != 0.0 {
|
||||
let nv = nudged(spec, value, steps);
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
v = nv;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pointer_edit(ui, &g, &resp, &mut out);
|
||||
nudge_edit(ui, &g, &resp, pointer_in_row, &mut out);
|
||||
side_scroll_edit(ui, &g, pointer_in_row, &mut out);
|
||||
wheel_edit(ui, &g, wheel_armed, pointer_in_row, &mut out);
|
||||
}
|
||||
let v = out.value.unwrap_or(value);
|
||||
// paint
|
||||
let hovered = resp.hovered() || resp.dragged();
|
||||
let text_c = if enabled { t.text_label } else { t.text_disabled };
|
||||
@@ -680,7 +465,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
}
|
||||
}
|
||||
let ring = THUMB_RING;
|
||||
let tx = to_x(v);
|
||||
let tx = g.to_x(v);
|
||||
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
|
||||
let p = ui.painter();
|
||||
let ring_c = if !enabled {
|
||||
@@ -698,6 +483,310 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
out
|
||||
}
|
||||
|
||||
/// A value being typed into a slider: the text, the frames it has been open, and whether the press that opened it is
|
||||
/// still down.
|
||||
type Draft = (String, u8, bool);
|
||||
|
||||
/// What a slider is, as its interaction code needs it.
|
||||
struct SliderLayout<'a> {
|
||||
spec: &'a ControlSpec,
|
||||
value: f64,
|
||||
enabled: bool,
|
||||
id: egui::Id,
|
||||
/// where the thumb moves
|
||||
track_rect: Rect,
|
||||
/// the readout, where a value is typed or scrubbed
|
||||
value_rect: Rect,
|
||||
}
|
||||
|
||||
impl SliderLayout<'_> {
|
||||
fn typing_id(&self) -> egui::Id {
|
||||
self.id.with("typing")
|
||||
}
|
||||
|
||||
fn scrub_id(&self) -> egui::Id {
|
||||
self.id.with("scrub-pos")
|
||||
}
|
||||
|
||||
/// The text field a value is typed in.
|
||||
fn field_id(&self) -> egui::Id {
|
||||
crate::text_field::id(&format!("sliderField:{}", self.spec.id))
|
||||
}
|
||||
|
||||
fn step(&self) -> f64 {
|
||||
self.spec.step.max(1e-9)
|
||||
}
|
||||
|
||||
/// `v` on the control's steps and within its range.
|
||||
fn snap(&self, v: f64) -> f64 {
|
||||
((v / self.step()).round() * self.step()).clamp(self.spec.min, self.spec.max)
|
||||
}
|
||||
|
||||
/// Whether `nv` is a different value from the one shown.
|
||||
fn differs(&self, nv: f64) -> bool {
|
||||
(nv - self.value).abs() > 1e-12
|
||||
}
|
||||
|
||||
/// value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`)
|
||||
fn to_x(&self, v: f64) -> f32 {
|
||||
self.track_rect.left() + self.spec.position(v) as f32 * self.track_rect.width()
|
||||
}
|
||||
|
||||
fn value_at_x(&self, x: f32) -> f64 {
|
||||
self.spec.value_at(f64::from((x - self.track_rect.left()) / self.track_rect.width()))
|
||||
}
|
||||
|
||||
/// Whether `p` is on the thumb.
|
||||
fn on_thumb(&self, p: egui::Pos2) -> bool {
|
||||
(p.x - self.to_x(self.value)).abs() <= THUMB_RING + 1.0
|
||||
}
|
||||
|
||||
/// A change made in one go (a click, a key, a wheel step): it starts and stops the drag at once.
|
||||
fn set_whole(&self, out: &mut SliderOut, nv: f64) {
|
||||
out.value = Some(nv);
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
|
||||
/// Move to `nv` as a whole change, when it is a different value.
|
||||
fn set_whole_if_changed(&self, out: &mut SliderOut, nv: f64) {
|
||||
if self.differs(nv) {
|
||||
self.set_whole(out, nv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell screen readers the slider's range, step and the value as shown ("+0.50", "5600").
|
||||
fn describe_slider(ui: &Ui, g: &SliderLayout, resp: &Response) {
|
||||
let shown = shown_value(g.spec, g.value);
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| {
|
||||
node.set_min_numeric_value(g.spec.min);
|
||||
node.set_max_numeric_value(g.spec.max);
|
||||
node.set_numeric_value_step(g.spec.step);
|
||||
node.set_value(shown);
|
||||
if g.enabled {
|
||||
node.add_action(egui::accesskit::Action::Increment);
|
||||
node.add_action(egui::accesskit::Action::Decrement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A press on the readout that moves sideways starts a scrub: the edit the press opened is dropped. Returns whether
|
||||
/// a scrub started this frame (`scrub` then holds its first position).
|
||||
fn begin_scrub(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, scrub: &mut Option<f64>, typing: &mut Option<Draft>) -> bool {
|
||||
if !g.enabled || scrub.is_some() || !value_resp.dragged() {
|
||||
return false;
|
||||
}
|
||||
let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) else { return false };
|
||||
if (p.x - origin.x).abs() < SCRUB_START {
|
||||
return false;
|
||||
}
|
||||
*typing = None;
|
||||
ui.data_mut(|m| m.remove::<Draft>(g.typing_id()));
|
||||
ui.memory_mut(|m| m.surrender_focus(g.field_id()));
|
||||
// the travel so far counts, so the value doesn't lag the pointer by the start distance
|
||||
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
|
||||
*scrub = Some((g.spec.position(g.value) + f64::from((p.x - origin.x) / g.track_rect.width()) * fine).clamp(0.0, 1.0));
|
||||
true
|
||||
}
|
||||
|
||||
/// Open the typed edit on a press or click of the readout; drop it when the press ends elsewhere.
|
||||
fn update_typing_gesture(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, typing: &mut Option<Draft>) {
|
||||
let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
|
||||
// Focus on the owned primary press before fast typing; retain the readout's click activation.
|
||||
if g.enabled && typing.is_none() && (value_resp.clicked() || value_pressed) {
|
||||
*typing = Some((shown_value(g.spec, g.value).trim_start_matches('+').to_string(), 0, value_pressed));
|
||||
}
|
||||
let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
|
||||
// A release over the value keeps the field the press opened, however long the button was held
|
||||
// (a press held past egui's click time ends without a click). A release elsewhere cancels the
|
||||
// gesture, and a press that scrubs already dropped the field above.
|
||||
let released_over_value = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| g.value_rect.contains(p));
|
||||
if value_released && !released_over_value && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() {
|
||||
*typing = None;
|
||||
ui.data_mut(|m| m.remove::<Draft>(g.typing_id()));
|
||||
ui.memory_mut(|m| m.surrender_focus(g.field_id()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel over the track's width (a
|
||||
/// tenth with Shift), on the control's steps. `pos` is the unrounded track position reached so far.
|
||||
fn scrub_value(ui: &mut Ui, g: &SliderLayout, value_resp: &Response, mut pos: f64, started: bool, out: &mut SliderOut) {
|
||||
if !started && value_resp.dragged() {
|
||||
let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };
|
||||
let dx = ui.input(|i| i.pointer.delta().x);
|
||||
pos = (pos + f64::from(dx / g.track_rect.width()) * fine).clamp(0.0, 1.0);
|
||||
}
|
||||
let nv = g.snap(g.spec.value_at(pos));
|
||||
if g.differs(nv) {
|
||||
out.value = Some(nv);
|
||||
}
|
||||
out.drag_started = started;
|
||||
if value_resp.drag_stopped() {
|
||||
out.drag_stopped = true;
|
||||
ui.data_mut(|m| m.remove::<f64>(g.scrub_id()));
|
||||
} else {
|
||||
ui.data_mut(|m| m.insert_temp(g.scrub_id(), pos));
|
||||
}
|
||||
}
|
||||
|
||||
/// The text field over the readout while a value is typed. Returns the draft to keep, if the edit goes on.
|
||||
fn edit_value(ui: &mut Ui, t: &Tokens, g: &SliderLayout, draft: Draft, out: &mut SliderOut) -> Option<Draft> {
|
||||
let (mut text, frames, pressed) = draft;
|
||||
// Keep the owned opening release focused without restarting its draft or selection.
|
||||
// egui gives that release's click to the readout, so the field under it gives up the focus
|
||||
// whatever frame it lands on (issue #1362): the field takes it back after drawing too.
|
||||
let released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
|
||||
let opening_release = pressed && released;
|
||||
if opening_release {
|
||||
ui.memory_mut(|m| m.request_focus(g.field_id()));
|
||||
}
|
||||
let pressed = pressed && !released;
|
||||
// a child over the value, so the field never moves the rows around it
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.value_rect).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
// the old value is selected when the field takes the focus, so typing replaces it
|
||||
let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", g.spec.id), &mut text)
|
||||
.font(t.font(12.5))
|
||||
.horizontal_align(egui::Align::RIGHT)
|
||||
.width(g.value_rect.width())
|
||||
.margin(egui::Margin::ZERO)
|
||||
.select_on_focus(true)
|
||||
.begin_edit(frames == 0)
|
||||
.show(&mut child);
|
||||
if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) {
|
||||
field.response.request_focus();
|
||||
let draft = (text, frames.saturating_add(1), pressed);
|
||||
ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone()));
|
||||
return Some(draft);
|
||||
}
|
||||
if field.ending.is_some() || !field.editing || !g.enabled {
|
||||
// Esc (or the slider turning off) keeps the old value; Return or clicking away applies
|
||||
let cancelled = !g.enabled || field.cancelled();
|
||||
if let Some(nv) = typed_value(g.spec, &text).filter(|nv| !cancelled && g.differs(*nv)) {
|
||||
g.set_whole(out, nv);
|
||||
}
|
||||
ui.data_mut(|m| m.remove::<Draft>(g.typing_id()));
|
||||
return None;
|
||||
}
|
||||
let draft = (text, frames, pressed);
|
||||
ui.data_mut(|m| m.insert_temp(g.typing_id(), draft.clone()));
|
||||
Some(draft)
|
||||
}
|
||||
|
||||
/// Dragging the thumb or the track, or clicking the track.
|
||||
fn pointer_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, out: &mut SliderOut) {
|
||||
if resp.drag_started() {
|
||||
out.drag_started = true;
|
||||
}
|
||||
if (resp.dragged() || resp.drag_started())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let raw = if ui.input(|i| i.modifiers.shift) { fine_drag_value(ui, g) } else { g.value_at_x(p.x) };
|
||||
let nv = g.snap(raw);
|
||||
if g.differs(nv) {
|
||||
out.value = Some(nv);
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
// a click on the thumb leaves it there, so the first click of a double-click on it isn't a
|
||||
// history step of its own before the reset
|
||||
&& !g.on_thumb(p)
|
||||
{
|
||||
g.set_whole(out, g.snap(g.value_at_x(p.x)));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A tenth of the pointer's movement along the track; the unrounded position is kept, so a slow drag adds up instead
|
||||
/// of rounding back to the same step every frame.
|
||||
fn fine_drag_value(ui: &mut Ui, g: &SliderLayout) -> f64 {
|
||||
let step = g.step();
|
||||
let fine_id = g.id.with("fine-pos");
|
||||
let dx = ui.input(|i| i.pointer.delta().x);
|
||||
let kept = ui.data(|m| m.get_temp::<f64>(fine_id)).filter(|&p| ((g.spec.value_at(p) / step).round() * step - g.value).abs() < step / 2.0);
|
||||
let pos = (kept.unwrap_or_else(|| g.spec.position(g.value)) + f64::from(dx / g.track_rect.width()) * 0.1).clamp(0.0, 1.0);
|
||||
ui.data_mut(|m| m.insert_temp(fine_id, pos));
|
||||
g.spec.value_at(pos)
|
||||
}
|
||||
|
||||
/// A screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) as `(increment, decrement)`.
|
||||
fn accessibility_nudge(ui: &Ui, resp: &Response) -> (bool, bool) {
|
||||
ui.input(|i| {
|
||||
i.events.iter().fold((false, false), |(inc, dec), e| match e {
|
||||
egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => {
|
||||
(inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement)
|
||||
}
|
||||
_ => (inc, dec),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The arrow keys (with either a hovered or a focused slider; Shift: five steps) and a screen reader's actions.
|
||||
fn nudge_edit(ui: &mut Ui, g: &SliderLayout, resp: &Response, pointer_in_row: bool, out: &mut SliderOut) {
|
||||
let (inc, dec) = accessibility_nudge(ui, resp);
|
||||
if !g.enabled || out.value.is_some() || !(pointer_in_row || resp.has_focus() || inc || dec) {
|
||||
return;
|
||||
}
|
||||
let (up, down, shift) = ui.input_mut(|i| {
|
||||
let shift = i.modifiers.shift;
|
||||
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
|
||||
(i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift)
|
||||
});
|
||||
let (up, down) = (up || inc, down || dec);
|
||||
if up || down {
|
||||
let nv = nudged(g.spec, g.value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 });
|
||||
g.set_whole_if_changed(out, nv);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under
|
||||
/// the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it.
|
||||
/// Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel
|
||||
/// leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does.
|
||||
fn side_scroll_edit(ui: &mut Ui, g: &SliderLayout, pointer_in_row: bool, out: &mut SliderOut) {
|
||||
let side_id = g.id.with("side-rem");
|
||||
let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y));
|
||||
if !g.enabled || out.value.is_some() || !pointer_in_row || dx.abs() <= dy.abs() {
|
||||
ui.data_mut(|m| m.remove::<f32>(side_id));
|
||||
return;
|
||||
}
|
||||
// the vertical part would scroll the panel and move the slider out from under the pointer
|
||||
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||||
let mut remainder = ui.data(|m| m.get_temp::<f32>(side_id)).unwrap_or(0.0);
|
||||
let steps = wheel_nudges(&mut remainder, dx);
|
||||
ui.data_mut(|m| m.insert_temp(side_id, remainder));
|
||||
if steps != 0.0 {
|
||||
g.set_whole_if_changed(out, nudged(g.spec, g.value, steps));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wheel nudges require an explicitly selected (armed) slider, unlike regular panel scrolling.
|
||||
/// Consume only the vertical component so a trackpad's horizontal scroll still works.
|
||||
fn wheel_edit(ui: &mut Ui, g: &SliderLayout, armed: bool, pointer_in_row: bool, out: &mut SliderOut) {
|
||||
if !armed || !g.enabled || out.value.is_some() || !pointer_in_row {
|
||||
return;
|
||||
}
|
||||
let delta = ui.input_mut(|i| {
|
||||
let dy = i.smooth_scroll_delta.y;
|
||||
if dy != 0.0 {
|
||||
i.smooth_scroll_delta.y = 0.0;
|
||||
}
|
||||
dy
|
||||
});
|
||||
if delta == 0.0 {
|
||||
return;
|
||||
}
|
||||
let wheel_id = g.id.with("wheel-rem");
|
||||
let mut remainder = ui.data(|m| m.get_temp::<f32>(wheel_id)).unwrap_or(0.0);
|
||||
let steps = wheel_nudges(&mut remainder, delta);
|
||||
ui.data_mut(|m| m.insert_temp(wheel_id, remainder));
|
||||
if steps != 0.0 {
|
||||
g.set_whole_if_changed(out, nudged(g.spec, g.value, steps));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert vertical wheel movement to bounded slider steps, keeping sub-notch
|
||||
/// trackpad movement between frames without amplifying a single large event. One mouse-wheel
|
||||
/// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step.
|
||||
|
||||
Reference in New Issue
Block a user