diff --git a/crates/moon-chart/src/trade_marks/tests.rs b/crates/moon-chart/src/trade_marks/tests.rs index c79f4017..bcda9f99 100644 --- a/crates/moon-chart/src/trade_marks/tests.rs +++ b/crates/moon-chart/src/trade_marks/tests.rs @@ -110,3 +110,22 @@ fn trade_hit_area_grows_with_the_drawn_arrow_scale() { vec![0] ); } + +/// The shipped graphics settings must survive their own normalizer. +/// +/// `normalize_chart_graphics` exists because this value is COMPARED — a chart re-bakes its base +/// texture when its settings differ from the ones it drew with — so a value that the normalizer +/// still moves differs from itself on every notification, which is a re-bake per frame rather than +/// a wrong pixel. The defaults are handed straight to a chart by +/// `WindowLayout::reset_chart_graphics_default`, without passing the normalizer on the way, and +/// this is what keeps that shortcut honest: a `def_*` that ever drifts outside its own clamp fails +/// here rather than in the frame loop. +#[test] +fn the_shipped_graphics_survive_their_own_normalizer() { + let shipped = ChartGraphicsCfg::default(); + assert_eq!( + normalize_chart_graphics(shipped), + shipped, + "a shipped graphics default sits outside the range its own normalizer accepts" + ); +} diff --git a/crates/moon-core/src/config/chart_defaults.rs b/crates/moon-core/src/config/chart_defaults.rs index 2c445093..c910e6c4 100644 --- a/crates/moon-core/src/config/chart_defaults.rs +++ b/crates/moon-core/src/config/chart_defaults.rs @@ -10,8 +10,9 @@ //! on `WindowLayout` stays the [`ChartTabKind::Main`] default — an old profile keeps working, and a //! reader who never touches the feature keeps one default for everything — and the other kinds hold //! [`ChartTabDefaults`], which is empty until the first time a default is set FOR that kind. Empty -//! means "follow Main", except for the trade window's captions, which fall back to their own -//! built-in set ([`crate::config::ChartLabelsCfg::trade_default`]); see +//! means "follow Main", except for the CAPTIONS of the two kinds that ship their own set — the +//! trade window ([`crate::config::ChartLabelsCfg::trade_default`]) and a comparison +//! ([`crate::config::ChartLabelsCfg::compare_default`]); see //! [`WindowLayout::set_chart_labels_default`](super::layout::WindowLayout::set_chart_labels_default) //! for what the first press does about that. @@ -43,13 +44,17 @@ pub enum ChartTabKind { AddTo, /// A tab under the anchor lock, wherever it lives: in the strip, in a window, or the main /// chart itself. + /// + /// Ships its own captions ([`crate::config::ChartLabelsCfg::compare_default`]): several panes + /// of one coin, each a third of the usual width, are read by what tells them APART, and the + /// live default's per-market blocks are printed over and over on such a tab. Compare, /// The trade-detail window: one closed trade, drawn from a frozen replay. /// /// Not a tab at all, and the only kind that is a WINDOW rather than a state a tab can be in — /// which is exactly why it needs its own defaults. It shows a market that stopped moving hours /// ago, so the captions a live chart is read with describe something that is not on the screen. - /// It is the one kind whose caption default is NOT Main's: see + /// Its caption default is its own rather than Main's, as [`Self::Compare`]'s is: see /// [`crate::config::ChartLabelsCfg::trade_default`]. Trade, } @@ -63,6 +68,24 @@ impl ChartTabKind { ChartTabKind::Trade, ]; + /// Every kind, in the order a RESET must visit them: Main first. + /// + /// A reset of Main goes through the setter, whose separation pass freezes every kind that still + /// FOLLOWS Main — which is what stops a press on the main chart from moving a kind the reader + /// never ticked. A kind the same press also addresses is emptied again on its own turn, so it + /// ends up following the reset Main rather than frozen at the value just discarded — and that + /// only holds while Main is visited first. + /// + /// Beside [`Self::ALL`] rather than as a rule written at the caller: the ordering is a property + /// of what these kinds ARE to each other, and the caller is in another crate where nothing + /// tests it. + pub const RESET_ORDER: [ChartTabKind; 4] = [ + ChartTabKind::Main, + ChartTabKind::AddTo, + ChartTabKind::Compare, + ChartTabKind::Trade, + ]; + /// The kinds that are TABS, for the walks that visit tabs. /// /// [`Self::Trade`] is absent: its windows are opened from the Report and live outside the tab @@ -93,7 +116,14 @@ impl ChartTabKind { static TRADE: std::sync::OnceLock = std::sync::OnceLock::new(); Some(TRADE.get_or_init(ChartLabelsCfg::trade_default)) } - ChartTabKind::Main | ChartTabKind::AddTo | ChartTabKind::Compare => None, + // A comparison draws the SAME market several times over, in panes a third the width, so + // Main's per-market blocks are printed once per pane and the pane's own identity — its + // venue, its scale, its spread against the anchor — is what the eye is there for. + ChartTabKind::Compare => { + static COMPARE: std::sync::OnceLock = std::sync::OnceLock::new(); + Some(COMPARE.get_or_init(ChartLabelsCfg::compare_default)) + } + ChartTabKind::Main | ChartTabKind::AddTo => None, } } @@ -126,11 +156,15 @@ impl ChartTabKind { /// a reader who never splits the defaults apart. It is per SETTING rather than per kind: setting /// the caption default for windows must not freeze their candles as a side effect. /// -/// The FIRST press for a setting fills this in for EVERY other non-Main kind, not only the one -/// addressed: separating the defaults is the moment they stop moving together, and a Compare that -/// still followed Main would jump the next time the main chart's default was set — which is the -/// surprise the split exists to remove. Each kind is frozen at what IT was showing, which for the -/// trade window's captions is its own built-in set rather than Main's. +/// The FIRST press for a setting fills this in for every other non-Main kind that FOLLOWS Main, not +/// only the one addressed: separating the defaults is the moment they stop moving together, and an +/// AddTo that still followed Main would jump the next time the main chart's default was set — which +/// is the surprise the split exists to remove. Each such kind is frozen at what IT was showing. +/// +/// A kind that ships its own set for a setting is skipped instead: it does not follow Main, so +/// there is nothing to separate it from, and a copy taken today would outlive every later +/// improvement to that set. For the captions that is both the trade window and a comparison; see +/// [`ChartTabKind::builtin_labels`]. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ChartTabDefaults { diff --git a/crates/moon-core/src/config/chart_defaults/tests.rs b/crates/moon-core/src/config/chart_defaults/tests.rs index 372fe011..8687539f 100644 --- a/crates/moon-core/src/config/chart_defaults/tests.rs +++ b/crates/moon-core/src/config/chart_defaults/tests.rs @@ -52,16 +52,50 @@ fn the_tab_kinds_are_every_kind_but_the_trade_window() { assert_eq!(ChartTabKind::TAB_KINDS.to_vec(), expected); } -/// Only the trade window ships captions of its own; every other kind follows the Main default. +/// Two kinds ship captions of their own — the trade window and a comparison — because both draw +/// something the live default's figures do not describe: a market that stopped moving hours ago, +/// and the same market several times over in panes a third the width. The other two follow Main. #[test] -fn only_the_trade_window_ships_its_own_captions() { +fn the_kinds_that_ship_their_own_captions() { for kind in ChartTabKind::ALL { + let ships = matches!(kind, ChartTabKind::Trade | ChartTabKind::Compare); assert_eq!( kind.builtin_labels().is_some(), - kind == ChartTabKind::Trade, + ships, "{kind:?} disagrees about shipping its own captions" ); } + // Two sets, not one shared by both: they answer different questions and would have been one + // function if they did not. + assert_ne!( + ChartTabKind::Trade.builtin_labels(), + ChartTabKind::Compare.builtin_labels() + ); + // Shared and stable: this is read on every settings comparison, and a fresh clone per read + // would make a panel's signature differ from itself. + assert!(std::ptr::eq( + ChartTabKind::Compare.builtin_labels().expect("a set"), + ChartTabKind::Compare.builtin_labels().expect("a set") + )); +} + +/// A reset walk visits Main FIRST and still visits every kind. +/// +/// Resetting Main separates the kinds that follow it — that is what keeps a press on the main chart +/// from moving a kind the reader never ticked — so a kind the same press also resets has to be +/// emptied AFTER that separation, or it keeps a frozen copy of the value being discarded. The order +/// is the whole mechanism, and the caller that walks it lives in another crate. +#[test] +fn a_reset_walk_starts_at_main_and_reaches_every_kind() { + assert_eq!(ChartTabKind::RESET_ORDER[0], ChartTabKind::Main); + let mut sorted = ChartTabKind::RESET_ORDER.to_vec(); + let mut all = ChartTabKind::ALL.to_vec(); + sorted.sort_by_key(|k| format!("{k:?}")); + all.sort_by_key(|k| format!("{k:?}")); + assert_eq!( + sorted, all, + "a reset must reach exactly the kinds that exist" + ); } /// The runtime classifier never produces the trade window: that kind is set by the window itself, diff --git a/crates/moon-core/src/config/chart_labels.rs b/crates/moon-core/src/config/chart_labels.rs index 0333b641..76db0064 100644 --- a/crates/moon-core/src/config/chart_labels.rs +++ b/crates/moon-core/src/config/chart_labels.rs @@ -1264,6 +1264,99 @@ impl ChartLabelsCfg { cfg } + /// The working set a COMPARISON opens with. + /// + /// Its own value rather than [`Self::default`] for the reason the trade window has one: a + /// comparison is READ differently. Several panes of the same coin stand side by side, and the + /// question asked of them is where this venue is against that one — so the figures that + /// describe ONE market in depth are what has to go. The live default's volume block, its + /// measuring block and its session counters are printed three or four times over on such a + /// tab, in panes a third the width, and none of them is what the eye is there for. + /// + /// What stays is what a comparison is read by: which venue this pane is, how far its scale + /// reaches, what is open on it, the venue roster, and the spread against the anchor. The last + /// one — [`ChartLabelField::CompareDelta`] — is fed only on a book-only broom follower + /// (`chartdx::text::captions`), so on any other pane it prints nothing and takes no room, the + /// way every optional figure here behaves. It appears in no other shipped set for the same + /// reason. + /// + /// Transcribed from the developer's own comparison tab on 2026-09-03, the way + /// [`Self::default`] was transcribed from their main chart: a set that has been USED rather + /// than assembled. Two SIZES come with it, which is where this set parts company with + /// `default`'s "no sizes at all", and they go in OPPOSITE directions on purpose: against + /// [`LABEL_SIZE_MULT_DEFAULT`] the badge is a step up and the venue roster a step down. In a + /// pane a third of the usual width the badge is what a glance checks first, and the roster is + /// a dozen lines that have to fit beside the plot at all. The cost is the one `default` + /// documents — these two captions no longer follow that number if it moves — and it is + /// accepted here because the sizes ARE the layout on a narrow pane. + /// + /// It follows the LOCK rather than the pane's width, and that is the intended reading: the + /// anchor lock is a state the reader puts on and takes off, and while it is on the question + /// being asked of the chart is "this venue against that one" whether the pane is a third of the + /// screen or all of it. Locking a single full-width main chart therefore re-dresses it too, and + /// unlocking hands it back the set of the kind its place gives it — nothing is written to the + /// profile either way. + /// + /// It is a DEFAULT, not a fixture: the moment the reader sets one for comparisons, theirs is + /// what opens. See [`super::chart_defaults::ChartTabKind::Compare`]. + pub fn compare_default() -> Self { + // Stated as STEPS from the shared size rather than as absolutes, so this pair keeps its + // relationship to that number if it ever moves — which is the property `default`'s "no + // sizes at all" protects, kept here at the one place a size is worth spending. + const BADGE_STEP: f32 = 0.2; + const ROSTER_STEP: f32 = 0.25; + + let mut cfg = Self::empty(); + + // The same block the live default opens with, in the control strip: on a tab where every + // pane is the same coin, the venue under it is the pane's whole identity. + cfg.push_prepared(instrument_row(true)); + + // The Y-scale badge, a step ABOVE the shared size: two panes are only comparable while + // their scales are, and this is the caption that states one. Through the preset rather than + // hand-built, so its band and alignment cannot drift from the catalogue's. + if let Some(ix) = cfg.push_preset(LabelPreset::Scale) { + cfg.rows[ix].parts[0].style.size_mult = Some(LABEL_SIZE_MULT_DEFAULT + BADGE_STEP); + } + + // What is open on THIS venue, as one line along the plot's top-left edge: the reason a + // comparison is usually being looked at. Every figure keeps its caption, unlike the live + // default's copy of this module — on a tab of near-identical panes the bare percentage + // beside a bare amount is the one place a reader has to guess which is which. + let mut orders = ChartLabelRow::new(LabelZone::ChartTop, LabelAlign::Left); + orders.preset = Some(LabelPreset::Position); + orders.placement = LabelFlow::Row; + orders.push_part(ChartLabelField::OpenOrders); + orders.push_part(ChartLabelField::OpenPnlMoney); + orders.push_part(ChartLabelField::OpenPnlPct); + orders.push_part(ChartLabelField::Exposure); + cfg.push_prepared(orders); + + // The venue roster down the left edge, a step BELOW the shared size: it is the tallest + // module the chart prints and a narrow pane has to hold all of it. Only a spread worth + // acting on is coloured, exactly as the live default sets it. + if let Some(ix) = cfg.push_preset(LabelPreset::Arbitrage) { + let row = &mut cfg.rows[ix]; + row.gap = 8; + row.parts[0].style.color = Some(LabelColor::BySign); + row.parts[0].style.color_min_pct = Some(0.5); + row.parts[0].style.size_mult = Some(LABEL_SIZE_MULT_DEFAULT - ROSTER_STEP); + } + + // The spread against the anchor, in the strip's bottom band where the pane's own numbers + // end. No preset and no name: the field names itself, and there is no module for it to be + // one of. + cfg.push_row( + ChartLabelField::CompareDelta, + LabelZone::ZoneBottom, + LabelAlign::Right, + ); + + // Repaired here for [`Self::trade_default`]'s reason: one shape wherever it is compared. + cfg.sanitize(); + cfg + } + /// A configuration with no rows at all. /// /// Public because "print nothing" is a legitimate choice a user can reach by removing every diff --git a/crates/moon-core/src/config/chart_labels/tests.rs b/crates/moon-core/src/config/chart_labels/tests.rs index 1324c978..5888eaa4 100644 --- a/crates/moon-core/src/config/chart_labels/tests.rs +++ b/crates/moon-core/src/config/chart_labels/tests.rs @@ -1446,3 +1446,26 @@ fn a_label_timeframe_round_trips_only_when_it_was_chosen() { let back: ChartLabelsCfg = serde_json::from_str(&chosen).expect("reads"); assert_eq!(back.rows[0].parts[0].tf, LabelTf::H4); } + +/// Every shipped set must be a fixpoint of `sanitize`. +/// +/// These values are COMPARED — a panel's settings signature is rebuilt on every backend +/// notification, and one side of that comparison has been through the repair while the other has +/// not. A shipped set that `sanitize` still moves would therefore differ from itself forever, which +/// is a repaint per notification rather than a wrong pixel: the failure this asserts against is +/// silent and permanent. +#[test] +fn the_shipped_sets_survive_their_own_repair() { + for (name, shipped) in [ + ("default", ChartLabelsCfg::default()), + ("trade", ChartLabelsCfg::trade_default()), + ("compare", ChartLabelsCfg::compare_default()), + ] { + let mut repaired = shipped.clone(); + repaired.sanitize(); + assert_eq!( + repaired, shipped, + "the {name} set is not a sanitize fixpoint" + ); + } +} diff --git a/crates/moon-core/src/config/layout.rs b/crates/moon-core/src/config/layout.rs index 0b0b4bae..f3371019 100644 --- a/crates/moon-core/src/config/layout.rs +++ b/crates/moon-core/src/config/layout.rs @@ -976,7 +976,9 @@ pub struct WindowLayout { deserialize_with = "super::chart_defaults::ChartTabDefaults::de_lenient_boxed" )] pub chart_defaults_addto: Box, - /// Defaults for tabs under the anchor lock, wherever they live. Empty means "follow Main". + /// Defaults for tabs under the anchor lock, wherever they live. Empty means "follow Main" for + /// the candles and the graphics, and this kind's OWN shipped set for the captions — see + /// [`super::chart_labels::ChartLabelsCfg::compare_default`]. #[serde( default, deserialize_with = "super::chart_defaults::ChartTabDefaults::de_lenient_boxed" @@ -1643,7 +1645,7 @@ impl WindowLayout { let split = self.split_defaults( |d| &mut d.chart_labels, |l, k| l.chart_labels_for(k).clone(), - // The trade window ships its own captions; see the guard inside. + // The trade window and a comparison both ship their own captions; see the guard inside. |k| k.builtin_labels().is_some(), ); // `|` rather than `||`: the store must run even when the separation already reported a @@ -1674,6 +1676,67 @@ impl WindowLayout { } } + /// Put one kind's candle default back to what the terminal ships, reporting whether it moved. + /// + /// Two different acts under one word, because "back to the shipped set" means two different + /// things depending on where the kind's default lives: + /// + /// - a non-Main kind's slot is EMPTIED, so it follows again whatever it followed before anyone + /// pressed anything — its own built-in set where it has one, Main where it does not. Storing + /// today's shipped value instead would freeze the kind on a copy, and a later build that + /// improved that set would never reach the reader. + /// - Main's default IS the base field and cannot be emptied, so it takes the shipped value — + /// through the setter, whose separation pass keeps the other kinds where they are. Without + /// that, resetting the main chart would drag every kind still following it along, which is + /// the one thing a per-kind reset must not do. The cost is deliberate and worth naming: a + /// kind that held nothing is left holding a copy of what it was showing, so it is now + /// detached from Main. Moving it instead — silently redressing tabs the reader did not tick + /// — is the worse of the two. + /// + /// "The shipped set" therefore means what the kind ships FOR THIS SETTING, and only the + /// captions have any: a comparison and the trade window answer + /// [`super::chart_defaults::ChartTabKind::builtin_labels`], while candles and graphics ship + /// none at all. For everything else — and for `AddTo` even in the captions — an emptied slot + /// means "follow Main again" rather than a set of its own. + pub fn reset_candle_view_default(&mut self, kind: super::chart_defaults::ChartTabKind) -> bool { + match self.kind_defaults_mut(kind) { + Some(d) => d.candle_view.take().is_some(), + None => { + self.set_candle_view_default(kind, crate::market::candles::CandleViewCfg::default()) + } + } + } + + /// Put one kind's graphics default back to the shipped set; see + /// [`Self::reset_candle_view_default`]. + pub fn reset_chart_graphics_default( + &mut self, + kind: super::chart_defaults::ChartTabKind, + ) -> bool { + match self.kind_defaults_mut(kind) { + Some(d) => d.chart_graphics.take().is_some(), + None => self.set_chart_graphics_default(kind, ChartGraphicsCfg::default()), + } + } + + /// Put one kind's caption default back to the shipped set; see + /// [`Self::reset_candle_view_default`]. + /// + /// Emptying the slot is what hands a comparison and the trade window their OWN shipped + /// captions back, rather than Main's: both kinds answer + /// [`super::chart_defaults::ChartTabKind::builtin_labels`]. + pub fn reset_chart_labels_default( + &mut self, + kind: super::chart_defaults::ChartTabKind, + ) -> bool { + match self.kind_defaults_mut(kind) { + Some(d) => d.chart_labels.take().is_some(), + None => { + self.set_chart_labels_default(kind, super::chart_labels::ChartLabelsCfg::default()) + } + } + } + /// This kind's own defaults, or `None` for Main, whose defaults are the base fields. fn kind_defaults( &self, diff --git a/crates/moon-core/src/config/layout/tests.rs b/crates/moon-core/src/config/layout/tests.rs index a6679fe3..96960ade 100644 --- a/crates/moon-core/src/config/layout/tests.rs +++ b/crates/moon-core/src/config/layout/tests.rs @@ -1557,7 +1557,8 @@ fn the_trade_window_falls_back_to_its_own_captions() { "the built-in trade set prints the live figure {field:?}" ); } - // The other two kinds still follow Main, which the split has not touched. + // AddTo — the one kind that ships no captions of its own — still follows Main, which the split + // has not touched. assert_eq!( layout.chart_labels_for(ChartTabKind::AddTo), &layout.chart_labels @@ -1599,11 +1600,16 @@ fn separating_the_kinds_does_not_hand_the_trade_window_mains_captions() { &ChartLabelsCfg::trade_default(), "the trade window kept its own set when the kinds were separated" ); - // And the two tab kinds kept the set they were showing at that moment, which IS Main's old one. + // And the one tab kind that follows Main kept the set it was showing at that moment, which IS + // Main's old one. A comparison is skipped by the freeze for the trade window's reason. assert_eq!( layout.chart_labels_for(ChartTabKind::AddTo), &ChartLabelsCfg::default() ); + assert!( + layout.chart_defaults_compare.chart_labels.is_none(), + "a comparison ships its own captions and must not be frozen at a copy of Main's" + ); // The trade window's slot stays EMPTY rather than frozen at a copy of the shipped set: it does // not follow Main, so there is nothing to separate it from — and a copy taken today would // outlive every later improvement to that set. @@ -1644,6 +1650,138 @@ fn storing_one_kinds_captions_separates_nothing() { assert!(layout.chart_defaults_addto.chart_labels.is_some()); } +/// A comparison opens on ITS OWN captions, not on the main chart's. +/// +/// Several panes of one coin stand side by side there, each a third of the usual width: the live +/// default's per-market blocks are printed once per pane, and what tells the panes apart — the +/// venue, the scale, the spread against the anchor — is what the tab is read by. +#[test] +fn a_comparison_falls_back_to_its_own_captions() { + use crate::config::chart_defaults::ChartTabKind; + use crate::config::chart_labels::{ChartLabelField, ChartLabelsCfg}; + + let layout = WindowLayout::default(); + let compare = layout.chart_labels_for(ChartTabKind::Compare); + assert_eq!(compare, &ChartLabelsCfg::compare_default()); + assert_ne!( + compare, &layout.chart_labels, + "a comparison must not inherit the main chart's captions" + ); + // It prints what a comparison is read by, including the one field that has nothing to say + // anywhere else. + for field in [ + ChartLabelField::CompareDelta, + ChartLabelField::ArbColumn, + ChartLabelField::ScaleBadge, + ] { + assert!( + compare.any_drawn(|f| f == field), + "the built-in comparison set does not print {field:?}" + ); + } + // And none of the per-market blocks that would be repeated in every pane. + for field in [ + ChartLabelField::WindowBuyVolume, + ChartLabelField::SessionProfit, + ChartLabelField::Funding, + ] { + assert!( + !compare.any_drawn(|f| f == field), + "the built-in comparison set repeats the per-market figure {field:?} in every pane" + ); + } + // AddTo is the only tab kind left following Main. + assert_eq!( + layout.chart_labels_for(ChartTabKind::AddTo), + &layout.chart_labels + ); +} + +/// Resetting one kind's default puts THAT kind back on the shipped set and leaves its neighbours +/// exactly where they were. +/// +/// This is the only way back to a shipped set once a default has been stored over it — a profile +/// that pressed "make default" once holds a frozen copy in every kind — so it has to be reachable +/// per kind, and it must never be a way to lose the other three. +#[test] +fn resetting_one_kinds_captions_leaves_the_other_kinds_alone() { + use crate::config::chart_defaults::ChartTabKind; + use crate::config::chart_labels::{ChartLabelsCfg, LabelPreset}; + + let mut layout = WindowLayout::default(); + let mut mine = ChartLabelsCfg::empty(); + mine.push_preset(LabelPreset::Scale); + assert!(layout.set_chart_labels_default(ChartTabKind::Compare, mine.clone())); + let mut windows = ChartLabelsCfg::empty(); + windows.push_preset(LabelPreset::Funding); + assert!(layout.set_chart_labels_default(ChartTabKind::AddTo, windows.clone())); + assert_eq!(layout.chart_labels_for(ChartTabKind::Compare), &mine); + + assert!(layout.reset_chart_labels_default(ChartTabKind::Compare)); + assert_eq!( + layout.chart_labels_for(ChartTabKind::Compare), + &ChartLabelsCfg::compare_default(), + "an emptied slot must fall back to the kind's own shipped set" + ); + // Emptied rather than filled with a copy: a later build that improves the shipped set has to + // reach this profile. + assert!(layout.chart_defaults_compare.chart_labels.is_none()); + // The neighbours kept every value they held. + assert_eq!(layout.chart_labels_for(ChartTabKind::AddTo), &windows); + assert_eq!( + layout.chart_labels_for(ChartTabKind::Trade), + &ChartLabelsCfg::trade_default() + ); + assert_eq!( + layout.chart_labels_for(ChartTabKind::Main), + &ChartLabelsCfg::default() + ); + // Nothing left to remove, so a second press moves nothing and must not dirty the file. + assert!(!layout.reset_chart_labels_default(ChartTabKind::Compare)); +} + +/// Resetting MAIN takes the shipped value — its default is the base field and cannot be emptied — +/// and must not drag the kinds that were following it along. +#[test] +fn resetting_main_takes_the_shipped_set_without_moving_the_others() { + use crate::config::chart_defaults::ChartTabKind; + use crate::config::chart_labels::{ChartLabelsCfg, LabelPreset}; + use crate::market::candles::CandleViewCfg; + + let mut layout = WindowLayout::default(); + let mut mine = ChartLabelsCfg::empty(); + mine.push_preset(LabelPreset::Instrument); + assert!(layout.set_chart_labels_default(ChartTabKind::Main, mine.clone())); + let mut windows = ChartLabelsCfg::empty(); + windows.push_preset(LabelPreset::Position); + assert!(layout.set_chart_labels_default(ChartTabKind::AddTo, windows.clone())); + + assert!(layout.reset_chart_labels_default(ChartTabKind::Main)); + assert_eq!( + layout.chart_labels_for(ChartTabKind::Main), + &ChartLabelsCfg::default() + ); + assert_eq!( + layout.chart_labels_for(ChartTabKind::AddTo), + &windows, + "resetting the main chart must not reach into another kind's stored default" + ); + + // The same for a setting no kind ships its own of: emptying a non-Main slot returns it to + // FOLLOWING Main rather than to a value of its own. + let dense = CandleViewCfg { + tf_min: 1, + ..CandleViewCfg::default() + }; + assert!(layout.set_candle_view_default(ChartTabKind::Compare, dense)); + assert_eq!(layout.candle_view_for(ChartTabKind::Compare), dense); + assert!(layout.reset_candle_view_default(ChartTabKind::Compare)); + assert_eq!( + layout.candle_view_for(ChartTabKind::Compare), + layout.candle_view + ); +} + /// `WindowLayout` must stay SMALL, because it is moved on the stack. /// /// It is loaded, cloned for snapshots and handed to the persistence pass, so its size is paid diff --git a/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs b/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs index f76b2624..a37ca888 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/add_stack.rs @@ -56,7 +56,8 @@ pub(crate) struct AddChartStack { candle_view: Option, /// Tab chart-drawing settings (`None` = the global `layout.chart_graphics` default). chart_graphics: Option, - /// Tab chart captions (`None` = the global `layout.chart_labels` default). + /// Tab chart captions (`None` = the default of the tab's KIND, which for a comparison is a + /// shipped set of its own rather than Main's; see `moon_core::config::ChartTabKind`). chart_labels: Option, /// Captions a panel's own right-click menu produced, on their way to the host that persists /// them. See `panels::chart::volume_menu` for why they travel rather than being written here. diff --git a/crates/moon-ui-gpui/src/chart_tabs/apply_all.rs b/crates/moon-ui-gpui/src/chart_tabs/apply_all.rs index 57dd659d..34297022 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/apply_all.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/apply_all.rs @@ -14,12 +14,15 @@ //! it follows the default live, so overwriting the default changed Main as surely as editing it, //! and a press from anywhere else had to freeze Main first to stop it. //! -//! Split apart, both halves get simpler. A press either: +//! Split apart, both halves get simpler. A press does one of three things ([`ApplyMode`]): //! -//! - **sets the default** for the kinds it names ([`ApplyAll::as_default`]) — which also CLEARS the -//! override of every tab of those kinds, open or closed, so they follow the new default rather -//! than each keeping a frozen copy of it. A later change of that default reaches them again; -//! copying, which is what the old press did, was a one-way ticket. +//! - **sets the default** for the kinds it names — which also CLEARS the override of every tab of +//! those kinds, open or closed, so they follow the new default rather than each keeping a frozen +//! copy of it. A later change of that default reaches them again; copying, which is what the old +//! press did, was a one-way ticket. +//! - **resets the default** of those kinds to the set the terminal ships, clearing the same +//! overrides — the only way back to a shipped set once a default has been stored over it, and +//! the reason it is a press rather than a per-tab button: the value it removes is per KIND. //! - **writes the values** into the tabs of those kinds as their own overrides. This is what the ⚙ //! layout popup does, because its values have no default to set: they are per-tab only. //! @@ -77,10 +80,27 @@ impl KindTargets { } } -/// One press: what to write, where it lands, and whether it becomes a default. +/// What a press does with the values it carries. /// -/// A value that has a default of its own carries that fact through [`StackSetting::global_slot`], -/// so a press cannot name a default and a value that disagree. +/// Only a setting that HAS a default can be addressed by the two default modes, which +/// [`StackSetting::global_slot`] answers — so a press cannot name a default and a value that +/// disagree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ApplyMode { + /// Write the values into the tabs of the targeted kinds, as their own overrides. + Tabs, + /// Store the values as those kinds' defaults, dropping the overrides that hid them. + SetDefault, + /// Put those kinds' defaults back to the shipped set, dropping the same overrides. + /// + /// Carries the SLOTS rather than riding on the press's values, which it would only read the + /// slots out of: a reset lands on what the terminal ships, so a value it carried could only be + /// noise — and the whole caption configuration is over six kilobytes, copied on every render of + /// the row that offers the button. + ResetDefault(Vec), +} + +/// One press: what to write, where it lands, and what it does there. #[derive(Clone, Debug, PartialEq)] pub(crate) struct ApplyAll { /// Values the press carries, applied in this order. @@ -90,9 +110,8 @@ pub(crate) struct ApplyAll { pub x_ppm: Option, /// Which kinds of tab the press addresses. pub targets: KindTargets, - /// Whether this press sets those kinds' DEFAULTS and clears their tabs' overrides, rather than - /// writing the values into those tabs as overrides. - pub as_default: bool, + /// What the press does with its values. + pub mode: ApplyMode, } /// A detached window's press, queued through Backend for its group's tab strip to perform. @@ -162,30 +181,55 @@ impl super::ChartTabs { if !apply.targets.any() { return; } - match apply.as_default { - true => self.set_kind_defaults(apply, cx), - false => self.write_into_kinds(apply, cx), + match apply.mode { + ApplyMode::SetDefault | ApplyMode::ResetDefault(_) => self.set_kind_defaults(apply, cx), + ApplyMode::Tabs => self.write_into_kinds(apply, cx), } cx.notify(); } - /// Store the pressed values as the targeted kinds' defaults, and drop the overrides that hid them. + /// Store the pressed values as the targeted kinds' defaults — or put those defaults back to the + /// shipped set — and drop the overrides that hid them either way. + /// + /// One path for both because everything AFTER the layout write is the same act: a stored + /// override outlives whichever way the default moved, and a window that never heard about the + /// press keeps drawing the value it holds. Splitting them would have duplicated that, and the + /// copy left behind is where the next half-fix lives. fn set_kind_defaults(&mut self, apply: ApplyAll, cx: &mut Context) { - let slots = pressed_slots(&apply.values); + let slots = match &apply.mode { + ApplyMode::ResetDefault(slots) => slots.clone(), + _ => pressed_slots(&apply.values), + }; if slots.is_empty() { // Nothing storable, but the candle popup's X scale still travels: it has no default of // its own and rides along with the press. self.apply_x_ppm(&apply, cx); return; } + let reset = matches!(apply.mode, ApplyMode::ResetDefault(_)); let targets = apply.targets; let rebuild_orderbook = apply.values.iter().any(|v| v.rebuilds_orderbook_demand()); self.backend.update(cx, |b, bcx| { let mut moved = false; - for kind in ChartTabKind::ALL.into_iter().filter(|k| targets.has(*k)) { - for value in &apply.values { - if let Some(slot) = value.global_slot() { - moved |= slot.write_default(&mut b.layout, kind, value.clone()); + match reset { + true => { + // In `RESET_ORDER`, which puts Main first for the reason stated there. + for kind in ChartTabKind::RESET_ORDER + .into_iter() + .filter(|k| targets.has(*k)) + { + for slot in &slots { + moved |= slot.reset_default(&mut b.layout, kind); + } + } + } + false => { + for kind in ChartTabKind::ALL.into_iter().filter(|k| targets.has(*k)) { + for value in &apply.values { + if let Some(slot) = value.global_slot() { + moved |= slot.write_default(&mut b.layout, kind, value.clone()); + } + } } } } @@ -224,6 +268,8 @@ impl super::ChartTabs { }); // Including this strip's own stacks, through the same path every other window takes. self.drain_default_clears(cx); + // A reset carries no scale to begin with — `x_ppm` is what the popup SHOWS, and a press + // that removes stored values has nothing to spread — so this is a no-op for one. self.apply_x_ppm(&apply, cx); } diff --git a/crates/moon-ui-gpui/src/chart_tabs/apply_row.rs b/crates/moon-ui-gpui/src/chart_tabs/apply_row.rs index 9d4e4cd4..08341344 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/apply_row.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/apply_row.rs @@ -20,7 +20,7 @@ use moon_ui::{ }; use rust_i18n::t; -use super::apply_all::{ApplyAll, KindTargets}; +use super::apply_all::{ApplyAll, ApplyMode, KindTargets}; use super::common::StackSetting; use crate::design; @@ -130,32 +130,71 @@ pub(super) fn render_apply_row( }), ); } - let go_entity = entity.clone(); + // The slots the reset addresses, taken before the values are moved into the press: the reset + // needs nothing else from them, and the press it travels in is built from these alone. + let reset_slots: Vec<_> = values.iter().filter_map(|v| v.global_slot()).collect(); let pressed = ApplyAll { values, x_ppm, targets, - as_default, + mode: match as_default { + true => ApplyMode::SetDefault, + false => ApplyMode::Tabs, + }, }; - let go = MoonButton::new(SharedString::from(format!("{id_prefix}-apply-go"))) - .label(match as_default { + // One builder for both buttons: they differ in their wording, their weight and the press they + // carry, and in nothing else — the click does the same three things either way. + // + // NO tooltip on either, deliberately: this row renders INSIDE a popover, and MoonUI defers a + // tooltip at priority 2 against the popover's 30 000, so a hint here would paint under the + // surface it belongs to and never be seen (docs-internal/FORK_BUGS.md). What the buttons do is + // stated in the sentence above them instead. + let press_button = + |suffix: &str, label: String, variant: MoonButtonVariant, apply: ApplyAll| { + let entity = entity.clone(); + MoonButton::new(SharedString::from(format!("{id_prefix}-apply-{suffix}"))) + .label(label) + .size(MoonButtonSize::Micro) + .variant(variant) + // A press with no ticks reaches nothing, so the button says so instead of + // accepting the click. The click path is guarded too — `ChartTabs::apply_all` + // returns on an empty target set, which is also where a detached window's queued + // press lands — so this is the wording, not the safety. + .disabled(!targets.any()) + .on_click(move |_, _w, app| { + let apply = apply.clone(); + entity.update(app, |this, cx| { + this.apply_press_mut().open = false; + this.perform_apply(apply, cx); + cx.notify(); + }); + }) + .render() + }; + let go = press_button( + "go", + match as_default { true => t!("chart.defaults.set").to_string(), false => t!("chart.defaults.apply").to_string(), - }) - .size(MoonButtonSize::Micro) - .variant(MoonButtonVariant::Soft) - .disabled(!targets.any()) - .on_click(move |_, _w, app| { - let apply = pressed.clone(); - go_entity.update(app, |this, cx| { - this.apply_press_mut().open = false; - if apply.targets.any() { - this.perform_apply(apply, cx); - } - cx.notify(); - }); - }) - .render(); + }, + MoonButtonVariant::Soft, + pressed, + ); + // Offered only where the values HAVE a default: the ⚙ popup's settings are per-tab, and a + // button to reset a default they do not have would name nothing. + let reset = as_default.then(|| { + press_button( + "reset", + t!("chart.defaults.reset").to_string(), + MoonButtonVariant::Ghost, + ApplyAll { + values: Vec::new(), + x_ppm: None, + targets, + mode: ApplyMode::ResetDefault(reset_slots), + }, + ) + }); Some( v_flex() .gap_1() @@ -165,13 +204,39 @@ pub(super) fn render_apply_row( .text_color(rgb(p.text_muted)) // Two different sentences on purpose: setting a default also CLEARS what hides // it, and a reader about to overwrite the tuning of several windows should read - // that in the popup rather than discover it afterwards. + // that in the popup rather than discover it afterwards. It is also the ONLY + // explanation these buttons get — a tooltip inside a popover is invisible in + // this fork — so it says what each of them does. .child(match as_default { true => t!("chart.defaults.set_hint").to_string(), false => t!("chart.defaults.apply_hint").to_string(), }), ) - .child(h_flex().gap_2().items_center().child(ticks).child(go)) + .child( + // WRAPS at both levels, because NOTHING in this row can shrink: a `MoonPopover` + // is a fixed width, a `MoonButton` is `flex_shrink_0` with a label that does not + // truncate, and so is a `MoonCheckbox`. A row of four ticks and two buttons + // therefore has a hard minimum, and in a language with long words it used to be + // past the popup's edge. The outer wrap puts the buttons on a line of their own, + // the inner one stacks the pair when even that line is too narrow. + // + // What this still does NOT bound is one button whose label alone is wider than the + // popup: nothing can break that, so the labels are kept short instead. + h_flex() + .w_full() + .gap_2() + .items_center() + .flex_wrap() + .child(ticks) + .child( + h_flex() + .gap_2() + .items_center() + .flex_wrap() + .child(go) + .children(reset), + ), + ) .into_any_element(), ) } diff --git a/crates/moon-ui-gpui/src/chart_tabs/common.rs b/crates/moon-ui-gpui/src/chart_tabs/common.rs index 0b55d9cd..2bf6f9c5 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/common.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/common.rs @@ -183,6 +183,22 @@ impl GlobalSlot { } } + /// Put one KIND's default for this setting back to the shipped one, reporting whether it moved. + /// + /// Carries no value, unlike [`Self::write_default`]: a reset lands on what the terminal ships, + /// and the press that performs one reads the popup only to learn WHICH slots it is about. + pub(crate) fn reset_default( + self, + layout: &mut moon_core::config::WindowLayout, + kind: moon_core::config::ChartTabKind, + ) -> bool { + match self { + GlobalSlot::CandleView => layout.reset_candle_view_default(kind), + GlobalSlot::Graphics => layout.reset_chart_graphics_default(kind), + GlobalSlot::Labels => layout.reset_chart_labels_default(kind), + } + } + /// The Main stack's own value for this setting, or `None` when it follows its kind's default. pub(crate) fn main_value(self, main: &super::MainChartStack) -> Option { match self { diff --git a/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs b/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs index 3506c591..fcd88f31 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs @@ -54,7 +54,8 @@ pub(crate) struct MainChartStack { candle_view: Option, /// Chart-drawing settings for the tab; `None` uses the global `layout.chart_graphics` default. chart_graphics: Option, - /// Chart captions for the tab; `None` uses the global `layout.chart_labels` default. + /// Chart captions for the tab; `None` uses the default of the tab's KIND — the main chart is + /// a comparison while the anchor lock is on, and then follows the comparison set. chart_labels: Option, /// Captions a panel's own right-click menu produced, on their way to the host that persists /// them. See `panels::chart::volume_menu` for why they travel rather than being written here. diff --git a/locales/shell.yml b/locales/shell.yml index 5dec0ec4..65e9e2a0 100644 --- a/locales/shell.yml +++ b/locales/shell.yml @@ -995,13 +995,17 @@ chart.defaults.apply: en: "Apply" es: "Aplicar" chart.defaults.set_hint: - ru: "Дефолт для выбранных видов. Собственные настройки их вкладок будут сброшены — в скобках сколько." - en: "Default for the ticked kinds. Their tabs' own settings are cleared — the count is in brackets." - es: "Predeterminado para los tipos marcados. Los ajustes propios de sus pestañas se borran: el número va entre paréntesis." + ru: "Дефолт для выбранных видов. «Сбросить» убирает их дефолт: где есть заводской набор — вернётся он, иначе вид снова следует «Основным». Обе кнопки сбрасывают свои настройки этих вкладок — в скобках сколько." + en: "Default for the ticked kinds. \"Reset\" drops theirs: a kind with a shipped set of its own gets it back, otherwise it follows Main again. Both buttons clear those tabs' own settings — the count is in brackets." + es: "Predeterminado para los tipos marcados. «Restablecer» quita el suyo: el tipo que trae un conjunto de fábrica lo recupera; si no, vuelve a seguir a «Principales». Ambos botones borran los ajustes propios de esas pestañas: el número entre paréntesis." chart.defaults.apply_hint: ru: "Записать эти значения всем вкладкам выбранных видов. У этих настроек дефолта нет." en: "Write these values into every tab of the ticked kinds. These settings have no default." es: "Escribir estos valores en cada pestaña de los tipos marcados. Estos ajustes no tienen predeterminado." +chart.defaults.reset: + ru: "Сбросить" + en: "Reset" + es: "Restablecer" chart.candles.title: ru: "Свечи и трейды" en: "Candles & trades"