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
156 changes: 70 additions & 86 deletions crates/moon-ui-gpui/src/trade_window/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) -> 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`).
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -277,6 +258,9 @@ impl Render for TradeWindowView {
}
}

#[cfg(test)]
mod tests;

impl TradeWindowView {
/// The chart, or the overlay that stands in for it.
///
Expand Down
109 changes: 109 additions & 0 deletions crates/moon-ui-gpui/src/trade_window/render/tests.rs
Original file line number Diff line number Diff line change
@@ -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<RefCell<Vec<Bounds<Pixels>>>>,
}

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<Self>) -> 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();
}
}
}
}
40 changes: 40 additions & 0 deletions crates/moon-ui-gpui/src/trade_window/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 26 additions & 10 deletions crates/moon-ui-gpui/tests/theme_contract/windowing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading