From e0ce845767397ad4d09e59bb5c14ad5141d0f70e Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:06:25 +0200 Subject: [PATCH] feat(trade-window): allow maximizing the trade detail window The trade detail window opened from a Report row could only be resized by hand. It now carries the same Minimize, Maximize and Close controls as detached panels, double-click on its title toggles maximize, and the maximized state is restored on reopen. --- .../moon-ui-gpui/src/trade_window/render.rs | 156 ++++++++---------- .../src/trade_window/render/tests.rs | 109 ++++++++++++ crates/moon-ui-gpui/src/trade_window/tests.rs | 40 +++++ .../tests/theme_contract/windowing.rs | 36 ++-- docs/WINDOWING.md | 5 + 5 files changed, 250 insertions(+), 96 deletions(-) create mode 100644 crates/moon-ui-gpui/src/trade_window/render/tests.rs diff --git a/crates/moon-ui-gpui/src/trade_window/render.rs b/crates/moon-ui-gpui/src/trade_window/render.rs index 0c6ad985f..d87e31b8b 100644 --- a/crates/moon-ui-gpui/src/trade_window/render.rs +++ b/crates/moon-ui-gpui/src/trade_window/render.rs @@ -91,8 +91,69 @@ pub(super) fn rail_wraps(viewport_w: f32) -> bool { viewport_w < NARROW_W } +/// Lay out title and controls without letting a long trade name squeeze the native buttons. +/// +/// The settings and scale slots are supplied by the view; a headless layout test can exercise +/// this same row without constructing a backend or fetching a trade. Embedded panes omit chrome. +fn trade_header( + title: String, + embedded: bool, + settings: AnyElement, + scale: AnyElement, + cx: &App, +) -> Div { + let p = MoonPalette::active(cx); + let frame = MoonWindowFrame::detached_chart("trade-window-frame", 0.0) + .header_height(HEADER_H) + .controls(MoonWindowFrameControls::MinimizeMaximizeClose) + .show_controls(design::show_custom_window_controls()); + let title_el = match embedded { + true => div() + .flex_1() + .min_w_0() + .truncate() + .text_size(design::t_body(cx)) + .text_color(moon(p.text)) + .child(title) + .into_any_element(), + false => frame + .title_cluster(title, cx) + .h_full() + .flex_1() + .min_w_0() + .items_center() + .into_any_element(), + }; + h_flex() + .h(design::fit_h_px(cx, HEADER_H, 13.0, 10.5)) + .w_full() + .items_center() + .gap(design::ui_px(cx, design::CHROME_GAP)) + .pl(design::ui_px( + cx, + if embedded { + 8.0 + } else { + design::titlebar_leading_inset() + }, + )) + .pr(design::ui_px(cx, 6.0)) + .border_b_1() + .border_color(moon(p.border)) + .bg(moon(p.shell_high)) + .child(title_el) + .child(design::chrome_section(cx).child(settings)) + .child(design::chrome_divider(cx, p)) + .child(design::chrome_section(cx).child(scale)) + // Use the same frame for the title's native drag/double-click zone and the buttons. + // Fixed controls leave truncation to the title at the minimum supported window width. + .when(!embedded && design::show_custom_window_controls(), |el| { + el.child(frame.visual_controls(cx).flex_none()) + }) +} + impl Render for TradeWindowView { - /// Render the focused trade and its remembered neighbouring-trades control. + /// Render the focused trade with resizable chart space and native window controls. fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // A pane leaves the keyboard to the window it sits in: no focus of its own to restore, // no keys, no window chrome (`Host::Embedded`). @@ -102,31 +163,11 @@ impl Render for TradeWindowView { } let p = MoonPalette::active(cx); let narrow = rail_wraps(f32::from(window.viewport_size().width)); - // The header's ACTUAL height, not the constant it is built from: that constant is scaled, - // so a raw value drifts under a non-default UI or font scale. - let header_h = design::fit_h_px(cx, HEADER_H, 13.0, 10.5); - let frame = MoonWindowFrame::detached_chart("trade-window-frame", 0.0) - .header_height(HEADER_H) - .controls(MoonWindowFrameControls::Close) - .show_controls(design::show_custom_window_controls()); let title = format!("{} · {}", self.record.coin, self.stamps.1); - let title_el = match embedded { - true => div() - .flex_1() - .min_w_0() - .truncate() - .text_size(design::t_body(cx)) - .text_color(moon(p.text)) - .child(title) - .into_any_element(), - false => frame - .title_cluster(title, cx) - .h_full() - .flex_1() - .min_w_0() - .items_center() - .into_any_element(), - }; + let settings = self.settings_popup_host(cx).into_any_element(); + let scale = + crate::controls::scale_dropdown_for_trade_window(cx, self.scale(cx), cx.entity(), p); + let header = trade_header(title, embedded, settings, scale, cx); // Built per branch rather than cloned: `AnyElement` is single-use by construction, which // is also what keeps a stray second mount from silently sharing element ids. let body = self.chart_area(p, cx); @@ -181,67 +222,7 @@ impl Render for TradeWindowView { }, )) }) - .child( - h_flex() - .h(header_h) - .w_full() - .items_center() - .gap(design::ui_px(cx, design::CHROME_GAP)) - // A pane has no traffic lights to clear. - .pl(design::ui_px( - cx, - if embedded { - 8.0 - } else { - design::titlebar_leading_inset() - }, - )) - .pr(design::ui_px(cx, 6.0)) - .border_b_1() - .border_color(moon(p.border)) - .bg(moon(p.shell_high)) - .child(title_el) - // THE SETTINGS POPUP: one ⚙ for everything this window draws, including the - // "other trades" toggle that used to sit here as a bare checkbox. The header - // has a hard minimum width and every control on it is `flex_none`; one button - // is what fits beside the scale and the close control. - .child(design::chrome_section(cx).child(self.settings_popup_host(cx))) - // THE VERTICAL-SCALE CONTROL: one remembered zoom for every trade window, - // written through `pick_scale` into `WindowLayout.trade_window_scale` and - // restored when the next window opens. Auto (`None`) is the default when - // nothing has been saved yet. - // - // The chart's own badge states what the pane is CURRENTLY on and is part of - // this view's caption set; this trigger states what was PICKED. Reached - // through the `controls` facade because the `scale` module behind it is - // private. - // - // `chrome_section` is `flex_none`, for the same reason the close button below - // is: the title cluster beside it is `flex_1().min_w_0()` and would otherwise - // squeeze the trigger to nothing at the minimum window width. The row's own - // `CHROME_GAP` supplies the space on both sides of the rule, so the divider - // carries no margin of its own. - .child(design::chrome_divider(cx, p)) - .child(design::chrome_section(cx).child( - crate::controls::scale_dropdown_for_trade_window( - cx, - self.scale(cx), - cx.entity(), - p, - ), - )) - // THE CLOSE BUTTON. `.controls(...)` and `.show_controls(...)` above only - // CONFIGURE the frame; the buttons are drawn by this separate call, and - // without it the window has no chrome affordance at all. That is not a - // cosmetic loss here: `trade_window_options` hides the taskbar button too, - // so an unmounted control leaves the window with no way out. - // - // `flex_none` because the sibling title cluster is `flex_1().min_w_0()` and - // would otherwise squeeze the button to zero at the minimum window width. - .when(!embedded && design::show_custom_window_controls(), |el| { - el.child(frame.visual_controls(cx).flex_none()) - }), - ) + .child(header) .when(narrow && !self.hide_rail, |el| { el.child(figures::rail( &self.record, @@ -277,6 +258,9 @@ impl Render for TradeWindowView { } } +#[cfg(test)] +mod tests; + impl TradeWindowView { /// The chart, or the overlay that stands in for it. /// diff --git a/crates/moon-ui-gpui/src/trade_window/render/tests.rs b/crates/moon-ui-gpui/src/trade_window/render/tests.rs new file mode 100644 index 000000000..02af71b6c --- /dev/null +++ b/crates/moon-ui-gpui/src/trade_window/render/tests.rs @@ -0,0 +1,109 @@ +//! Headless layout coverage of the production trade-detail titlebar. + +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{ + AppContext, Bounds, Context, IntoElement, ParentElement, Pixels, Render, Styled, Window, div, + px, +}; +use moon_core::config::UiThemeMode; +use moon_ui::{MoonButtonSegment, MoonDropdown, MoonPalette, MoonSize}; + +use super::trade_header; + +/// Supply inert settings/scale triggers to the real header, without a backend or trade fetch. +struct HeaderHarness { + width: f32, + boxes: Rc>>>, +} + +impl Render for HeaderHarness { + /// Lay out the real header with a long title and the shared icon settings trigger. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let boxes = self.boxes.clone(); + let settings = crate::panels::common::popup_gear_trigger_dense( + "header-settings", + rust_i18n::t!("trade_window.settings.tip").to_string(), + false, + ) + .into_any_element(); + // The scale slot is the same MoonUI trigger size and width as controls::scale; handlers + // and menu contents do not participate in the closed titlebar's layout. + let p = MoonPalette::active(cx); + let scale = MoonDropdown::new("header-scale") + .trigger_width_scaled(58.0) + .trigger_size(MoonSize::Xs.into()) + .segment(MoonButtonSegment::new("\u{1f50d}").color(p.text_muted)) + .segment(MoonButtonSegment::new( + rust_i18n::t!("toolbar.scale_auto_short").to_string(), + )) + .into_any_element(); + div().w(px(self.width)).child( + trade_header( + "UNIT_TRADE_LONG_NAME ".repeat(20), + false, + settings, + scale, + cx, + ) + .on_children_prepainted(move |bounds, _, _| *boxes.borrow_mut() = bounds), + ) + } +} + +/// Removing title truncation or fixed control sizing clips/overlaps buttons at minimum width. +/// Exercise all locales and themes, including the 360-content-pixel width at 200% UI zoom. +#[gpui::test] +fn trade_header_controls_fit_every_locale(cx: &mut gpui::TestAppContext) { + cx.update(moon_ui::init); + for locale in ["ru", "en", "es", "uk", "tr", "pt", "vi"] { + let _locale = crate::test_locale::force(locale); + for mode in [ + UiThemeMode::Dark, + UiThemeMode::Light, + UiThemeMode::Graphite, + UiThemeMode::LightExperimental, + UiThemeMode::DarkExperimental, + ] { + cx.update(|cx| { + moon_ui::MoonTheme::install_config( + crate::startup::moon_theme_config_for_presentation(mode, 1.0), + cx, + ) + }); + for width in [360.0, 720.0, 1920.0] { + let boxes = Rc::new(RefCell::new(Vec::new())); + let sink = boxes.clone(); + let handle = cx.add_window(move |_, _| HeaderHarness { width, boxes: sink }); + cx.update_window(handle.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + let boxes = boxes.borrow(); + let custom_controls = crate::design::show_custom_window_controls(); + assert_eq!(boxes.len(), if custom_controls { 5 } else { 4 }); + for bounds in boxes.iter() { + assert!( + bounds.origin.x >= px(0.0) && bounds.right() <= px(width + 0.5), + "{locale}: child clipped at width {width}: {bounds:?}" + ); + } + for pair in boxes.windows(2) { + assert!( + pair[0].right() <= pair[1].origin.x, + "{locale}: titlebar children overlap at width {width}" + ); + } + assert!(boxes[0].size.width > px(0.0), "title must remain readable"); + // Three 25px native controls with two 2px gaps, independently specified by + // MoonUI's frame contract; dropping maximize or shrinking the group fails. + if custom_controls { + assert_eq!(boxes[4].size.width, px(79.0)); + } + cx.update_window(handle.into(), |_, window, _| window.remove_window()) + .unwrap(); + } + } + } +} diff --git a/crates/moon-ui-gpui/src/trade_window/tests.rs b/crates/moon-ui-gpui/src/trade_window/tests.rs index 2a0b03477..6c1d1b8e4 100644 --- a/crates/moon-ui-gpui/src/trade_window/tests.rs +++ b/crates/moon-ui-gpui/src/trade_window/tests.rs @@ -155,6 +155,46 @@ fn remembered_trade_window_geometry_never_persists_a_cascade_offset() { } } +/// Dropping the state flags while remembering or serializing a trade reopens it windowed; +/// retaining a stale true flag prevents a restored window from reopening at its normal size. +#[test] +fn trade_window_maximize_and_restore_round_trip_through_layout() { + use gpui::{Bounds, WindowBounds, point, px, size}; + use moon_core::config::layout::WindowLayout; + let display = uuid::Uuid::from_u128(0xfeed_cafe_dead_beef_0123_4567_89ab_cdef); + let previous = rect(100, 200, 1100, 720, Some(display)); + for cascade in [0.0, 34.0] { + for maximized in [true, false] { + let mut observed = rect(100 + cascade as i32, 200 + cascade as i32, 1100, 720, None); + observed.maximized = maximized; + let mut layout = WindowLayout::default(); + layout.trade_window = Some(remembered_geometry(Some(previous), observed, cascade)); + let encoded = serde_json::to_string(&layout).expect("serialize layout"); + let restored: WindowLayout = serde_json::from_str(&encoded).expect("restore layout"); + let saved = restored.trade_window.expect("trade geometry retained"); + assert_eq!(saved.display_uuid, Some(display)); + let bounds = Bounds { + origin: point(px(saved.x as f32), px(saved.y as f32)), + size: size(px(saved.w as f32), px(saved.h as f32)), + }; + let native = crate::window::windowing::window_bounds_for( + saved.maximized, + saved.fullscreen, + bounds, + ); + let expected = Bounds { + origin: point(px(100.0), px(200.0)), + size: size(px(1100.0), px(720.0)), + }; + match (maximized, native) { + (true, WindowBounds::Maximized(actual)) + | (false, WindowBounds::Windowed(actual)) => assert_eq!(actual, expected), + (_, actual) => panic!("wrong reopened state: {actual:?}"), + } + } + } +} + /// Publishing only the focus when enabled, or the full history when disabled, hides/shows the /// wrong arrows. Restoring the retained snapshot must also preserve neighbours outside replay. #[test] diff --git a/crates/moon-ui-gpui/tests/theme_contract/windowing.rs b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs index 7e7eaf5f3..b80fe84c0 100644 --- a/crates/moon-ui-gpui/tests/theme_contract/windowing.rs +++ b/crates/moon-ui-gpui/tests/theme_contract/windowing.rs @@ -1282,6 +1282,7 @@ fn secondary_window_frames_expose_maximize_without_changing_login() { "core_expert/render.rs", "diagnostics/debug_window.rs", "window/detached.rs", + "trade_window/render.rs", ] { let source = code_only(&read_unsplit(path)); let mut checked = 0; @@ -1307,16 +1308,31 @@ fn secondary_window_frames_expose_maximize_without_changing_login() { } let login = code_only(&fs::read_to_string(root.join("window/login.rs")).unwrap()); assert!(!login.contains("MinimizeMaximizeClose")); - // The pinned MoonUI cannot request maximize without minimize. A taskbar-hidden chart/trade - // window must stay Close-only until the upstream frame exposes a maximize-and-close set. - for path in [ - "chart_tabs/detached_host/render.rs", - "trade_window/render.rs", - ] { - let source = code_only(&fs::read_to_string(root.join(path)).unwrap()); - assert!(source.contains(".controls(MoonWindowFrameControls::Close)")); - assert!(!source.contains("MinimizeMaximizeClose")); - } + // Detached live charts retain Close-only chrome; historical trade-detail windows use the + // same full set as detached panels and Profit Monitor. + let source = code_only(&read_src("chart_tabs/detached_host/render.rs")); + assert!(source.contains(".controls(MoonWindowFrameControls::Close)")); + assert!(!source.contains("MinimizeMaximizeClose")); +} + +/// Losing the trade opener's saved state or its bounds observer breaks reopen-after-maximize. +/// The title cluster already owns native drag/double-click input; no overlay may cover controls. +#[test] +fn trade_detail_window_keeps_geometry_and_native_chrome_wiring() { + let window = code_only(&read_src("trade_window/window.rs")); + assert!(window.contains("(geom.maximized, geom.fullscreen)")); + assert!(window.contains("window_bounds_for(saved_state.0, saved_state.1, bounds)")); + assert!(window.contains("saved.and_then(|geom| geom.display_uuid)")); + let constructor = braced_body(&window, "fn new_view("); + assert!(constructor.contains("window_geom_rect(window, cx)")); + assert!(constructor.contains("remembered_geometry(b.layout.trade_window, geom, cascade_px)")); + assert!(constructor.contains("b.layout.trade_window = Some(geom)")); + assert!(constructor.contains("b.layout_dirty = true")); + let render = code_only(&read_src("trade_window/render.rs")); + let header = braced_body(&render, "fn trade_header("); + assert!(header.contains(".title_cluster(title, cx)")); + assert!(header.contains("frame.visual_controls(cx).flex_none()")); + assert!(!header.contains(".hit_overlay(")); } /// Restoring the old windowed-only reader or constant-false restore flags loses maximized charts; diff --git a/docs/WINDOWING.md b/docs/WINDOWING.md index 91f661eaf..883af46d9 100644 --- a/docs/WINDOWING.md +++ b/docs/WINDOWING.md @@ -27,6 +27,11 @@ Current factories: button; minimizing a Main/group window never minimizes it. - `trade_window_options` - one closed trade replayed beside Main. Independent and hidden from the taskbar, like a detached chart, with a minimum size the detached-chart factory does not take. + Its titlebar uses Minimize, Maximize/Restore and Close, matching detached panels and Profit + Monitor. Double-clicking the title cluster uses MoonUI's native titlebar gesture. This is the + historical trade-detail window opened from Report, not the main manual-trading window. + The shared `WindowLayout.trade_window` geometry retains the restore rectangle, maximized flag + and display UUID; reopening passes that state through `window_bounds_for`. - `login_window_options` - the startup login prompt. Standalone, with the application icon and a taskbar button, because it can open before any group window exists.