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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 10:54:56 -04:00
co-authored by Claude Opus 5.5
parent adcc2830dd
commit 8c0b70b37f
3 changed files with 81 additions and 16 deletions
+1
View File
@@ -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();
+52 -16
View File
@@ -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(&current.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| {
+28
View File
@@ -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");