Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/moon-ui-gpui/src/strategies/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mod version_facts;
mod versions;
mod window;

use sections::section_display_title;
use split::{PanelResizeDrag, PanelSplit};
use tree::pane_cache::{LeftPaneFrame, PaneCache};
pub(crate) use window::StrategyRevealRequest;
Expand Down
5 changes: 4 additions & 1 deletion crates/moon-ui-gpui/src/strategies/param_entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub(super) enum ParamEntry {
pub(super) struct ParamLabels<'a> {
/// Caption for the trailing group of changed fields absent from the current kind's schema.
pub(super) orphans: &'a str,
/// Display name for one runtime section title. The caller owns the locale lookup so this module
/// stays pure; a test passes identity and reads the raw titles back.
pub(super) section_title: &'a dyn Fn(&str) -> String,
}

#[cfg(test)]
Expand Down Expand Up @@ -116,7 +119,7 @@ pub(super) fn flatten_params(
field_count += fields.len();
entries.push(ParamEntry::SectionHeader {
section: Some(i),
title: sec.title.clone(),
title: (labels.section_title)(&sec.title),
field_count: fields.len(),
});
entries.extend(fields.into_iter().map(|field| ParamEntry::Field {
Expand Down
34 changes: 34 additions & 0 deletions crates/moon-ui-gpui/src/strategies/param_entries/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ fn version_full_mode_keeps_only_changed_fields_and_groups_orphans() {
false,
ParamLabels {
orphans: "Other fields",
section_title: &|title| title.to_string(),
},
);

Expand All @@ -93,3 +94,36 @@ fn version_full_mode_keeps_only_changed_fields_and_groups_orphans() {
assert_eq!(flat.heading_at.get(&1), Some(&2));
assert_eq!(flat.field_count, changed.len());
}

/// `param_entries.rs::flatten_params`: bypassing `ParamLabels::section_title` for full-mode
/// headings would expose raw schema titles there while the per-section pane remains translated.
#[test]
fn full_mode_section_headers_use_the_section_title_seam() {
let sections = vec![SchemaSection {
title: "Main".to_string(),
fields: vec![field("AutoBuy")],
}];

let flat = flatten_params(
&sections,
None,
false,
None,
false,
ParamLabels {
orphans: "Other fields",
section_title: &|title| format!("<{title}>"),
},
);

assert!(matches!(
flat.entries.first(),
Some(ParamEntry::SectionHeader {
section: Some(0),
title,
field_count: 1,
}) if title == "<Main>"
));
assert_eq!(flat.heading_at, HashMap::from([(0, 0)]));
assert_eq!(flat.field_count, 1);
}
1,313 changes: 1,073 additions & 240 deletions crates/moon-ui-gpui/src/strategies/params.rs

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions crates/moon-ui-gpui/src/strategies/params/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//! Unit tests for strategy-field label lookup.

use super::field_keys;

/// `params.rs::field_keys`: dropping an exact arm or weakening its case-sensitive guard
/// would make a known field fall back to its raw identifier or accept a schema spelling we do not
/// localize, leaving traders with an untranslated label or an invented match.
#[test]
fn field_labels_are_exact_and_fail_closed() {
assert_eq!(
field_keys("AutoBuy"),
Some((Some("strat.field.AutoBuy"), Some("strat.label.AutoBuy")))
);
assert_eq!(field_keys("autobuy"), None);
assert_eq!(
field_keys("SilentNoCharts"),
Some((None, Some("strat.label.SilentNoCharts")))
);
assert_eq!(field_keys("silentnocharts"), None);
}
159 changes: 148 additions & 11 deletions crates/moon-ui-gpui/src/strategies/sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,119 @@

use super::*;

#[cfg(test)]
mod tests;

/// Canonical runtime section titles and the locale key of their human name, in
/// `assets/param_deps.toml` order.
///
/// The schema streams at runtime, so this is an INDEX of what the repository has evidence for,
/// never a catalogue of what can arrive: a title absent here renders verbatim. Titles are matched
/// through [`section_title_eq`], never byte-for-byte -- Moonbot's own spelling differs from the
/// canonical form below in punctuation and spacing.
const SECTION_LABELS: &[(&str, &str)] = &[
("Main", "strat.section.Main"),
(
"Dynamic White/Black List",
"strat.section.DynamicWhiteBlackList",
),
("Filters", "strat.section.Filters"),
("Filters / Base", "strat.section.Filters_Base"),
("Filters / Delta", "strat.section.Filters_Delta"),
("Filters / Ping", "strat.section.Filters_Ping"),
(
"Filters / Price/Position",
"strat.section.Filters_PricePosition",
),
("Filters / Time", "strat.section.Filters_Time"),
("Filters / Volume", "strat.section.Filters_Volume"),
("Buy conditions", "strat.section.BuyConditions"),
("Delta Modifiers", "strat.section.DeltaModifiers"),
("Multiple Orders", "strat.section.MultipleOrders"),
("Sell order", "strat.section.SellOrder"),
("Sell order / SellShot", "strat.section.SellOrder_SellShot"),
(
"Sell order / SellSpread",
"strat.section.SellOrder_SellSpread",
),
("Session", "strat.section.Session"),
("Stops", "strat.section.Stops"),
("Strategy settings", "strat.section.StrategySettings"),
(
"Triggers Master / Slave",
"strat.section.Triggers_MasterSlave",
),
("User Interface", "strat.section.UserInterface"),
];

/// Compare two section titles under the punctuation the runtime actually produces.
///
/// moonproto composes a title as either Moonbot's own chapter string or `"<chapter> / <value>"`
/// (`strategy_schema.rs`), and what reaches the wire differs from the canonical spelling in ways a
/// byte compare cannot survive: `Dynamic White\Black List` arrives with a BACKSLASH, and
/// `Triggers Master / Slave` with a doubled space. So `\` equals `/`, runs of whitespace equal one
/// space, whitespace beside a separator is ignored, and ASCII case is ignored. Allocation-free:
/// both sides are split on the separator and their segments compared word by word, so this can run
/// per frame against every candidate.
///
/// Args:
/// a: One section title, canonical or as the schema streamed it.
/// b: The other title, compared under the same normalization.
///
/// Returns:
/// Whether the two titles name the same section.
fn section_title_eq(a: &str, b: &str) -> bool {
let separator = |c: char| c == '/' || c == '\\';
let mut left = a.split(separator);
let mut right = b.split(separator);
loop {
match (left.next(), right.next()) {
(None, None) => return true,
(Some(x), Some(y)) => {
let mut words_x = x.split_whitespace();
let mut words_y = y.split_whitespace();
loop {
match (words_x.next(), words_y.next()) {
(None, None) => break,
(Some(p), Some(q)) if p.eq_ignore_ascii_case(q) => {}
_ => return false,
}
}
}
_ => return false,
}
}
}

/// Return the locale key of the human name for a runtime section title.
///
/// Args:
/// raw_title: Section title exactly as the streamed schema produced it.
///
/// Returns:
/// A static locale key when the repository has evidence for that section, otherwise `None` --
/// the caller then renders the raw title, the same fail-closed rule the field labels use.
pub(super) fn section_label_key(raw_title: &str) -> Option<&'static str> {
SECTION_LABELS
.iter()
.find(|(canonical, _)| section_title_eq(canonical, raw_title))
.map(|(_, key)| *key)
}

/// Human name for a runtime section title, or the raw title when there is no label for it.
///
/// Args:
/// raw_title: Section title exactly as the streamed schema produced it.
///
/// Returns:
/// The localized section name, or `raw_title` unchanged.
pub(super) fn section_display_title(raw_title: &str) -> String {
match section_label_key(raw_title) {
Some(key) => t!(key).to_string(),
None => raw_title.to_string(),
}
}

impl StrategiesView {
/// Measure the longest selected runtime section title for responsive first-run layout.
///
Expand All @@ -19,7 +132,13 @@ impl StrategiesView {
.and_then(|sections| {
sections
.iter()
.map(|section| design::mono_body_text_width(cx, &section.title, 400.0))
.map(|section| {
design::mono_body_text_width(
cx,
&section_display_title(&section.title),
400.0,
)
})
.reduce(f32::max)
})
.unwrap_or_else(|| {
Expand Down Expand Up @@ -60,15 +179,11 @@ impl StrategiesView {
)
.child(div().w_full().h(px(1.0)).bg(border));

// With nothing selected this column has nothing to list, and the parameters pane to its
// right already says so. Repeating the sentence here made the window ask the same question
// twice side by side, so the column keeps its heading and stays otherwise empty.
let Some(sections) = selected_sections(self, store) else {
return col
.child(
div()
.mt_2()
.text_color(moon(p.text_muted))
.child(t!("strat.no_selection").to_string()),
)
.into_any_element();
return col.into_any_element();
};
if sections.is_empty() {
return col
Expand Down Expand Up @@ -143,7 +258,16 @@ impl StrategiesView {
let on = self.versions.section == Some(i);
let mut row = row_base(SharedString::from(format!("sec-ver-{i}")), cx)
.text_color(moon(p.text))
.child(sec.title.clone())
// The count badge beside it cannot shrink, and a Russian section name is
// half again as long as the schema's own: without this the title paints
// over the badge instead of degrading to an ellipsis.
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.child(section_display_title(&sec.title)),
)
.child(
h_flex().ml_auto().flex_none().child(
MoonBadge::new(n.to_string())
Expand Down Expand Up @@ -197,6 +321,9 @@ impl StrategiesView {
let sec = &sections[i];
let on = self.selected_section == i;
let tcol = if !active { p.text_muted } else { p.text };
// Resolved once: the caption and the raw-title tooltip ask the same question of the
// same string, and this row is rebuilt on every repaint.
let label_key = section_label_key(&sec.title);
let mut row = div()
.id(SharedString::from(format!("sec-{i}")))
.w_full()
Expand All @@ -209,7 +336,17 @@ impl StrategiesView {
.items_center()
.cursor_pointer()
.text_color(moon(tcol))
.child(sec.title.clone())
// The pane is user-resizable down to a width no Russian section name fits, so the
// caption degrades to an ellipsis rather than spilling into the splitter.
.child(div().flex_1().min_w_0().truncate().child(match label_key {
Some(key) => t!(key).to_string(),
None => sec.title.clone(),
}))
// The raw schema title stays one hover away wherever a human name replaced it, so
// a trader who knows Moonbot's own wording can still find the section by it.
.when_some(label_key.map(|_| sec.title.clone()), |row, raw| {
row.tooltip(crate::panels::common::text_tooltip(raw))
})
.on_click(cx.listener(move |this, _, _, cx| {
if this.selected_section != i {
this.selected_section = i;
Expand Down
41 changes: 41 additions & 0 deletions crates/moon-ui-gpui/src/strategies/sections/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Unit tests for normalized strategy-section label lookup.

use super::{SECTION_LABELS, section_label_key, section_title_eq};

/// `sections.rs::section_title_eq`: replacing normalized comparison with raw equality would leave
/// runtime spelling variants untranslated, so the section list would show raw Moonbot headings.
#[test]
fn section_titles_normalize_runtime_spelling_without_collisions() {
let dynamic_slash = section_label_key("Dynamic White/Black List");
assert_eq!(
section_label_key("Dynamic White\\Black List"),
dynamic_slash
);
assert!(dynamic_slash.is_some());
assert_eq!(
section_label_key("Triggers Master / Slave"),
section_label_key("Triggers Master / Slave")
);
assert_eq!(
section_label_key("Filters/Base"),
section_label_key("Filters / Base")
);
assert_eq!(section_label_key(" main "), section_label_key("Main"));
assert_ne!(
section_label_key("Filters / Delta"),
section_label_key("Delta Modifiers")
);
assert_eq!(section_label_key("Not a Moonbot section"), None);

for (left, _) in SECTION_LABELS {
assert!(section_title_eq(left, left));
for (right, _) in SECTION_LABELS {
if left != right {
assert!(
!section_title_eq(left, right),
"canonical section titles {left:?} and {right:?} must not normalize together"
);
}
}
}
}
8 changes: 6 additions & 2 deletions crates/moon-ui-gpui/src/strategies/tree/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@ impl StrategiesView {
) -> AnyElement {
let start_label = t!("strat.start_checked").to_string();
let stop_label = t!("strat.stop_checked").to_string();
// The button says the verb; the tooltip says what it acts on. Deriving the tooltip from the
// caption instead would drop the object the moment the caption is shortened to fit.
let start_tip = t!("strat.start_checked_tip").to_string();
let stop_tip = t!("strat.stop_checked_tip").to_string();
// The same count the cached width was measured against, so the rendered label and the
// density decision cannot describe different states.
let staged = pane.staged;
Expand Down Expand Up @@ -593,7 +597,7 @@ impl StrategiesView {
.primary()
.size(MoonButtonSize::Action)
.leading_icon(MoonButtonIconSlot::new("icons/play.svg"))
.tooltip(format!("▶ {start_label}"))
.tooltip(format!("▶ {start_tip}"))
.on_click({
let plan = plan.clone();
cx.listener(move |this, _, _, cx| {
Expand All @@ -604,7 +608,7 @@ impl StrategiesView {
.outline()
.size(MoonButtonSize::Action)
.leading_icon(MoonButtonIconSlot::new("icons/pause.svg"))
.tooltip(format!("■ {stop_label}"))
.tooltip(format!("■ {stop_tip}"))
.on_click({
let plan = plan.clone();
cx.listener(move |this, _, _, cx| {
Expand Down
Loading