mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 13:38:14 -06:00
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:
co-authored by
Claude Opus 5.5
parent
adcc2830dd
commit
8c0b70b37f
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user