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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions crates/moon-ui-gpui/src/chart_tabs/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,93 @@ pub(super) trait CoinPopupHost: Sized + 'static {
fn coin_field(&self) -> &Entity<MoonInputState>;
/// The backend this host reads and writes, so shared plumbing can reach persisted state.
fn coin_backend(&self) -> Entity<crate::Backend>;
/// 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<CoreId>;
}

/// 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<T: CoinPopupHost>(
this: &T,
input: Entity<MoonInputState>,
window: &mut Window,
cx: &mut Context<T>,
) {
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<T: CoinPopupHost + LayoutPopupHost>(
this: &mut T,
ev: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<T>,
) -> 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.
Expand Down
29 changes: 29 additions & 0 deletions crates/moon-ui-gpui/src/chart_tabs/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Self>,
) {
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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>) {
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>) {
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<CoreId> {
self.backend.read(cx).active_trade_core(&self.group)
}
}
48 changes: 48 additions & 0 deletions crates/moon-ui-gpui/src/chart_tabs/detached_host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ pub(super) struct DetachedChartHost {
coin_input: Entity<MoonInputState>,
/// 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<crate::controls::coin_search::CoinGroupKey>,
/// 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
Expand Down Expand Up @@ -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) =
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<Self>,
) {
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<Self>) {
// 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)));
Expand Down Expand Up @@ -934,9 +971,20 @@ impl CoinPopupHost for DetachedChartHost {

fn clear_coin_search(&mut self, cx: &mut Context<Self>) {
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<CoreId> {
self.backend.read(cx).active_trade_core(&self.group)
}

fn open_picked_coin(&mut self, core: CoreId, market: String, cx: &mut Context<Self>) {
self.panel.update(cx, |p, c| {
p.add_coin(core, &market, coin_search::MANUAL_COIN_TTL_MS, c)
Expand Down
10 changes: 10 additions & 0 deletions crates/moon-ui-gpui/src/chart_tabs/detached_host/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions crates/moon-ui-gpui/src/chart_tabs/main_stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions crates/moon-ui-gpui/src/chart_tabs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::controls::coin_search::CoinGroupKey>,
/// 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<u32, u64>,
Expand Down Expand Up @@ -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) =
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand Down
Loading