From 8c0b70b37f04468ccce9343e1b446d62ae9f79d5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 06:58:31 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: field menu with submenus in the rule editor The rule editor's field dropdown shows Rating, Pick Flag, Color Label and Any Searchable Text at the top, then a separator and one submenu per field group instead of one long flat list. The group holding the current field is shown in bold. Picking a field from a submenu closes the menu and keeps the operator only when the new field has it. Widget ids ruleField / ruleFieldGroup / ruleFieldItem let the control channel drive it, and group names join the i18n display-label gap report. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/panels/rules_editor.rs | 68 +++++++++++++++++------ crates/ui-egui/src/tests_masking.rs | 28 ++++++++++ 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 15992604c..3cdd83b93 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -640,6 +640,7 @@ mod tests { labels.push(label); labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); } + labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); let ui_labels = labels.len(); diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index b498f6693..98918414c 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -2,7 +2,7 @@ //! value), with nested groups. Field and operator lists come from `lightcraft_catalog::rules`. use egui::RichText; -use lightcraft_catalog::rules::{FIELDS, Kind, field_kind, ops_for}; +use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, TOP_LEVEL_FIELDS, field_kind, field_label, ops_for}; use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; @@ -133,6 +133,56 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & } } +/// The field menu of one rule: the top-level fields, then one submenu per field group. Picking a +/// field keeps the operator when the new field has it, else takes the field's first operator. +fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String, value: &mut Value) { + let label = field_label(field).unwrap_or(field.as_str()).to_string(); + let current = field.clone(); + let r = + egui::ComboBox::from_id_salt(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { + let mut pick = None; + let mut item = |ui: &mut egui::Ui, id: &'static str| { + let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); + register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); + if r.clicked() { + pick = Some(id); + ui.close(); + } + }; + for id in TOP_LEVEL_FIELDS { + item(ui, id); + } + ui.separator(); + // submenu rows look like the items above them; the one holding the current field stands out + // (a transparent frame, not none: a frameless button drops its padding and hover fill) + let inactive = &mut ui.visuals_mut().widgets.inactive; + inactive.weak_bg_fill = egui::Color32::TRANSPARENT; + inactive.bg_stroke = egui::Stroke::NONE; + for (group, ids) in FIELD_GROUPS { + let text = RichText::new(crate::i18n::tr(group)); + let text = if ids.contains(¤t.as_str()) { text.strong() } else { text }; + let r = ui.menu_button(text, |ui| { + for id in *ids { + item(ui, id); + } + }); + register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); + } + pick + }); + register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect); + let Some(Some(id)) = r.inner else { return }; + if field == id { + return; + } + *field = id.to_string(); + let ops = ops_for(field_kind(id).unwrap_or(Kind::Text)); + if !ops.iter().any(|o| o.0 == op.as_str()) { + *op = ops.first().map_or("is", |o| o.0).to_string(); + } + *value = default_value(field, op); +} + /// Edit `rs` in place; `salt` keeps widget ids apart between groups. pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { let t = Tokens::get(ui.ctx()); @@ -158,21 +208,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { } Rule::Field { field, op, value } => { ui.horizontal(|ui| { - let label = FIELDS.iter().find(|f| f.0 == field.as_str()).map_or(field.as_str(), |f| f.1).to_string(); - egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui( - ui, - |ui| { - for (id, l, k) in FIELDS { - if ui.selectable_label(field == id, crate::i18n::tr(l)).clicked() && field != id { - *field = id.to_string(); - if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) { - *op = ops_for(*k)[0].0.to_string(); - } - *value = default_value(field, op); - } - } - }, - ); + field_menu(ui, &rsalt, field, op, value); let kind = field_kind(field).unwrap_or(Kind::Text); let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0729a854f..5358f3717 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -8,6 +8,7 @@ use serde_json::json; use crate::headless::Headless; use crate::state::RightPanel; use crate::{LightcraftApp, Services}; +use lightcraft_catalog::Rule; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); @@ -295,6 +296,33 @@ fn smart_album_rule_editor_creates_and_edits() { h.settle(SETTLE); } +/// Given the rule editor, the field menu shows the top-level fields and one submenu per group; +/// picking a field from a submenu changes the rule and gives it an operator of that field. +#[test] +fn smart_album_field_menu_groups_fields_in_submenus() { + let mut h = detail("panel.edit"); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Grouped"})); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let r = h.request("ui.clickWidget", json!({"id": "ruleField:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(has(&h, "ruleFieldItem:rating:rules-0"), "top-level fields are in the menu itself"); + for (group, _) in lightcraft_catalog::rules::FIELD_GROUPS { + assert!(has(&h, &format!("ruleFieldGroup:{group}:rules-0")), "no {group} submenu"); + } + assert!(!has(&h, "ruleFieldItem:filePath:rules-0"), "grouped fields wait in their submenu"); + let r = h.request("ui.hoverWidget", json!({"id": "ruleFieldGroup:File:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "ruleFieldItem:filePath:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Rule::Field { field, op, .. } = &rules.rules[0] else { panic!("not a field rule") }; + assert_eq!((field.as_str(), op.as_str()), ("filePath", "contains"), "Rating's ≥ isn't a text operator"); + assert!(!has(&h, "ruleFieldItem:rating:rules-0") && !has(&h, "ruleFieldItem:filePath:rules-0"), "picking a field closes the menu"); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit");