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: "Ориентация: вертикально / горизонтально"