From 1391855daa0e80712da0540a24ce4a72ed3f18f2 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:38:13 +0200 Subject: [PATCH] feat(chart): open a coin with Enter, one row per coin with its cores Opening one chart from the coin search takes three actions: type, tick a checkbox, click the button below, because Enter does nothing and the list repeats the same coin once per core, 56 identical rows on this setup. Escape in the field reaches CloseActiveChart and closes the user's chart. Enter now opens the first match on the active core through the existing pick funnel, Escape ends the search, the dropdown groups exchange -> coin -> cores with the core names verbatim (collapsed when a coin has several), and the empty Main stack carries a one-line hint. The grouping lives in the shared coin-search widget, so the header ticker, the Report filter and the detached chart host get it too while keeping their own selection behaviour. Claude-Session: https://claude.ai/code/session_01RaQ2tnAyr4pyiAiwGktPBv --- crates/moon-ui-gpui/src/chart_tabs/common.rs | 87 +++ crates/moon-ui-gpui/src/chart_tabs/custom.rs | 29 + .../src/chart_tabs/detached_host/mod.rs | 48 ++ .../src/chart_tabs/detached_host/render.rs | 10 + .../moon-ui-gpui/src/chart_tabs/main_stack.rs | 14 + crates/moon-ui-gpui/src/chart_tabs/mod.rs | 19 + crates/moon-ui-gpui/src/chart_tabs/strip.rs | 25 + .../moon-ui-gpui/src/controls/coin_search.rs | 696 +++++++++++++----- .../src/controls/coin_search/tests.rs | 240 ++++-- .../moon-ui-gpui/src/panels/report/actions.rs | 14 + crates/moon-ui-gpui/src/panels/report/mod.rs | 5 + .../moon-ui-gpui/src/panels/report/render.rs | 9 + .../moon-ui-gpui/src/panels/report/state.rs | 4 + crates/moon-ui-gpui/src/shell/init.rs | 1 + crates/moon-ui-gpui/src/shell/mod.rs | 5 + crates/moon-ui-gpui/src/shell/ticker.rs | 25 + .../tests/theme_contract/chart.rs | 115 +++ .../tests/theme_contract/shell.rs | 18 +- .../tests/theme_contract/windowing.rs | 7 +- locales/shell.yml | 14 + 20 files changed, 1148 insertions(+), 237 deletions(-) diff --git a/crates/moon-ui-gpui/src/chart_tabs/common.rs b/crates/moon-ui-gpui/src/chart_tabs/common.rs index 2bf6f9c5..92ef970d 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/common.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/common.rs @@ -1153,6 +1153,93 @@ pub(super) trait CoinPopupHost: Sized + 'static { fn coin_field(&self) -> &Entity; /// The backend this host reads and writes, so shared plumbing can reach persisted state. fn coin_backend(&self) -> Entity; + /// What the dropdown is showing right now. + /// + /// On the trait because the KEYBOARD path needs it and both hosts already build it for their + /// renderer; each impl delegates to its own inherent builder so the two lists can never + /// disagree about what Enter would open versus what the user is looking at. + fn coin_popup_results(&self, cx: &App) -> crate::controls::coin_search::CoinResults; + /// Keeps row picks aligned with the host's trading focus, or leaves an unscoped host to use + /// the first matching core. + fn coin_active_core(&self, cx: &App) -> Option; +} + +/// Routes `Enter` through the ordinary pick funnel after resolving the first typed match. +/// +/// The fast path beside the multi-select one: ticking boxes and pressing "Open in new tab" still +/// builds a SET, while `Enter` opens the one coin the user just typed. Both end in the same funnel +/// ([`coin_pick_handler`]), so the recents list, the field, the popup and the keyboard are settled +/// identically however the market was chosen. +/// +/// An EMPTY field opens nothing: the list is then Recent and Top movers, two suggestions the user +/// did not ask for. A query matching nothing opens nothing either, leaving the "no matches" note +/// standing rather than closing the list under the user. +/// +/// The pick is DEFERRED, and that is load-bearing rather than tidy: this runs inside the field's +/// subscription, where the host is already borrowed as `&mut self`, and [`coin_pick_handler`] +/// re-enters it through `view.update`. Called inline it panics with "cannot read ... while it is +/// already being updated" — the same hazard the handler's own comment names. +/// +/// Args: +/// this: The coin field's host. +/// input: The field itself, passed while the caller still holds `&self`. +/// window: Window the press arrived on, used to defer and to release focus. +/// cx: Host context. +/// +/// Returns: +/// Nothing; an empty field, an empty query and a suggestion list all leave the list standing. +pub(super) fn coin_enter_handler( + this: &T, + input: Entity, + window: &mut Window, + cx: &mut Context, +) { + let active = this.coin_active_core(cx); + let Some((core, market)) = + crate::controls::coin_search::enter_target(this.coin_popup_results(cx), active) + else { + return; + }; + let pick = coin_pick_handler(cx, input); + window.defer(cx, move |window, app| pick(core, market, window, app)); +} + +/// End an open market search on `Escape`, and report whether it did. +/// +/// Without this the key is not merely inert — it CLOSES THE ACTIVE CHART. `MoonInputState::escape` +/// propagates when the field is not `clean_on_escape`, and Escape carries no `key_char`, so +/// `hotkeys::belongs_to_the_field` lets it through to the window's binding, which resolves to +/// `CloseActiveChart`. The list stays up and the chart under it disappears. +/// +/// Modelled on [`crate::hotkeys::escape_leaves_sells_zone`]: matched on the raw key BEFORE the +/// hotkey table is consulted, consuming the press, so the NEXT Escape behaves exactly as it always +/// did. Gated on the popup actually being open for the same reason — Escape keeps every other +/// meaning it has. +/// +/// Args: +/// this: The coin field's host. +/// ev: The key press, before hotkey resolution. +/// window: Window the press arrived on, used to release the field's keyboard. +/// cx: Host context. +/// +/// Returns: +/// Whether the press was consumed and must not reach the hotkey table. +pub(super) fn coin_escape_ends_search( + this: &mut T, + ev: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, +) -> bool { + if ev.keystroke.key != "escape" || ev.keystroke.modifiers.modified() { + return false; + } + if !this.popup_shows(ChartPopup::Coin) { + return false; + } + this.clear_coin_search(cx); + let field = this.coin_field().clone(); + crate::controls::coin_search::release_focus(&field, window, cx); + true } /// Handle a coin-list selection by opening it, clearing the field, and closing the popup. diff --git a/crates/moon-ui-gpui/src/chart_tabs/custom.rs b/crates/moon-ui-gpui/src/chart_tabs/custom.rs index 969815ca..dc3d72af 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/custom.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/custom.rs @@ -80,6 +80,11 @@ impl ChartTabs { // without rewriting the input, so reopening on focus must resync both values or suggestions // can appear under text the user can still see in the field. self.coin_query = self.coin_input.read(cx).value().to_string(); + // Reset the open rows HERE rather than on each way the list can close. Six paths close it + // without passing `clear_coin_search` — a displaced popup through `settle_closed_popup`, + // an Auto scope change, the toolbar press layer — so chasing them all is how one gets + // missed. Opening is the ONE funnel, and defaults on open is the behaviour anyway. + self.coin_expanded.clear(); // Resolve through the same helper the render path uses: the bucket is the suggestion // cache key, so a mismatch here would refresh one entry and read another, leaving the // Top 24h section permanently empty. @@ -117,6 +122,18 @@ impl ChartTabs { cx.notify(); } + /// Records an explicit expansion override so the shared size-based defaults need no seeding. + pub(super) fn toggle_coin_expanded( + &mut self, + key: crate::controls::coin_search::CoinGroupKey, + cx: &mut Context, + ) { + if !self.coin_expanded.remove(&key) { + self.coin_expanded.insert(key); + } + cx.notify(); + } + /// Toggle a coin through its dropdown checkbox, accumulating a selection for Open in new tab. /// Selection survives query changes, so BTC and ETH can be selected in separate searches. pub(super) fn toggle_coin_selected( @@ -191,6 +208,7 @@ impl ChartTabs { // Clear the selection, field, and popup. self.coin_selected.clear(); self.coin_query.clear(); + self.coin_expanded.clear(); self.close_chart_popup(ChartPopup::Coin, cx); self.sync_active_scale(cx); self.sync_inactive_chart_visibility(cx); @@ -760,10 +778,21 @@ impl CoinPopupHost for ChartTabs { /// Clear the coin field and close the list after selection or an outside click. fn clear_coin_search(&mut self, cx: &mut Context) { self.coin_query.clear(); + // The next open starts from the defaults, like the query does. + self.coin_expanded.clear(); self.close_chart_popup(ChartPopup::Coin, cx); cx.notify(); } fn open_picked_coin(&mut self, core: CoreId, market: String, cx: &mut Context) { self.open_coin_on_active(core, market, cx); } + + fn coin_popup_results(&self, cx: &App) -> crate::controls::coin_search::CoinResults { + self.coin_results(cx) + } + + /// Reuses the trading controls' core resolution so a row opens in the currently addressed core. + fn coin_active_core(&self, cx: &App) -> Option { + self.backend.read(cx).active_trade_core(&self.group) + } } diff --git a/crates/moon-ui-gpui/src/chart_tabs/detached_host/mod.rs b/crates/moon-ui-gpui/src/chart_tabs/detached_host/mod.rs index 3f2e8de0..8dfd91b7 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/detached_host/mod.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/detached_host/mod.rs @@ -85,6 +85,11 @@ pub(super) struct DetachedChartHost { coin_input: Entity, /// Current market-search text mirroring `coin_input`. coin_query: String, + /// Coin rows of the search dropdown whose core list the user has flipped open or shut. + /// + /// Holds what was TOGGLED away from the default, not what is open, so a freshly opened list + /// needs no seeding; see `controls::coin_search::group_is_open`. + coin_expanded: std::collections::HashSet, /// Window-root focus handle for receiving `on_key_down` hotkeys when nothing else is focused. /// /// The root receives focus on creation. Clicking market input moves focus there, but key events @@ -363,6 +368,12 @@ impl DetachedChartHost { this.open_coin_popup(cx); return; } + // Enter opens the first match on the active core -- the fast path beside the + // multi-select one. See `common::coin_enter_handler`. + if matches!(ev, MoonInputEvent::PressEnter { .. }) { + super::common::coin_enter_handler(this, input.clone(), window, cx); + return; + } if matches!(ev, MoonInputEvent::Change) { let value = input.read(cx).value().to_string(); if let std::borrow::Cow::Owned(en) = @@ -374,6 +385,11 @@ impl DetachedChartHost { if this.coin_query != value { // Clearing the text falls back to suggestions rather than closing. this.coin_query = value; + // Keep this direct reopen path aligned with `open_coin_popup`, which it + // bypasses when the popup was already closed. + if !this.popup_shows(ChartPopup::Coin) { + this.coin_expanded.clear(); + } this.open_chart_popup(ChartPopup::Coin, cx); } } @@ -402,6 +418,7 @@ impl DetachedChartHost { custom_name_input, coin_input, coin_query: String::new(), + coin_expanded: std::collections::HashSet::new(), focus, modifier_watch: moon_ui::MoonHotkeyModifierWatch::default(), taskbar_hide, @@ -444,6 +461,12 @@ impl DetachedChartHost { cx.stop_propagation(); return; } + // Then Escape ends an open market search, BEFORE the hotkey table sees it: otherwise it + // resolves to CloseActiveChart and shuts the chart under the list. + if super::common::coin_escape_ends_search(self, ev, window, cx) { + cx.stop_propagation(); + return; + } // As in the group window: the resolver owns the rule, this only answers whether a focused // field is taking text. let typing = window.is_text_input_active(); @@ -623,10 +646,24 @@ impl DetachedChartHost { CoinResults::Suggest { recent, volatile } } + /// Records an explicit expansion override so the shared size-based defaults need no seeding. + pub(super) fn toggle_coin_expanded( + &mut self, + key: crate::controls::coin_search::CoinGroupKey, + cx: &mut Context, + ) { + if !self.coin_expanded.remove(&key) { + self.coin_expanded.insert(key); + } + cx.notify(); + } + /// Open this window's coin dropdown, refreshing the suggestions it reads. pub(super) fn open_coin_popup(&mut self, cx: &mut Context) { // Resync the query mirror with the field; see `ChartTabs::open_coin_popup`. self.coin_query = self.coin_input.read(cx).value().to_string(); + // Reset the open rows on OPEN, for the reason `ChartTabs::open_coin_popup` states. + self.coin_expanded.clear(); let (group, bucket) = (self.group.clone(), self.bucket.clone()); self.backend .update(cx, |b, _| b.refresh_coin_suggest(&group, Some(&bucket))); @@ -934,9 +971,20 @@ impl CoinPopupHost for DetachedChartHost { fn clear_coin_search(&mut self, cx: &mut Context) { self.coin_query.clear(); + // The next open starts from the defaults, like the query does. + self.coin_expanded.clear(); self.close_chart_popup(ChartPopup::Coin, cx); cx.notify(); } + fn coin_popup_results(&self, cx: &App) -> crate::controls::coin_search::CoinResults { + self.coin_results(cx) + } + + /// Reuses the strip's core resolution so a row opens in the currently addressed core. + fn coin_active_core(&self, cx: &App) -> Option { + self.backend.read(cx).active_trade_core(&self.group) + } + fn open_picked_coin(&mut self, core: CoreId, market: String, cx: &mut Context) { self.panel.update(cx, |p, c| { p.add_coin(core, &market, coin_search::MANUAL_COIN_TTL_MS, c) diff --git a/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs b/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs index 469757a4..3d6c28f8 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs @@ -123,16 +123,26 @@ impl Render for DetachedChartHost { let header_h = design::fit_h_px(cx, 34.0, 13.0, 10.5); let coin_popup = self.popup_shows(ChartPopup::Coin).then(|| { let results = self.coin_results(cx); + // The same resolution the strip uses: a coin row opens on the core this window is + // addressing. Its hits are already bucket-scoped, so a foreign active core simply + // falls through to the first in-scope member. + let active_core = common::CoinPopupHost::coin_active_core(self, cx); + let view_expand = cx.entity(); coin_search::render_popup( "detached-coin", results, &std::collections::HashSet::new(), + &self.coin_expanded, false, + active_core, None, p, cx, common::coin_pick_handler(cx, self.coin_input.clone()), |_core, _market, _app| {}, + move |key, app| { + view_expand.update(app, |this, cx| this.toggle_coin_expanded(key, cx)); + }, |_window, _app| {}, ) .absolute() 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 2645db64..5eefa061 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/main_stack.rs @@ -1619,12 +1619,26 @@ impl Render for MainChartStack { .size_full() .bg(rgb(palette.chart_bg)) .flex() + .flex_col() .items_center() .justify_center() + .gap(crate::design::ui_px(cx, 10.0)) .child(crate::design::logo_glow_sized( cx, crate::design::EMPTY_STACK_LOGO_W, )) + // A logo alone says the stack is empty but not what to do about it. One muted line, + // naming the ONE gesture that actually opens a chart from here: there is no + // double-click on a core row that does it — the rail only RETARGETS a chart that + // already exists (`sync_auto_workspace_chart` returns early on an empty Main). + .child( + div() + .max_w(crate::design::font_w_px(cx, 420.0)) + .text_center() + .text_size(crate::design::t_body(cx)) + .text_color(rgb(palette.text_muted)) + .child(rust_i18n::t!("chart.empty.hint").to_string()), + ) .into_any_element(); // Measured here too, for the reason the fullscreen branch keeps its probe: a resize // taken while the stack is empty must not leave a size the first divided frame uses. diff --git a/crates/moon-ui-gpui/src/chart_tabs/mod.rs b/crates/moon-ui-gpui/src/chart_tabs/mod.rs index 5ba51255..9582e928 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/mod.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/mod.rs @@ -283,6 +283,11 @@ pub struct ChartTabs { next_custom_num: u32, /// Markets checked in the search dropdown for Open in New Tab. coin_selected: std::collections::HashSet<(CoreId, String)>, + /// Coin rows of the search dropdown whose core list the user has flipped open or shut. + /// + /// Holds what was TOGGLED away from the default, not what is open, so a freshly opened list + /// needs no seeding; see `controls::coin_search::group_is_open`. + coin_expanded: std::collections::HashSet, /// Order-book gate generation by custom-tab number, invalidating stale five-second suspend /// timers so only the latest leave/return/leave cycle applies. custom_gate_gen: HashMap, @@ -647,6 +652,12 @@ impl ChartTabs { this.open_coin_popup(cx); return; } + // Enter opens the first match on the active core -- the fast path beside the + // multi-select one. See `common::coin_enter_handler`. + if matches!(ev, MoonInputEvent::PressEnter { .. }) { + common::coin_enter_handler(this, input.clone(), window, cx); + return; + } if matches!(ev, MoonInputEvent::Change) { let value = input.read(cx).value().to_string(); if let std::borrow::Cow::Owned(en) = @@ -658,6 +669,13 @@ impl ChartTabs { if this.coin_query != value { // Clearing the text does not close the list; it falls back to suggestions. this.coin_query = value; + // This branch REOPENS a closed list without passing `open_coin_popup`, so + // it owes that funnel's reset: an Auto rail move closes the popup from + // `sync_auto_workspace_chart`, and the next keystroke would otherwise bring + // it back carrying the previous scope's expanded rows. + if !this.popup_shows(ChartPopup::Coin) { + this.coin_expanded.clear(); + } this.open_chart_popup(ChartPopup::Coin, cx); } } @@ -731,6 +749,7 @@ impl ChartTabs { custom_labels: HashMap::new(), next_custom_num: CUSTOM_NUM_BASE, coin_selected: std::collections::HashSet::new(), + coin_expanded: std::collections::HashSet::new(), custom_gate_gen: HashMap::new(), detached: Vec::new(), active: Tab::Main, diff --git a/crates/moon-ui-gpui/src/chart_tabs/strip.rs b/crates/moon-ui-gpui/src/chart_tabs/strip.rs index 361f61c1..751251df 100644 --- a/crates/moon-ui-gpui/src/chart_tabs/strip.rs +++ b/crates/moon-ui-gpui/src/chart_tabs/strip.rs @@ -297,14 +297,19 @@ impl Render for ChartTabs { .map(|name| crate::display_text::flatten_lines(&name)) }; let results = self.coin_results(cx); + // The core a COIN row opens on, resolved by the same rule the trading controls use. + let active_core = common::CoinPopupHost::coin_active_core(self, cx); let view_toggle = cx.entity(); + let view_expand = cx.entity(); let view_open = cx.entity(); let input_open = self.coin_input.clone(); coin_search::render_popup( "tabs-coin", results, &self.coin_selected, + &self.coin_expanded, true, + active_core, server_context, p_strip, cx, @@ -312,6 +317,9 @@ impl Render for ChartTabs { move |core, market, app| { view_toggle.update(app, |this, cx| this.toggle_coin_selected(core, market, cx)); }, + move |key, app| { + view_expand.update(app, |this, cx| this.toggle_coin_expanded(key, cx)); + }, move |window, app| { view_open.update(app, |this, cx| this.open_selected_in_new_tab(cx)); crate::controls::coin_search::release_focus(&input_open, window, app); @@ -419,6 +427,23 @@ impl Render for ChartTabs { v_flex() .size_full() .relative() + // Escape ends an open market search HERE, on an ancestor of the field, so it is + // consumed before the Shell root resolves it to CloseActiveChart -- which is what it + // does today, closing the chart with the list still up. + .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { + // The SAME first rule as `Shell::on_hotkey` and `DetachedChartHost::on_hotkey`, + // and it has to be repeated here rather than left to the Shell root: gpui + // dispatches bubbled key listeners from the focused descendant OUTWARD, so this + // listener — an ancestor of the field, but a descendant of the root — runs first + // and a `stop_propagation` here would never let the root's safety rule run at all. + if crate::hotkeys::escape_leaves_sells_zone(ev, &this.backend, cx) { + cx.stop_propagation(); + return; + } + if common::coin_escape_ends_search(this, ev, window, cx) { + cx.stop_propagation(); + } + })) .child( // Tabs yield (`flex_1 min_w_0`); the right chrome cluster is a real flex sibling, // not an overlay. This row does not clip: hanging coin/figstyle layers are lifted. diff --git a/crates/moon-ui-gpui/src/controls/coin_search.rs b/crates/moon-ui-gpui/src/controls/coin_search.rs index 7b6a3ddf..7fe1acd4 100644 --- a/crates/moon-ui-gpui/src/controls/coin_search.rs +++ b/crates/moon-ui-gpui/src/controls/coin_search.rs @@ -1,8 +1,14 @@ //! Shared market picker for typed search and cached empty-field suggestions. //! -//! Rows group identical full instrument labels across cores. They show the core as secondary -//! `@server` context unless one popup-level server context covers the whole list. The widget does -//! not define selection behavior; its owner supplies `on_pick`. +//! The list is a two-level tree: exchange sections (drawn only when there is more than one), then +//! ONE ROW PER COIN, with the cores that offer it as child rows underneath. A coin row opens on the +//! core the host is addressing ([`pick_core`]); its `@server` names that core so the choice is +//! never hidden, and child rows expose a clipped core name in a tooltip. A group of more than +//! [`COIN_GROUP_AUTO_EXPAND`] cores starts collapsed — on a fifty-six-core config the flat form was +//! fifty-six identical rows of one coin. The widget does not define selection behavior; its owner +//! supplies `on_pick`, `on_toggle` and `on_expand`, and owns the expanded-row set the same way it +//! owns the multi-select one. +//! //! Chart tabs open a market and may show Recent and Top 24h volatility sections, while the header //! rate ticker and Report token filter remain query-only consumers. //! @@ -15,16 +21,18 @@ use std::collections::{HashMap, HashSet}; use gpui::prelude::FluentBuilder; use gpui::*; use moon_ui::{ - MoonButton, MoonButtonSize, MoonButtonVariant, MoonCheckbox, MoonCheckboxSize, MoonInputState, - MoonPalette, h_flex, + MoonButton, MoonButtonSize, MoonButtonVariant, MoonCheckbox, MoonCheckboxSize, MoonDisclosure, + MoonDisclosureDirection, MoonInputState, MoonPalette, h_flex, }; use rust_i18n::t; use crate::Backend; +use crate::core_order::ExchangeSection; use crate::design; use moon_core::config::ChartBucket; use moon_core::market::MarketLabel; use moon_core::session::CoreId; +use moon_core::venue::CoreVenue; mod ranking; @@ -194,6 +202,12 @@ pub(crate) struct CoinHit { pub(crate) server: String, /// Coin token and quote as the CORE names them; see `MarketDataSource::market_label`. pub(crate) label: MarketLabel, + /// Carries the core's venue into grouping, before render has lost the core identity. + /// + /// Resolved beside the label rather than at render for the same reason: the renderer holds no + /// core, and the venue is what decides whether two cores offering `BTC-USDT` are ONE choice on + /// one exchange or two choices on two. + pub(crate) venue: Option, } /// Returns token-search results, each carrying its resolved label. @@ -471,6 +485,7 @@ pub(crate) fn hits_for( return Vec::new(); } let ms = b.session.market_source(); + let venues = b.session.core_venues(); let mut out: Vec> = vec![None; pairs.len()]; // Group the positions by core so each core resolves its labels under one lock and snapshot. let mut cores: Vec = Vec::new(); @@ -489,6 +504,9 @@ pub(crate) fn hits_for( else { continue; }; + // Cloned once per CORE rather than per hit: one core contributes many rows to a query, and + // the value is three small fields. + let venue = venues.get(&core).cloned(); let positions: Vec = pairs .iter() .enumerate() @@ -503,70 +521,180 @@ pub(crate) fn hits_for( market: pairs[ix].1.clone(), server: server.clone(), label, + venue: venue.clone(), }); } } out.into_iter().flatten().collect() } -/// One rendered result row: a hit plus whether it OPENS a run of the same instrument. +/// Identity of one coin row: the exchange it sits under, and the instrument it names. /// -/// See [`group_hits`] for what a run is and why the flag is presentation-only. -struct CoinRow { - hit: CoinHit, - /// The instrument label this row displays, resolved once by [`group_hits`] as its grouping key - /// rather than formatted again per row at render. - pair: SharedString, - /// First row of a run of the same instrument offered by several cores. - first_of_group: bool, +/// The value a HOST retains between frames to remember which rows are open, so it borrows nothing. +/// The exchange belongs in the key because the same instrument on two exchanges is two different +/// choices — folding them together would offer `BTC-USDT` once and silently pick a venue. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct CoinGroupKey { + pub(crate) section: ExchangeSection, + pub(crate) pair: SharedString, +} + +/// One coin, and every core in this section that can open it. +pub(crate) struct CoinGroup { + pub(crate) key: CoinGroupKey, + /// The instrument label the coin row displays, formatted once by [`group_hits`]. + pub(crate) pair: SharedString, + /// The cores offering it, in canonical core order. Never empty. + pub(crate) members: Vec, +} + +/// One exchange's block of coin rows. +pub(crate) struct CoinSection { + /// What the section stands for, or `None` for the cores no venue could be named for. + pub(crate) venue: Option, + pub(crate) groups: Vec, } -/// Order hits so the same instrument offered by several cores forms one adjacent run. +/// Number of cores above which a coin row starts COLLAPSED. /// -/// The list concatenates one page of hits per core, so a coin available on eight cores arrives as -/// eight rows scattered by core order and reads as noise. Grouping makes each instrument one block. +/// Three cores fit under a coin without pushing the next coin off the screen; the user's own config +/// has fifty-six, where an expanded-by-default row IS the wall this grouping exists to remove. +pub(crate) const COIN_GROUP_AUTO_EXPAND: usize = 3; + +/// Keeps small groups immediately usable without reopening a wall of cores on shared markets. +pub(crate) fn group_starts_expanded(members: usize) -> bool { + members <= COIN_GROUP_AUTO_EXPAND +} + +/// Fold hits into exchange sections, each holding one row per COIN with its cores underneath. /// -/// The key is the FULL instrument label ([`MarketLabel::pair`]), never the contract-stripped coin: -/// `display_coin`/`match_key` fold `BTC_0925` into `BTC` on purpose, so grouping by either would -/// merge a perpetual with a dated contract — two different instruments the user must be able to -/// tell apart. Nothing is removed or deduplicated: the same coin on two cores is two real choices. +/// The list concatenates one page of hits per core, so a coin every core carries arrives as one row +/// per core — fifty-six identical `BTC-USDT` lines on the user's config. Folding them makes the +/// coin the row and the cores its children. /// -/// Ordering is stable in both directions — groups appear in the order their first hit did (which is -/// canonical core order), and hits keep their original order inside a group — so the list does not -/// reshuffle as the query grows. +/// The key is the exchange section plus the FULL instrument label ([`MarketLabel::pair`]), never +/// the contract-stripped coin: `display_coin`/`match_key` fold `BTC_0925` into `BTC` on purpose, so +/// grouping by either would merge a perpetual with a dated contract — two different instruments the +/// user must be able to tell apart. Nothing is removed or deduplicated: the members ARE the cores, +/// each still openable on its own. +/// +/// Sections come from [`crate::core_order::exchange_sections`], the same bucketing the left rail +/// and the Strategies tree use, so a coin list and a core list can never disagree about which +/// exchange a core belongs to. Ordering is stable in both directions — groups appear in the order +/// their first hit did (canonical core order), and members keep their arrival order inside a +/// group — so the list does not reshuffle as the query grows. /// /// Args: /// hits: Search or suggestion hits, in the order they were produced. /// /// Returns: -/// The same hits, reordered into runs, each row flagged as opening its run or continuing it. -fn group_hits(hits: Vec) -> Vec { - // One `pair()` per hit, kept and reused: it formats a fresh string, and it is both the grouping - // key and what the row displays. - let mut keyed: Vec<(SharedString, CoinHit)> = hits +/// Exchange sections, unidentified first, each holding its coin groups. +pub(crate) fn group_hits(hits: Vec) -> Vec { + // Resolve the sections while the hits are still borrowable, and take OWNED keys out of that + // borrow so the hits can be consumed below. + let plan: Vec<(ExchangeSection, Option, Vec)> = + crate::core_order::exchange_sections( + hits.iter() + .enumerate() + .map(|(ix, hit)| (ix, hit.venue.as_ref())), + ) .into_iter() - .map(|hit| (SharedString::from(hit.label.pair()), hit)) + .map(|(venue, members)| { + ( + crate::core_order::section_of(venue), + venue.cloned(), + members, + ) + }) .collect(); - // Stable sort by first appearance of each key: groups keep the canonical core order they - // arrived in, and members keep their order inside a group. - let mut order: HashMap = HashMap::new(); - for (key, _) in &keyed { - let next = order.len(); - order.entry(key.clone()).or_insert(next); - } - keyed.sort_by_key(|(key, _)| order.get(key).copied().unwrap_or(usize::MAX)); - let mut out: Vec = Vec::with_capacity(keyed.len()); - let mut previous: Option = None; - for (pair, hit) in keyed { - let first_of_group = previous.as_ref() != Some(&pair); - previous = Some(pair.clone()); - out.push(CoinRow { - hit, - pair, - first_of_group, - }); + // Moved out by index below: `exchange_sections` hands back POSITIONS, and a coin's members are + // scattered across them, so the hits cannot simply be drained in order. + let mut hits: Vec> = hits.into_iter().map(Some).collect(); + plan.into_iter() + .map(|(section, venue, members)| { + // First-appearance order of each pair inside this section, so groups read in canonical + // core order rather than in hash order. + let mut order: Vec = Vec::new(); + let mut buckets: HashMap> = HashMap::new(); + for ix in members { + let Some(hit) = hits[ix].take() else { + continue; + }; + let pair = SharedString::from(hit.label.pair()); + match buckets.get_mut(&pair) { + Some(bucket) => bucket.push(hit), + None => { + order.push(pair.clone()); + buckets.insert(pair, vec![hit]); + } + } + } + let groups = order + .into_iter() + .filter_map(|pair| { + let members = buckets.remove(&pair)?; + Some(CoinGroup { + key: CoinGroupKey { + section, + pair: pair.clone(), + }, + pair, + members, + }) + }) + .collect(); + CoinSection { venue, groups } + }) + .filter(|section| !section.groups.is_empty()) + .collect() +} + +/// Choose the core a coin row opens on: the ACTIVE one when it carries the coin, else the first. +/// +/// The coin row stands for the instrument rather than for any one core, so it needs a rule for +/// which core it hands to `on_pick`. The active core is what every other surface in the window is +/// already addressing; when it does not carry this instrument the first member is the core the user +/// reads first everywhere else, because `members` is in canonical [`cores_for`] order. +/// +/// Args: +/// members: The cores offering one instrument, in canonical order. Never empty. +/// active: Core the window is currently addressing, or `None` for an unscoped list. +/// +/// Returns: +/// The member to open, or `None` only for an empty slice, which [`group_hits`] never produces. +pub(crate) fn pick_core(members: &[CoinHit], active: Option) -> Option<&CoinHit> { + active + .and_then(|active| members.iter().find(|hit| hit.core == active)) + .or_else(|| members.first()) +} + +/// The market `Enter` opens: the first matching coin, on the active core. +/// +/// Only a TYPED query has a "first match" — the empty-field list is Recent and Top movers, two +/// SUGGESTIONS the user has not asked for, so `Enter` there must open nothing rather than pick one +/// for them. +/// +/// Args: +/// results: What the dropdown is currently showing. +/// active: Core the window is addressing, resolved exactly as the row click resolves it. +/// +/// Returns: +/// The core and market to open, or `None` for an empty field, an empty query and a +/// suggestion list. +pub(crate) fn enter_target( + results: CoinResults, + active: Option, +) -> Option<(CoreId, String)> { + let CoinResults::Query(hits) = results else { + return None; + }; + if hits.is_empty() { + return None; } - out + let sections = group_hits(hits); + let group = sections.first()?.groups.first()?; + let hit = pick_core(&group.members, active)?; + Some((hit.core, hit.market.clone())) } /// What the dropdown is showing: matches for a typed query, or suggestions for an empty field. @@ -585,6 +713,15 @@ pub(crate) enum CoinResults { }, } +/// [`CoinResults`] after grouping, so the row arithmetic and the renderer read the SAME shape. +enum GroupedResults { + Query(Vec), + Suggest { + recent: Vec, + volatile: Vec, + }, +} + /// Returns the fixed height shared by every direct child of the scrolling result list. /// /// Args: @@ -622,71 +759,128 @@ fn whole_row_cap(raw_cap: f32, row_h: f32) -> f32 { whole_row_slots(raw_cap, row_h) as f32 * row_h } -/// Counts the fixed-height direct children that a result set adds to the scrolling list. +/// Applies a recorded toggle as an inversion of the size-based default. +/// +/// `toggled` holds the groups the user has FLIPPED away from their default, not the open ones, so a +/// freshly opened popup needs no seeding and a small group is open without ever being recorded. /// /// Args: -/// results: Query matches or suggestion sections to render. +/// key: Identity of the coin row. +/// members: How many cores it holds, which decides its default. +/// toggled: Groups the host has recorded a click on. /// /// Returns: -/// Result, empty-state, and non-empty section-heading rows, excluding popup context rows. -fn result_row_count(results: &CoinResults) -> usize { - match results { - CoinResults::Query(hits) => hits.len().max(1), - CoinResults::Suggest { recent, volatile } => { - if recent.is_empty() && volatile.is_empty() { - 1 - } else { - recent.len() - + usize::from(!recent.is_empty()) - + volatile.len() - + usize::from(!volatile.is_empty()) - } - } - } +/// Whether its child rows are rendered. +pub(crate) fn group_is_open( + key: &CoinGroupKey, + members: usize, + toggled: &HashSet, +) -> bool { + group_starts_expanded(members) != toggled.contains(key) +} + +/// Avoids redundant exchange chrome when every row belongs to one venue. +/// +/// One exchange needs no heading — every row is on it, and the caption would be a line of chrome +/// repeating what the scope already says. +pub(crate) fn shows_sections(sections: &[CoinSection]) -> bool { + sections.len() > 1 +} + +/// Counts the fixed-height direct children one grouped list adds to the scrolling list. +/// +/// The viewport cap and the overflow fade are both derived from a COUNT of fixed-height direct +/// children ([`whole_row_slots`], [`whole_row_cap`]), so every row type this list can draw has to +/// be counted here or the last visible row is cut and the fade lies about what is below it. +/// +/// Args: +/// sections: The grouped list, as [`group_hits`] produced it. +/// toggled: Groups the host has recorded a click on. +/// +/// Returns: +/// Exchange headings, coin rows and the child rows of OPEN groups. +pub(crate) fn direct_row_count(sections: &[CoinSection], toggled: &HashSet) -> usize { + let headings = if shows_sections(sections) { + sections.len() + } else { + 0 + }; + headings + + sections + .iter() + .flat_map(|section| section.groups.iter()) + .map(|group| { + // The `members > 1` half is NOT redundant, and dropping it is the bug this guard + // exists for: a one-core group has no caret (`push_section` builds it under the + // same condition), so it can never enter `toggled`, so `group_is_open` is + // unconditionally true for it -- while the renderer skips its child row anyway. + // Counting 2 where 1 is drawn inflates the total on the COMMON case and paints the + // continuation fade over a list with nothing below it. + 1 + if group.members.len() > 1 + && group_is_open(&group.key, group.members.len(), toggled) + { + group.members.len() + } else { + 0 + } + }) + .sum::() } -/// Renders one section of rows into `list`, preceded by `heading` when there is something to show. +/// Renders one grouped list into `list`, preceded by `heading` when there is something to show. +/// +/// Three row kinds, every one a FIXED-HEIGHT DIRECT CHILD so [`direct_row_count`] can size the +/// viewport: an exchange heading (only when the list spans more than one), a COIN row, and — while +/// that coin row is open — one child row per core offering it. /// /// Args: -/// list: Stateful scrolling list that receives the section. +/// list: Stateful scrolling list that receives the rows. /// id: Stable popup identity used to derive row and checkbox IDs. /// section: Stable section identity that keeps IDs unique across suggestion groups. /// heading: Optional localized heading, omitted for ordinary query results. -/// hits: Rows to group by instrument and append. +/// sections: The grouped list, as [`group_hits`] produced it. /// selected: Markets currently accumulated for multi-select. +/// toggled: Coin rows the host has recorded a caret click on. /// multi_select: Whether rows include selection checkboxes. -/// show_server_per_row: Whether each row displays its server beside the instrument. +/// show_server_per_row: Whether a coin row names the core it would open on. +/// active_core: Core the window is addressing, which decides what a coin row opens. /// p: Active palette used by row text and hover states. /// cx: Application context used to resolve scaled design tokens. /// on_pick: Callback for opening a row's market. /// on_toggle: Callback for changing a checkbox selection. +/// on_expand: Callback for a caret click, carrying the coin row's identity. /// /// Returns: -/// The same stateful list with this section appended, or unchanged when `hits` is empty. +/// The same stateful list with this section appended, or unchanged when `sections` is empty. #[allow(clippy::too_many_arguments)] -fn push_section( +fn push_section( mut list: Stateful
, id: &'static str, section: &'static str, heading: Option, - hits: Vec, + sections: Vec, selected: &HashSet<(CoreId, String)>, + toggled: &HashSet, multi_select: bool, show_server_per_row: bool, + active_core: Option, p: MoonPalette, cx: &App, on_pick: F, on_toggle: G, + on_expand: E, ) -> Stateful
where F: Fn(CoreId, String, &mut Window, &mut App) + Clone + 'static, G: Fn(CoreId, String, &mut App) + Clone + 'static, + E: Fn(CoinGroupKey, &mut App) + Clone + 'static, { - if hits.is_empty() { + if sections.is_empty() { return list; } let row_h = coin_row_h(cx); let hover_bg = rgb(p.shell_high); + let show_sections = shows_sections(§ions); if let Some(heading) = heading { list = list.child( div() @@ -701,100 +895,223 @@ where .child(heading), ); } - for (i, row) in group_hits(hits).into_iter().enumerate() { - let CoinRow { - hit, - pair, - first_of_group, - } = row; - let CoinHit { - core, - market, - server, - label: _, - } = hit; - // The full instrument, expiry included, on EVERY row: a continuation row that showed only - // its core would make a perpetual and a dated contract on one core indistinguishable. The - // continuation is subordinated by colour instead, so the run still reads as one block. - let pair_fg = if first_of_group { - rgb(p.text) - } else { - rgb(p.text_soft) - }; - let on_pick = on_pick.clone(); - let market_pick = market.clone(); - let checked = selected.contains(&(core, market.clone())); - let on_toggle = on_toggle.clone(); - let market_toggle = market.clone(); - list = list.child( - div() - .id(SharedString::from(format!("{id}-{section}-row-{i}"))) - .w_full() - .h(px(row_h)) - .flex_none() - .flex() - .items_center() - .px(design::ui_px(cx, 8.0)) - .cursor_pointer() - .hover(move |s| s.bg(hover_bg)) - .child( - h_flex() + // One running index across every section, so an element id stays unique when a venue's caption + // repeats or a coin appears under two exchanges. + let mut i = 0usize; + for venue_section in sections { + if show_sections { + list = list.child( + div() + .w_full() + .h(px(row_h)) + .flex_none() + .flex() + .items_center() + .px(design::ui_px(cx, 8.0)) + .whitespace_nowrap() + .overflow_hidden() + .text_size(design::t_caption(cx)) + .text_color(rgb(p.text_muted)) + .child(crate::controls::venue_section_label( + venue_section.venue.as_ref(), + )), + ); + } + for group in venue_section.groups { + let members = group.members.len(); + let open = group_is_open(&group.key, members, toggled); + // The coin row stands for the instrument; this is the core it would actually open, and + // it is NAMED on the row so the choice is never hidden. + let Some(picked) = pick_core(&group.members, active_core) else { + continue; + }; + let pick_core_id = picked.core; + let pick_market = picked.market.clone(); + let pick_server = picked.server.clone(); + let checked = selected.contains(&(pick_core_id, pick_market.clone())); + + let on_pick_row = on_pick.clone(); + let market_pick = pick_market.clone(); + let on_toggle_row = on_toggle.clone(); + let market_toggle = pick_market.clone(); + let on_expand_row = on_expand.clone(); + let caret_key = group.key.clone(); + let pair = group.pair.clone(); + list = list.child( + div() + .id(SharedString::from(format!("{id}-{section}-row-{i}"))) + .w_full() + .h(px(row_h)) + .flex_none() + .flex() + .items_center() + .px(design::ui_px(cx, 8.0)) + .cursor_pointer() + .hover(move |s| s.bg(hover_bg)) + .child( + h_flex() + .w_full() + .gap(design::ui_px(cx, 6.0)) + .items_center() + // Clicking a multi-select checkbox does not open the market. The + // wrapper below needs no stop_propagation because MoonCheckbox does not + // trigger the row's on_pick handler. + .when(multi_select, |row| { + row.child( + MoonCheckbox::new(SharedString::from(format!( + "{id}-{section}-cb-{i}" + ))) + .checked(checked) + .size(MoonCheckboxSize::Compact) + .on_change( + move |_v: &bool, _w, app| { + on_toggle_row(pick_core_id, market_toggle.clone(), app); + app.stop_propagation(); + }, + ), + ) + }) + // A caret only where there is something under the row. A single-core + // coin renders exactly the row it always did. + .when(members > 1, |row| { + row.child( + MoonDisclosure::button( + SharedString::from(format!("{id}-{section}-caret-{i}")), + open, + ) + .direction(MoonDisclosureDirection::DownUp) + .size(design::DISCLOSURE_GLYPH) + .box_size(design::DISCLOSURE_BOX) + .hover_color(p.text) + .on_toggle( + move |_v: &bool, _w, app| { + on_expand_row(caret_key.clone(), app); + app.stop_propagation(); + }, + ), + ) + }) + // Clicking the row text opens the coin on the picked core. + .child( + h_flex() + .flex_1() + .min_w_0() + .gap(design::ui_px(cx, 6.0)) + .items_baseline() + .on_mouse_down(MouseButton::Left, move |_, window, app| { + on_pick_row(pick_core_id, market_pick.clone(), window, app); + app.stop_propagation(); + }) + // The instrument never yields; the optional core name does. + .child( + div() + .flex_none() + .text_size(design::t_body(cx)) + .text_color(rgb(p.text)) + .child(pair), + ) + .when(members > 1, |row| { + row.child( + div() + .flex_none() + .text_size(design::t_caption(cx)) + .text_color(rgb(p.text_muted)) + .child( + t!("chart.coin.cores", n = members.to_string()) + .to_string(), + ), + ) + }) + .when(show_server_per_row, |row| { + row.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(design::t_caption(cx)) + .text_color(rgb(p.text_muted)) + // `@core` distinguishes the server qualifier from + // the instrument symbol. + .child(format!("@{pick_server}")), + ) + }), + ), + ), + ); + i += 1; + if !open || members <= 1 { + continue; + } + for member in group.members { + let CoinHit { + core, + market, + server, + .. + } = member; + let checked = selected.contains(&(core, market.clone())); + let on_pick_child = on_pick.clone(); + let market_pick = market.clone(); + let on_toggle_child = on_toggle.clone(); + let market_toggle = market.clone(); + list = list.child( + div() + .id(SharedString::from(format!("{id}-{section}-row-{i}"))) .w_full() - .gap(design::ui_px(cx, 6.0)) + .h(px(row_h)) + .flex_none() + .flex() .items_center() - // Clicking a multi-select checkbox does not open the market. The wrapper - // below needs no stop_propagation because MoonCheckbox does not trigger the - // row's on_pick handler. - .when(multi_select, |row| { - row.child( - MoonCheckbox::new(SharedString::from(format!( - "{id}-{section}-cb-{i}" - ))) - .checked(checked) - .size(MoonCheckboxSize::Compact) - .on_change( - move |_v: &bool, _w, app| { - on_toggle(core, market_toggle.clone(), app); - app.stop_propagation(); - }, - ), - ) - }) - // Clicking the row text selects one market through on_pick. + .px(design::ui_px(cx, 8.0)) + .pl(design::ui_px(cx, 22.0)) + .cursor_pointer() + .hover(move |s| s.bg(hover_bg)) + // On the ROW, which is the stateful element: a plain `Div` carries no + // tooltip. A core name wider than the popup clips, and this is how the + // whole name — never a shortened one — stays reachable. + .tooltip(crate::panels::common::text_tooltip(server.clone())) .child( h_flex() - .flex_1() - .min_w_0() + .w_full() .gap(design::ui_px(cx, 6.0)) - .items_baseline() - .on_mouse_down(MouseButton::Left, move |_, window, app| { - on_pick(core, market_pick.clone(), window, app); - app.stop_propagation(); + .items_center() + .when(multi_select, |row| { + row.child( + MoonCheckbox::new(SharedString::from(format!( + "{id}-{section}-cb-{i}" + ))) + .checked(checked) + .size(MoonCheckboxSize::Compact) + .on_change( + move |_v: &bool, _w, app| { + on_toggle_child(core, market_toggle.clone(), app); + app.stop_propagation(); + }, + ), + ) }) - // The instrument never yields; the optional core name does. + // The row-level tooltip keeps a clipped core name available without + // widening the popup for one unusually long configured name. .child( div() - .flex_none() - .text_size(design::t_body(cx)) - .text_color(pair_fg) - .child(pair), - ) - .when(show_server_per_row, |row| { - row.child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(design::t_caption(cx)) - .text_color(rgb(p.text_muted)) - // `@core` distinguishes the server qualifier from the - // instrument symbol. - .child(format!("@{server}")), - ) - }), + .flex_1() + .min_w_0() + .whitespace_nowrap() + .overflow_hidden() + .truncate() + .text_size(design::t_caption(cx)) + .text_color(rgb(p.text_soft)) + .on_mouse_down(MouseButton::Left, move |_, window, app| { + on_pick_child(core, market_pick.clone(), window, app); + app.stop_propagation(); + }) + .child(server), + ), ), - ), - ); + ); + i += 1; + } + } } list } @@ -830,42 +1147,76 @@ pub(crate) fn release_focus(field: &Entity, window: &mut Window, /// id: Stable popup identity used for the scroll container and child controls. /// results: Query matches or the two empty-field suggestion sections. /// selected: Markets currently accumulated for multi-select. +/// toggled: Coin rows the host has recorded a caret click on; see [`group_is_open`]. /// multi_select: Whether checkboxes and the Open in New Tab footer are enabled. +/// active_core: Core the window is addressing, which decides what a coin row opens. /// server_context: Sole server named once above the rows, or `None` to label every row. /// p: Active palette used by the dropdown. /// cx: Application context used to resolve scaled design tokens. /// on_pick: Callback for opening a row's market. /// on_toggle: Callback for changing a checkbox selection. +/// on_expand: Callback for a caret click, carrying the coin row's identity. /// on_open_new: Callback for opening the accumulated selection in a new tab. /// /// Returns: /// A stateful dropdown element whose list can retain scroll position. #[allow(clippy::too_many_arguments)] -pub(crate) fn render_popup( +pub(crate) fn render_popup( id: &'static str, results: CoinResults, selected: &HashSet<(CoreId, String)>, + toggled: &HashSet, multi_select: bool, + active_core: Option, server_context: Option, p: MoonPalette, cx: &App, on_pick: F, on_toggle: G, + on_expand: E, on_open_new: H, ) -> Stateful
where F: Fn(CoreId, String, &mut Window, &mut App) + Clone + 'static, G: Fn(CoreId, String, &mut App) + Clone + 'static, H: Fn(&mut Window, &mut App) + Clone + 'static, + E: Fn(CoinGroupKey, &mut App) + Clone + 'static, { let selected_count = selected.len(); let show_server_per_row = server_context.is_none(); let row_h = coin_row_h(cx); let visible_slots = whole_row_slots(COIN_LIST_RAW_CAP, row_h); let list_cap = whole_row_cap(COIN_LIST_RAW_CAP, row_h); - let direct_child_count = result_row_count(&results) - + usize::from(server_context.is_some()) - + usize::from(multi_select); + // Grouped ONCE, here, and handed to both the arithmetic and the renderer: counting one shape + // while drawing another is exactly how a viewport cap starts lying. + let grouped = match results { + CoinResults::Query(hits) => GroupedResults::Query(group_hits(hits)), + CoinResults::Suggest { recent, volatile } => GroupedResults::Suggest { + recent: group_hits(recent), + volatile: group_hits(volatile), + }, + }; + let result_rows = match &grouped { + GroupedResults::Query(sections) => { + if sections.is_empty() { + 1 + } else { + direct_row_count(sections, toggled) + } + } + GroupedResults::Suggest { recent, volatile } => { + if recent.is_empty() && volatile.is_empty() { + 1 + } else { + direct_row_count(recent, toggled) + + usize::from(!recent.is_empty()) + + direct_row_count(volatile, toggled) + + usize::from(!volatile.is_empty()) + } + } + }; + let direct_child_count = + result_rows + usize::from(server_context.is_some()) + usize::from(multi_select); let list_overflows = direct_child_count > visible_slots; // `.id(..)` makes the container stateful so `overflow_y_scroll` can let GPUI track wheel // scrolling by ID. The integral cap keeps its final visible row whole at every font scale. @@ -936,9 +1287,9 @@ where ) }; - match results { - CoinResults::Query(hits) => { - if hits.is_empty() { + match grouped { + GroupedResults::Query(sections) => { + if sections.is_empty() { list = empty_note(list, t!("chart.coin.no_results").to_string()); } else { list = push_section( @@ -946,18 +1297,21 @@ where id, "q", None, - hits, + sections, selected, + toggled, multi_select, show_server_per_row, + active_core, p, cx, on_pick.clone(), on_toggle.clone(), + on_expand.clone(), ); } } - CoinResults::Suggest { recent, volatile } => { + GroupedResults::Suggest { recent, volatile } => { if recent.is_empty() && volatile.is_empty() { list = empty_note(list, t!("chart.coin.no_suggestions").to_string()); } else { @@ -968,12 +1322,15 @@ where Some(t!("chart.coin.recent").to_string()), recent, selected, + toggled, multi_select, show_server_per_row, + active_core, p, cx, on_pick.clone(), on_toggle.clone(), + on_expand.clone(), ); list = push_section( list, @@ -982,12 +1339,15 @@ where Some(t!("chart.coin.top_volatile").to_string()), volatile, selected, + toggled, multi_select, show_server_per_row, + active_core, p, cx, on_pick.clone(), on_toggle.clone(), + on_expand.clone(), ); } } diff --git a/crates/moon-ui-gpui/src/controls/coin_search/tests.rs b/crates/moon-ui-gpui/src/controls/coin_search/tests.rs index 9376542c..f6ecb6e1 100644 --- a/crates/moon-ui-gpui/src/controls/coin_search/tests.rs +++ b/crates/moon-ui-gpui/src/controls/coin_search/tests.rs @@ -4,10 +4,29 @@ //! test, so these tests exercise the pure helpers they delegate to instead. use super::{ - CoinHit, MOVER_VOL_REF, Mover, group_hits, merge_ranked_heads, mover_score, - neutralize_blind_provider, turnover_usd, whole_row_cap, + CoinHit, CoinResults, MOVER_VOL_REF, Mover, direct_row_count, enter_target, group_hits, + group_is_open, group_starts_expanded, merge_ranked_heads, mover_score, + neutralize_blind_provider, pick_core, turnover_usd, whole_row_cap, }; use moon_core::market::MarketLabel; +use moon_core::venue::CoreVenue; +use std::collections::HashSet; + +/// Build one coin hit with a stable full instrument label for grouped-search assertions. +fn coin_hit(core: u64, venue: u8, coin: &str) -> CoinHit { + CoinHit { + core, + market: format!("{coin}USDT"), + server: format!("Core {core}"), + label: MarketLabel { + coin: coin.to_string(), + canonic: String::new(), + quote: "USDT".to_string(), + contract: None, + }, + venue: Some(CoreVenue::identify(venue, "", None)), + } +} /// Build one ranked candidate the way `suggest_volatile` does, so a test states only what it is /// about. @@ -172,63 +191,6 @@ fn a_provider_reporting_no_turnover_still_competes() { ); } -/// `coin_search.rs:group_hits` must key a run on the FULL instrument label -/// ([`MarketLabel::pair`]), never a contract-stripped coin. -/// -/// Breakage this pins: changing the grouping key to `MarketLabel::match_key` or -/// `MarketLabel::display_coin`, reasoning "same coin, group it together". A perpetual and a -/// dated contract of the same coin would then merge into one run, and the continuation row would -/// make the two instruments indistinguishable in the dropdown. -#[test] -fn a_dated_contract_never_groups_with_its_perpetual() { - let perpetual = CoinHit { - core: 1, - market: "BTCUSDT".to_string(), - server: "Core A".to_string(), - label: MarketLabel { - coin: "BTC".to_string(), - canonic: String::new(), - quote: "USDT".to_string(), - contract: None, - }, - }; - let dated = CoinHit { - core: 2, - market: "BTCUSD0925".to_string(), - server: "Core B".to_string(), - label: MarketLabel { - coin: "BTC_0925".to_string(), - canonic: String::new(), - quote: "USDT".to_string(), - contract: None, - }, - }; - // `match_key`/`display_coin` would fold both hits to "BTC", which is exactly the collapse - // this test must catch if the grouping key is ever weakened to either of them. - assert_eq!(perpetual.label.match_key(), dated.label.match_key()); - - let rows = group_hits(vec![perpetual, dated]); - - assert_eq!( - rows.len(), - 2, - "both instruments must survive as distinct rows" - ); - let runs: Vec<(String, bool)> = rows - .into_iter() - .map(|row| (row.pair.to_string(), row.first_of_group)) - .collect(); - assert_eq!( - runs, - vec![ - ("BTC-USDT".to_string(), true), - ("BTC-USDT-0925".to_string(), true), - ], - "a perpetual and a dated contract of the same coin must form two SEPARATE runs, each \ - opening its own group, not one run where the second row reads as a continuation: {runs:?}" - ); -} - /// `coin_search.rs:turnover_usd` must keep "this market traded nothing" apart from "this market's /// turnover cannot be converted". /// @@ -318,3 +280,163 @@ fn render_popup_wires_whole_row_cap_instead_of_raw_height() { "render_popup must not restore the raw 340 px cap" ); } + +/// `coin_search.rs::group_hits` must fold all core offerings of one instrument on one venue into +/// one expandable group while retaining every original choice in core order. +/// +/// Breakage this pins: reverting grouping to one visible row per `CoinHit`. A coin available on +/// many cores would flood the dropdown and hide unrelated instruments below the scroll cap. +#[test] +fn fifty_six_cores_of_one_coin_fold_into_one_coin_row() { + let hits = (1..=56).map(|core| coin_hit(core, 1, "BTC")).collect(); + + let sections = group_hits(hits); + + assert_eq!(sections.len(), 1, "one venue must make one section"); + assert_eq!( + sections[0].groups.len(), + 1, + "one full instrument label on one venue must make one group" + ); + let members = §ions[0].groups[0].members; + assert_eq!( + members.len(), + 56, + "the group must retain every core offering" + ); + assert_eq!( + members.iter().map(|hit| hit.core).collect::>(), + (1..=56).collect::>(), + "members must preserve the canonical input order" + ); +} + +/// `coin_search.rs::group_hits` must key groups by the full `MarketLabel::pair`, rather than a +/// contract-stripped search key. +/// +/// Breakage this pins: changing the key to `match_key` or `display_coin`. A perpetual and dated +/// contract would merge, so selecting the visible coin could open the wrong instrument. +#[test] +fn a_dated_contract_never_groups_with_its_perpetual() { + let perpetual = coin_hit(1, 1, "BTC"); + let dated = coin_hit(2, 1, "BTC_0925"); + + assert_eq!(perpetual.label.match_key(), dated.label.match_key()); + assert_ne!(perpetual.label.pair(), dated.label.pair()); + + let sections = group_hits(vec![perpetual, dated]); + + assert_eq!(sections.len(), 1, "one venue must stay one section"); + assert_eq!( + sections[0].groups.len(), + 2, + "distinct full instrument labels must remain distinct groups" + ); +} + +/// `coin_search.rs::group_hits` must make the venue a section boundary as well as grouping by +/// full instrument label. +/// +/// Breakage this pins: dropping the exchange section from the group key. Identically named +/// markets on two exchanges would collapse into one ambiguous choice. +#[test] +fn the_same_coin_on_two_exchanges_stays_in_two_groups() { + let sections = group_hits(vec![coin_hit(1, 1, "BTC"), coin_hit(2, 2, "BTC")]); + + assert_eq!( + sections.len(), + 2, + "each exchange must retain its own section" + ); + assert!( + sections.iter().all(|section| section.groups.len() == 1), + "each exchange section must hold its own BTC group" + ); +} + +/// `coin_search.rs::direct_row_count` must count a collapsed multi-core group as one row and an +/// open multi-core group as its trigger plus its cores, while a single-member group stays one row. +/// +/// Breakage this pins: dropping `group.members.len() > 1 &&` from the child-row guard. A +/// single-core group has no caret but would count as two rows, so the continuation fade appears +/// over a list with nothing below it. +#[test] +fn a_collapsed_group_is_one_row_and_an_open_one_is_its_cores() { + let sections = group_hits(vec![ + coin_hit(1, 1, "BTC"), + coin_hit(2, 1, "BTC"), + coin_hit(3, 1, "ETH"), + ]); + let toggled = HashSet::from([sections[0].groups[0].key.clone()]); + assert_eq!( + direct_row_count(§ions, &toggled), + 2, + "a collapsed multi-core group and a single-core group each contribute one trigger" + ); + assert!( + group_is_open( + §ions[0].groups[0].key, + sections[0].groups[0].members.len(), + &HashSet::new(), + ), + "the two-core group must start open before its explicit toggle" + ); + assert_eq!( + direct_row_count(§ions, &HashSet::new()), + 4, + "an open two-core group draws three rows and its single-core neighbour draws one" + ); +} + +/// `coin_search.rs::group_starts_expanded` must expand no more than three cores by default. +/// +/// Breakage this pins: raising or removing the automatic-collapse boundary. A large multi-core +/// search would consume the dropdown before the user can see other matching instruments. +#[test] +fn groups_above_three_cores_start_collapsed() { + assert!(group_starts_expanded(3)); + assert!(!group_starts_expanded(4)); +} + +/// `coin_search.rs::pick_core` must prefer the active core when it offers the selected market. +/// +/// Breakage this pins: always taking the first group member. Enter or click would open the same +/// coin on a foreign core despite the user searching from a narrowed workspace. +#[test] +fn pick_core_prefers_the_active_core_and_falls_back_to_the_first() { + let members = vec![coin_hit(11, 1, "BTC"), coin_hit(22, 1, "BTC")]; + + assert_eq!(pick_core(&members, Some(22)).map(|hit| hit.core), Some(22)); + assert_eq!(pick_core(&members, Some(99)).map(|hit| hit.core), Some(11)); + assert_eq!(pick_core(&members, None).map(|hit| hit.core), Some(11)); +} + +/// `coin_search.rs::enter_target` must open only a typed-query match, selecting its active-core +/// group member when available. +/// +/// Breakage this pins: returning a suggestion from the `Suggest` arm. Pressing Enter in an empty +/// field would unexpectedly open an arbitrary top-mover chart. +#[test] +fn enter_opens_the_first_match_on_the_active_core_and_nothing_otherwise() { + let query = CoinResults::Query(vec![coin_hit(11, 1, "BTC"), coin_hit(22, 1, "BTC")]); + assert_eq!( + enter_target(query, Some(22)), + Some((22, "BTCUSDT".to_string())) + ); + assert_eq!( + enter_target(CoinResults::Query(Vec::new()), Some(22)), + None, + "an empty query has no market to open" + ); + assert_eq!( + enter_target( + CoinResults::Suggest { + recent: vec![coin_hit(11, 1, "BTC")], + volatile: vec![coin_hit(22, 2, "ETH")], + }, + Some(22), + ), + None, + "Enter on an empty field must not select a suggestion" + ); +} diff --git a/crates/moon-ui-gpui/src/panels/report/actions.rs b/crates/moon-ui-gpui/src/panels/report/actions.rs index 4b8d4a13..f9b1bb73 100644 --- a/crates/moon-ui-gpui/src/panels/report/actions.rs +++ b/crates/moon-ui-gpui/src/panels/report/actions.rs @@ -328,10 +328,24 @@ impl ReportPanel { pub(super) fn close_coin_popup(&mut self, cx: &mut Context) { if self.coin_popup_open { self.coin_popup_open = false; + // The next open starts from the defaults; only the TEXT survives a dismissal here. + self.coin_expanded.clear(); cx.notify(); } } + /// Records an explicit expansion override so the shared size-based defaults need no seeding. + pub(super) fn toggle_coin_expanded( + &mut self, + key: crate::controls::coin_search::CoinGroupKey, + cx: &mut Context, + ) { + if !self.coin_expanded.remove(&key) { + self.coin_expanded.insert(key); + } + cx.notify(); + } + /// Select an order-kind filter, persist the changed set, and request fresh rows. /// /// Args: diff --git a/crates/moon-ui-gpui/src/panels/report/mod.rs b/crates/moon-ui-gpui/src/panels/report/mod.rs index ed4d23ce..ace011af 100644 --- a/crates/moon-ui-gpui/src/panels/report/mod.rs +++ b/crates/moon-ui-gpui/src/panels/report/mod.rs @@ -626,6 +626,11 @@ pub struct ReportPanel { coin_query: String, /// Whether the shared `controls::coin_search` match popup is open. coin_popup_open: bool, + /// Coin rows of that popup whose core list the user has flipped open or shut. + /// + /// Holds what was TOGGLED away from the default, not what is open; see + /// `controls::coin_search::group_is_open`. + coin_expanded: HashSet, from: Entity, /// Mirror of the From field in UTC unix seconds, used to suppress duplicate scoped-update /// queries. The field picks whole minutes, so this is the first second of the picked minute. diff --git a/crates/moon-ui-gpui/src/panels/report/render.rs b/crates/moon-ui-gpui/src/panels/report/render.rs index f709439d..eb48a8d3 100644 --- a/crates/moon-ui-gpui/src/panels/report/render.rs +++ b/crates/moon-ui-gpui/src/panels/report/render.rs @@ -241,6 +241,7 @@ impl Render for ReportPanel { ) }; let view = cx.entity(); + let view_expand = cx.entity(); let coin_input = self.coin.clone(); let backend_pick = self.backend.clone(); // Always a query list: this field filters a report COLUMN, not a set of charts. @@ -248,7 +249,12 @@ impl Render for ReportPanel { "rep-coin-search", crate::controls::coin_search::CoinResults::Query(results), &HashSet::new(), + &self.coin_expanded, false, + // This field filters a report COLUMN rather than opening a chart, so it is scoped + // to no core: a row resolves to the first core carrying the instrument, which is + // the row this list showed first before grouping. + None, None, p, cx, @@ -281,6 +287,9 @@ impl Render for ReportPanel { crate::controls::coin_search::release_focus(&coin_input, window, app); }, |_core, _market, _app| {}, + move |key, app| { + view_expand.update(app, |this, cx| this.toggle_coin_expanded(key, cx)); + }, |_window, _app| {}, ) .absolute() diff --git a/crates/moon-ui-gpui/src/panels/report/state.rs b/crates/moon-ui-gpui/src/panels/report/state.rs index 3bbeebd6..ced1280c 100644 --- a/crates/moon-ui-gpui/src/panels/report/state.rs +++ b/crates/moon-ui-gpui/src/panels/report/state.rs @@ -672,6 +672,9 @@ impl ReportPanel { if t.coin_query != value { t.coin_query = value; t.coin_popup_open = !t.coin_query.trim().is_empty(); + // Defaults whenever the match list comes back up; several paths close it + // without passing `close_coin_popup`. + t.coin_expanded.clear(); t.request_requery(cx); } } @@ -866,6 +869,7 @@ impl ReportPanel { coin, coin_query, coin_popup_open: false, + coin_expanded: HashSet::new(), from, from_query, to, diff --git a/crates/moon-ui-gpui/src/shell/init.rs b/crates/moon-ui-gpui/src/shell/init.rs index db0b7e23..986c1fc0 100644 --- a/crates/moon-ui-gpui/src/shell/init.rs +++ b/crates/moon-ui-gpui/src/shell/init.rs @@ -586,6 +586,7 @@ impl Shell { quiet_to_input, quiet_charts_input, ticker_popup_open: false, + ticker_expanded: std::collections::HashSet::new(), ticker_popup_hovered: false, ticker_input, }; diff --git a/crates/moon-ui-gpui/src/shell/mod.rs b/crates/moon-ui-gpui/src/shell/mod.rs index 54f3a62b..8e39d75b 100644 --- a/crates/moon-ui-gpui/src/shell/mod.rs +++ b/crates/moon-ui-gpui/src/shell/mod.rs @@ -236,4 +236,9 @@ pub(crate) struct Shell { ticker_popup_hovered: bool, /// Coin search field used to build the ticker popup's market/core result list. ticker_input: Entity, + /// Coin rows of the ticker popup whose core list the user has flipped open or shut. + /// + /// Holds what was TOGGLED away from the default, not what is open, so an opening popup needs no + /// seeding; see `controls::coin_search::group_is_open`. + ticker_expanded: std::collections::HashSet, } diff --git a/crates/moon-ui-gpui/src/shell/ticker.rs b/crates/moon-ui-gpui/src/shell/ticker.rs index 55986db4..70b7b249 100644 --- a/crates/moon-ui-gpui/src/shell/ticker.rs +++ b/crates/moon-ui-gpui/src/shell/ticker.rs @@ -22,6 +22,9 @@ impl Shell { } else { self.ticker_popup_open = true; self.ticker_popup_hovered = false; + // Defaults on open: the popup is closed from several places that never pass + // `close_ticker_popup`, so opening is the only reliable reset point. + self.ticker_expanded.clear(); self.ticker_input .update(cx, |st, c| st.set_value(String::new(), window, c)); } @@ -31,10 +34,24 @@ impl Shell { pub(super) fn close_ticker_popup(&mut self, cx: &mut Context) { if self.ticker_popup_open { self.ticker_popup_open = false; + // The next open starts from the defaults, like the query does. + self.ticker_expanded.clear(); cx.notify(); } } + /// Records an explicit expansion override so the shared size-based defaults need no seeding. + fn toggle_ticker_expanded( + &mut self, + key: crate::controls::coin_search::CoinGroupKey, + cx: &mut Context, + ) { + if !self.ticker_expanded.remove(&key) { + self.ticker_expanded.insert(key); + } + cx.notify(); + } + /// Build the right-anchored ticker popup and its full-window dismiss layer. /// /// Both elements are `None` when the popup is closed or `chrome_width` hides the ticker trigger. @@ -64,13 +81,18 @@ impl Shell { let backend = self.backend.clone(); let view = cx.entity(); let ticker_field = self.ticker_input.clone(); + let view_expand = cx.entity(); // Always a query list: this field picks a RATE to display in the header, so "recently // opened chart" and "biggest mover" would be the wrong universe to offer. let list = coin_search::render_popup( "header-ticker-search", crate::controls::coin_search::CoinResults::Query(results), &Default::default(), + &self.ticker_expanded, false, + // The header ticker is not scoped to a core: its rows open on the first core that + // carries the instrument, exactly the row it showed first before grouping. + None, None, p, cx, @@ -85,6 +107,9 @@ impl Shell { crate::controls::coin_search::release_focus(&ticker_field, window, app); }, |_, _, _| {}, + move |key, app| { + view_expand.update(app, |this, cx| this.toggle_ticker_expanded(key, cx)); + }, |_, _| {}, ); diff --git a/crates/moon-ui-gpui/tests/theme_contract/chart.rs b/crates/moon-ui-gpui/tests/theme_contract/chart.rs index ec6c0be7..33712f47 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/chart.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/chart.rs @@ -1016,3 +1016,118 @@ fn chart_hit_testing_reads_the_engines_own_pane_layout() { ); } } + +/// Chart-tab Enter subscriptions must defer the common opening funnel until their host borrow +/// ends. +/// +/// Breakage this pins: removing `window.defer` from `chart_tabs/common.rs::coin_enter_handler`. +/// Enter would update the borrowed host and panic instead of opening the selected market. +#[test] +fn enter_subscriptions_defer_the_common_coin_opening_funnel() { + for path in ["chart_tabs/mod.rs", "chart_tabs/detached_host/mod.rs"] { + let source = code_only(&read_src(path)); + assert!( + source.contains("MoonInputEvent::PressEnter") && source.contains("coin_enter_handler("), + "{path} must route PressEnter through coin_enter_handler" + ); + } + + let common = code_only(&read_src("chart_tabs/common.rs")); + let handler = braced_body( + &common, + "pub(super) fn coin_enter_handler(", + ); + assert!( + handler.contains("coin_pick_handler(") && handler.contains("window.defer("), + "coin_enter_handler must defer coin_pick_handler until the subscription releases its host borrow" + ); +} + +/// Escape must close coin search before detached-host hotkey resolution and from the strip root. +/// +/// Breakage this pins: dropping `coin_escape_ends_search` from either route. Escape would close +/// the active chart while the search list remained open. +#[test] +fn escape_closes_coin_search_before_chart_hotkeys() { + let strip_source = code_only(&read_src("chart_tabs/strip.rs")); + let strip = braced_body(&strip_source, "fn render(&mut self, window: &mut Window"); + assert!( + strip.contains(".on_key_down(") && strip.contains("coin_escape_ends_search("), + "the chart-tab strip root must handle Escape while coin search is visible" + ); + + let detached = code_only(&read_src("chart_tabs/detached_host/mod.rs")); + let on_hotkey = braced_body(&detached, "fn on_hotkey("); + let escape = on_hotkey + .find("coin_escape_ends_search") + .expect("detached on_hotkey must end visible coin search"); + let resolve = on_hotkey + .find("hotkeys::resolve(") + .expect("detached on_hotkey must resolve ordinary hotkeys"); + assert!( + escape < resolve, + "detached Escape must end coin search before hotkeys::resolve can close the active chart" + ); +} + +/// An empty chart stack must retain its muted localized hint inside the size-probed render path. +/// +/// Breakage this pins: removing the empty-state hint or its size probe while restyling Main. A +/// newly opened workspace would render a blank, unmeasured chart area. +#[test] +fn empty_chart_stack_keeps_its_localized_size_probed_hint() { + let main_stack = read_src("chart_tabs/main_stack.rs"); + let render = braced_body( + &main_stack, + "fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement", + ); + let empty = braced_body(&render, "if self.charts.is_empty() {"); + assert!( + empty.contains("chart.empty.hint") && empty.contains("text_muted"), + "the empty Main stack branch must show chart.empty.hint in muted text" + ); + assert!( + empty.contains("with_size_probe("), + "the empty Main stack must remain inside the size-probed render path" + ); + + let locale = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("locales") + .join("shell.yml"), + ) + .expect("shell locale must be readable"); + for (key, next) in [ + ("chart.coin.cores:", "chart.empty.hint:"), + ("chart.empty.hint:", "# --- Stack orientation"), + ] { + let entry = chain_between(&locale, key, next, "localized chart text"); + assert!( + entry.contains("ru:") && entry.contains("en:") && entry.contains("es:"), + "{key} must carry ru, en, and es translations" + ); + } +} + +/// Coin-search rendering must resolve its narrowed bucket before rendering, never post-filter a +/// wide result set by core. +/// +/// Breakage this pins: adding `cores_for` or `retain(|hit| hit.core` after a wide cache lookup. +/// The suggestion cache would keep serving the previous core's markets for its full lifetime. +#[test] +fn coin_search_never_post_filters_a_wide_result_set() { + let coin_search = read_src("controls/coin_search.rs"); + let popup = code_only(braced_body( + &coin_search, + "pub(crate) fn render_popup(", + )); + let rows = code_only(braced_body(&coin_search, "fn push_section(")); + for (name, body) in [("render_popup", popup), ("push_section", rows)] { + assert!( + !body.contains("cores_for(") && !body.contains(".retain(|hit| hit.core"), + "{name} must receive a narrowed bucket result, never post-filter a wide cached result" + ); + } +} diff --git a/crates/moon-ui-gpui/tests/theme_contract/shell.rs b/crates/moon-ui-gpui/tests/theme_contract/shell.rs index c2c59ffd..60d7fd89 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/shell.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/shell.rs @@ -1425,7 +1425,7 @@ fn market_popups_occlude_the_wheel_from_the_surface_behind() { let coin_search = read_src("controls/coin_search.rs"); let ticker = read_src("shell/ticker.rs"); - let popup = braced_body(&coin_search, "pub(crate) fn render_popup("); + let popup = braced_body(&coin_search, "pub(crate) fn render_popup("); assert!( popup.contains(".occlude()"), "the coin-search popup must occlude, or the wheel over its results rescales the chart \ @@ -1651,7 +1651,7 @@ fn every_main_chart_removal_goes_through_the_shared_teardown() { #[test] fn the_multi_select_hint_clips_instead_of_wrapping() { let coin_search = read_src("controls/coin_search.rs"); - let popup = braced_body(&coin_search, "pub(crate) fn render_popup("); + let popup = braced_body(&coin_search, "pub(crate) fn render_popup("); let hint = chain_between( &popup, "if multi_select {", @@ -1686,7 +1686,7 @@ fn single_server_auto_search_names_the_server_once() { let popup = code_only(braced_body( &coin_search, - "pub(crate) fn render_popup(", + "pub(crate) fn render_popup(", )); assert!( popup.contains("let show_server_per_row = server_context.is_none()") @@ -1704,14 +1704,20 @@ fn single_server_auto_search_names_the_server_once() { && popup_server_context.contains(".child(context)"), "the single-server popup must visibly name its server above the result rows" ); - let rows = code_only(braced_body(&coin_search, "fn push_section(")); + let rows = code_only(braced_body(&coin_search, "fn push_section(")); assert!( rows.contains(".when(show_server_per_row, |row|"), "single-server Auto rows must omit the repeated visible @server suffix" ); + let result_row = code_only(chain_between( + &rows, + "let on_pick_row = on_pick.clone();", + "if !open || members <= 1 {", + "the grouped coin result row", + )); assert!( - !rows.contains(".tooltip(") && !rows.contains("text_tooltip("), - "result rows must not attach a tooltip that can cover the row" + !result_row.contains(".tooltip(") && !result_row.contains("text_tooltip("), + "the grouped coin result row must not attach a tooltip that can cover it" ); let strip = code_only(braced_body( diff --git a/crates/moon-ui-gpui/tests/theme_contract/windowing.rs b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs index ce6fed6f..08311a05 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/windowing.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs @@ -887,10 +887,9 @@ fn every_coin_search_exit_releases_the_keyboard() { ( "chart tab strip and detached window", "chart_tabs/common.rs", - // Pick, and the shared end-of-search funnel — the dismiss layer and a press on a - // neighbouring toolbar control both run through `coin_toolbar_press_handler`, so they - // are two exits behind one call. A third exit added here needs its own. - 2, + // Pick, the shared end-of-search funnel, and coin_escape_ends_search each need a + // focus release. + 3, ), ("open selection in a new tab", "chart_tabs/strip.rs", 1), ("report coin filter", "panels/report/render.rs", 2), // pick, dismiss diff --git a/locales/shell.yml b/locales/shell.yml index 757499dd..c092aa79 100644 --- a/locales/shell.yml +++ b/locales/shell.yml @@ -1282,6 +1282,20 @@ chart.coin.no_suggestions: ru: "Пока нечего предложить — начните вводить монету" en: "Nothing to suggest yet — start typing a coin" es: "Aún no hay sugerencias: empiece a escribir una moneda" +# Trailing count on a coin row that names how many cores offer the instrument. +chart.coin.cores: + # Noun-then-colon, the form already used by `chart_labels.cores_count` and `figures_count`: + # Russian declines the noun by count ("2 ядра", "5 ядер"), so "%{n} ядер" is wrong at 2-4, and a + # plural mechanism is not worth inventing for a row label. + ru: "Ядер: %{n}" + en: "Cores: %{n}" + es: "Núcleos: %{n}" +# The only line under the logo of an empty Main chart stack. It names the ONE gesture that exists: +# there is no double-click on a core row that opens a chart. +chart.empty.hint: + ru: "Введите монету в поле поиска справа и нажмите Enter — график откроется на активном ядре" + en: "Type a coin in the search field on the right and press Enter — the chart opens on the active core" + es: "Escriba una moneda en el campo de búsqueda de la derecha y pulse Enter: el gráfico se abrirá en el núcleo activo" # --- Stack orientation (vertical/horizontal) and width labels in horizontal mode --- chart.layout.orientation_tip: ru: "Ориентация: вертикально / горизонтально"