From dd97ca15128e030ce934fb7aec1579ae27bef85a Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 7 Aug 2026 01:53:17 +0800 Subject: [PATCH] feat(editor): refine slide navigator cards --- .../op-editor-ui/src/widgets/slides_panel.rs | 415 +++++++++++------- .../src/widgets/slides_panel_flow.rs | 2 - .../src/widgets/slides_panel_tests.rs | 324 ++++++++++++-- .../src/widget_host/slides_panel.rs | 23 +- .../src/widget_host/slides_panel_tests.rs | 14 +- .../src/widget_host/slides_panel.rs | 13 +- 6 files changed, 582 insertions(+), 209 deletions(-) diff --git a/crates/op-editor-ui/src/widgets/slides_panel.rs b/crates/op-editor-ui/src/widgets/slides_panel.rs index 2dd4dda2a..c67c371c9 100644 --- a/crates/op-editor-ui/src/widgets/slides_panel.rs +++ b/crates/op-editor-ui/src/widgets/slides_panel.rs @@ -1,31 +1,41 @@ //! Slides panel — the left rail's page-navigator tab. //! -//! One row per top-level board, in page order: a number, a real -//! rendered thumbnail of the board, and its name. Clicking a row frames -//! that board; dragging one reorders the deck; the footer starts the -//! presentation. It is the deck's only navigator — what a slide IS, -//! which one the camera is on and how a reorder commits all come from +//! One row per top-level board, in page order, and a row is a CARD: a +//! rounded surface one step off the rail, holding a real rendered +//! thumbnail of the board with a round slide-number chip riding its +//! top-left corner. Clicking a row frames that board; dragging one +//! reorders the deck; the footer starts the presentation. It is the +//! deck's only navigator — what a slide IS, which one the camera is on +//! and how a reorder commits all come from //! [`crate::widgets::deck_boards`], so the rail can never disagree with //! the presentation about the order. //! +//! **Rows carry no name.** The board's name is already on the board, as +//! the frame label the canvas paints above it; repeating it under every +//! thumbnail bought a second text baseline, a truncation rule and a +//! taller row for information the user is looking straight at. The list +//! is a sequence of pictures — the eye counts positions and recognises +//! slides, which is what a navigator is for. +//! //! **This widget paints a thumbnail PLACEHOLDER, never a thumbnail.** //! Rendering a board is platform work — a second skia surface per board //! — so the host paints its cached rasters into [`SlidesPanelLayout::thumb_rect`] //! after the widget has painted. A host without a local renderer (the -//! browser) simply paints nothing there and the placeholder stands, which -//! is why the placeholder carries the slide number rather than being a -//! blank hole. +//! browser) simply paints nothing there and the placeholder stands. +//! Because the blit lands ON the plate, everything that has to sit above +//! a thumbnail lives in [`SlidesPanel::paint_overlay`], which every host +//! calls after its own blit — see that method for the contract. //! //! Every rect is derived WITHOUT measuring text — rows are a fixed -//! height for a given board aspect — so the host's hit-test and the -//! paint pass compute the same layout from the same four inputs (panel -//! rect, row count, board aspect, scroll offset). Measurement only -//! decides where a name is cut, never where a row is. +//! height for a given rail width — so the host's hit-test and the paint +//! pass compute the same layout from the same four inputs (panel rect, +//! row count, board aspect, scroll offset). +use jian_widgets::centered_text_baseline_y; use op_editor_core::{SlidesDrag, SlidesPanelTarget}; -use crate::widgets::deck_boards::BoardChip; use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::text_metrics; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout, Theme}; @@ -46,24 +56,55 @@ const TAB_PAD_X: f32 = 8.0; /// Gap between a tab's glyph and its label, when it keeps one. const TAB_ICON_GAP: f32 = 5.0; +/// Margin between the rail's edge and a card. const ROW_PAD_X: f32 = 10.0; -/// Gutter holding the slide number, left of the thumbnail. -const INDEX_COL_W: f32 = 20.0; -const INDEX_GAP: f32 = 6.0; -/// Gap between the thumbnail and the name line under it. -const NAME_GAP: f32 = 4.0; -const NAME_H: f32 = 16.0; -/// Vertical gap between rows. +/// Padding inside a card, around the thumbnail plate. +const CARD_PAD: f32 = 10.0; +const CARD_RADIUS: f32 = 10.0; +/// Ring width on the selected card. +const CARD_STROKE: f32 = 2.0; +/// Vertical gap between cards. const ROW_GAP: f32 = 8.0; -const ROW_FONT: f32 = 11.5; -const THUMB_RADIUS: f32 = 4.0; +/// Corner radius of the thumbnail plate inside a card. Public because a +/// host blitting a rendered board has to clip to the same corners the +/// plate was drawn with, or the picture paints square over a round hole. +pub const SLIDE_THUMB_RADIUS: f32 = 5.0; +/// Diameter of the round slide-number chip. +const CHIP_D: f32 = 22.0; +/// Chip inset from the card's top-left corner. +const CHIP_INSET: f32 = 8.0; +const CHIP_FONT: f32 = 11.0; +/// Opacity of the chip's disc. The chip floats over a rendered board +/// whose colours belong to the DOCUMENT, not the editor, so it carries +/// its own scrim instead of a theme surface — one disc that stays +/// legible over a white cover slide and a black one, in either theme. +const CHIP_SCRIM_ALPHA: f32 = 0.72; +/// Opacity of the hairline around the disc. A dark scrim alone has no +/// shape on a dark slide — the number reads but the chip does not — and +/// a dark deck is exactly the case this panel was built for. The +/// hairline draws the circle there; the scrim draws it on a light one. +const CHIP_EDGE_ALPHA: f32 = 0.18; +/// Rail width the box height is struck at — the shipped default for +/// `layer_panel_width`. It is a REFERENCE, not a constraint: the rail +/// resizes freely and the height below does not follow it. +const REFERENCE_RAIL_W: f32 = 240.0; /// Fixed height of every row's thumbnail box. /// -/// The one number that makes a mixed document listable: rows keep this -/// height whether the page holds 16:9 decks, 3:4 cards or 9:19.5 phone -/// screens, and each board is fitted into the box rather than the box -/// being fitted to the board. -pub const THUMB_BOX_H: f32 = 132.0; +/// **The one number that keeps the list a list.** Rows hold this height +/// whether the page carries 16:9 decks, 3:4 cards or 9:19.5 phone +/// screens, and whether the rail is dragged to 180 px or 480 — each +/// board is fitted INTO the box, never the box to the board. Deriving +/// it from the rail's width instead (so a deck always filled its card) +/// looked right at one width and absurd at another: a rail dragged wide +/// gave 500 px slides and a list you could no longer read as a +/// sequence. +/// +/// The value is 16:9 at [`REFERENCE_RAIL_W`], so a deck — the common +/// case — fills its card exactly at the width the rail actually opens +/// at, and any other width only changes how much margin sits either +/// side of the picture. +pub const THUMB_BOX_H: f32 = + (REFERENCE_RAIL_W - (ROW_PAD_X + CARD_PAD) * 2.0) / DEFAULT_BOARD_ASPECT; /// Fallback board aspect (16:9) for a deck whose boards have no /// resolvable bounds yet — the scene may not have been built when the /// first frame paints. @@ -108,14 +149,13 @@ impl SlidesPanelLayout { /// /// `aspects` is one width / height per board, in page order. /// - /// **Rows are a FIXED height and boards are letterboxed into them.** - /// Sizing the row to the board instead would make a mixed document - /// unusable: one 3:4 card among the 16:9 boards stretches every row - /// in the list to the tallest shape, and a page of phone screens - /// gives rows twice the height of the rail. A fixed box also keeps - /// the list readable as a sequence — the eye counts positions, not - /// shapes — and keeps the drag arithmetic a division instead of a - /// scan. + /// **Rows are a FIXED height and boards are fitted into them.** The + /// height is [`THUMB_BOX_H`] and nothing — not the rail's width, + /// not the shapes on the page — moves it; only the box's WIDTH + /// tracks the rail, so dragging the rail wider spreads margin + /// around the pictures rather than growing them. See [`THUMB_BOX_H`] + /// for why. It also keeps the drag arithmetic a division instead of + /// a scan. /// /// `tabs` is passed in rather than derived because the tab row's /// own geometry depends on the labels, and labels are i18n — which @@ -126,7 +166,7 @@ impl SlidesPanelLayout { /// cannot show a slide is worse than no list: it is a strip that /// eats clicks and explains nothing. pub fn new(panel: Rect, tabs: SlidesPanelTabs, aspects: &[f32], offset: f32) -> Option { - let box_w = panel.size.x - ROW_PAD_X * 2.0 - INDEX_COL_W - INDEX_GAP; + let box_w = panel.size.x - (ROW_PAD_X + CARD_PAD) * 2.0; if box_w <= 0.0 { return None; } @@ -168,10 +208,11 @@ impl SlidesPanelLayout { Some(layout) } - /// Height of one row: the thumbnail box plus the name line under it. - /// The same for every row, whatever shape the boards are. + /// Height of one row: the card, which is the thumbnail box plus its + /// padding. A constant — the same for every row, on every page, at + /// every rail width. pub fn row_height(&self) -> f32 { - THUMB_BOX_H + NAME_GAP + NAME_H + THUMB_BOX_H + CARD_PAD * 2.0 } /// Row pitch — a row plus the gap that follows it. @@ -206,24 +247,54 @@ impl SlidesPanelLayout { } } - /// The fixed-size box row `index` gives its thumbnail — the same - /// shape in every row, whatever the board inside it looks like. - pub fn thumb_box_rect(&self, index: usize) -> Rect { + /// The painted card of row `index` — the rounded surface the + /// thumbnail sits inside. + /// + /// Narrower than [`Self::row_rect`] on purpose: the card is the + /// visual row, the row rect is the CLICK target, and letting a + /// press in the margin beside a card still hit it is what stops the + /// list feeling fiddly at the rail's edges. + pub fn card_rect(&self, index: usize) -> Rect { let row = self.row_rect(index); Rect { - origin: Point2D::new( - row.origin.x + ROW_PAD_X + INDEX_COL_W + INDEX_GAP, - row.origin.y, - ), + origin: Point2D::new(row.origin.x + ROW_PAD_X, row.origin.y), + size: Point2D::new((row.size.x - ROW_PAD_X * 2.0).max(0.0), row.size.y), + } + } + + /// The fixed-size box row `index` gives its thumbnail — the card + /// less its padding, the same shape in every row whatever the board + /// inside it looks like. + pub fn thumb_box_rect(&self, index: usize) -> Rect { + let card = self.card_rect(index); + Rect { + origin: Point2D::new(card.origin.x + CARD_PAD, card.origin.y + CARD_PAD), size: self.thumb_box, } } + /// The round slide-number chip on row `index`, riding the card's + /// top-left corner over the thumbnail. + pub fn chip_rect(&self, index: usize) -> Rect { + let card = self.card_rect(index); + Rect { + origin: Point2D::new(card.origin.x + CHIP_INSET, card.origin.y + CHIP_INSET), + size: Point2D::new(CHIP_D, CHIP_D), + } + } + /// Where row `index`'s board actually paints: its own aspect scaled - /// to fit [`Self::thumb_box_rect`] and centred in it — letterboxed - /// above and below for a wide board, pillarboxed either side for a - /// tall one. This is the rect a host blits its rendered board into, - /// so the picture never stretches to a shape the board is not. + /// to fit [`Self::thumb_box_rect`] and centred in it. This is the + /// rect a host blits its rendered board into, so the picture never + /// stretches to a shape the board is not. + /// + /// **The box's height leads.** It is the fixed side, so a board + /// normally fills the row's full height and takes whatever width its + /// aspect asks for — a tall phone screen becomes a narrow strip + /// centred in a wide card, which is exactly the reading a navigator + /// wants. Width only takes over when the aspect would overrun the + /// box, which is what stops a 16:9 board spilling out of a rail + /// dragged to its minimum; it letterboxes there instead. pub fn thumb_rect(&self, index: usize) -> Rect { let boxed = self.thumb_box_rect(index); let size = self @@ -336,15 +407,14 @@ pub fn drag_is_live(drag: &SlidesDrag) -> bool { /// The panel, ready to paint. pub struct SlidesPanel<'a> { - pub chips: &'a [BoardChip], /// The slide the camera is looking at, if any board resolves. pub active: Option, pub hover: Option, pub drag: Option, /// Whether the host will paint a rendered board over the thumbnail - /// box. When it will not, the box carries the slide number at size - /// instead of staying empty — an empty plate reads as broken, a - /// numbered one reads as a slide without a preview. + /// plate. When it will not, the plate carries a faint slide glyph + /// instead of staying empty — a bare plate reads as broken, and the + /// chip already says which slide it is. pub thumbnails_supported: bool, pub layers_label: &'a str, pub slides_label: &'a str, @@ -372,26 +442,63 @@ impl SlidesPanel<'_> { cx.backend.save(); cx.backend.clip_rect(layout.list); - let dragging = self.drag.filter(drag_is_live); - for (index, rect) in layout.visible_rows() { - let ghosted = dragging.is_some_and(|drag| drag.from == index); - self.paint_row(cx, theme, layout, index, rect, ghosted); - } - if let Some(drag) = dragging { - let slot = layout.insertion_slot(drag.pointer_y); - cx.backend.fill_rect( - Rect { - origin: Point2D::new(layout.list.origin.x + ROW_PAD_X, layout.drop_bar_y(slot)), - size: Point2D::new((layout.list.size.x - ROW_PAD_X * 2.0).max(0.0), DROP_BAR_H), - }, - theme.primary, - ); + for (index, _) in layout.visible_rows() { + self.paint_row(cx, theme, layout, index); } cx.backend.restore(); self.paint_footer(cx, theme, layout); } + /// Everything that has to sit ON TOP of a thumbnail: the number + /// chips, the ghost of a carried row, and the drop bar. + /// + /// **Every host calls this exactly once per frame, after + /// [`Self::paint`] and after its own thumbnail blit** — that is the + /// whole reason it is a second method rather than the tail of + /// `paint`. The chip rides the card's top-left corner, which is + /// inside the picture, so anything drawn here during `paint` would + /// be buried by the blit that follows. Splitting it is what keeps + /// the two hosts identical: the browser, which never blits, gets the + /// same pixels from the same two calls back to back. + /// + /// Once per frame, not "at least once": the carried row's ghost is a + /// translucent wash, so a second pass would darken it rather than + /// leave it alone. + pub fn paint_overlay(&self, cx: &mut PaintCx<'_>, layout: &SlidesPanelLayout, theme: &Theme) { + cx.backend.save(); + cx.backend.clip_rect(layout.list); + let dragging = self.drag.filter(drag_is_live); + for (index, _) in layout.visible_rows() { + let ghosted = dragging.is_some_and(|drag| drag.from == index); + if ghosted { + // Wash the card back towards the rail. Done here rather + // than by fading each piece as it paints, because the + // heaviest thing on a carried row is the host's blitted + // board — which the widget never draws and so cannot + // fade at the source. + cx.backend.fill_round_rect( + layout.card_rect(index), + CARD_RADIUS, + fade(theme.card, 1.0 - GHOST_ALPHA), + ); + } + self.paint_number_chip(cx, layout, index, ghosted); + } + if let Some(drag) = dragging { + let slot = layout.insertion_slot(drag.pointer_y); + cx.backend.fill_round_rect( + Rect { + origin: Point2D::new(layout.list.origin.x + ROW_PAD_X, layout.drop_bar_y(slot)), + size: Point2D::new((layout.list.size.x - ROW_PAD_X * 2.0).max(0.0), DROP_BAR_H), + }, + DROP_BAR_H / 2.0, + theme.primary, + ); + } + cx.backend.restore(); + } + fn paint_footer(&self, cx: &mut PaintCx<'_>, theme: &Theme, layout: &SlidesPanelLayout) { cx.backend.fill_rect(layout.footer, theme.card); cx.backend.fill_rect( @@ -416,7 +523,8 @@ impl SlidesPanel<'_> { }, ); let icon_size = 13.0; - let label_w = cx.backend.measure_text(self.present_label, TAB_FONT); + let label_w = + text_metrics::measure_chrome_weighted(cx.backend, self.present_label, TAB_FONT, 600); let content_w = icon_size + 6.0 + label_w; let icon_x = button.origin.x + (button.size.x - content_w) / 2.0; draw_icon( @@ -443,115 +551,118 @@ impl SlidesPanel<'_> { ); } + /// One card: its surface, its selection ring, and the plate the + /// thumbnail lands on. + /// + /// Nothing here fades for a carried row — a ghost has to cover the + /// host's blitted board too, so it is applied once, on top, in + /// [`Self::paint_overlay`]. fn paint_row( &self, cx: &mut PaintCx<'_>, theme: &Theme, layout: &SlidesPanelLayout, index: usize, - row: Rect, - ghosted: bool, ) { let active = self.active == Some(index); - let alpha = if ghosted { GHOST_ALPHA } else { 1.0 }; - // The board's own fitted rect, not the box around it: the plate - // has to sit exactly where the host will blit, or a tall card - // would show a wide plate with its picture floating inside. + let hovered = self.hover == Some(SlidesPanelTarget::Slide(index)); + let card = layout.card_rect(index); + // The card surface, a step off the rail so the list reads as a + // stack of slides rather than pictures loose on the panel. + // Selection lifts the same surface another step and rings it, + // which is why hover under a selected row changes nothing: the + // card is already as far forward as it goes. + let surface = match (active, hovered) { + (true, _) => theme.row_selected, + (false, true) => theme.accent, + (false, false) => theme.muted, + }; + cx.backend.fill_round_rect(card, CARD_RADIUS, surface); + if active { + cx.backend + .stroke_round_rect(card, CARD_RADIUS, theme.primary, CARD_STROKE); + } + // Thumbnail plate — the board's own fitted rect, not the box + // around it, so it sits exactly where the host will blit. A tall + // board gets a narrow plate rather than a wide one with its + // picture floating inside. Recessed (the rail's own tone, darker + // than the card) so an unfilled plate reads as a well. let thumb = layout.thumb_rect(index); - if self.hover == Some(SlidesPanelTarget::Slide(index)) && !ghosted { - cx.backend.fill_round_rect( - Rect { - origin: Point2D::new(row.origin.x + 4.0, row.origin.y - 3.0), - size: Point2D::new((row.size.x - 8.0).max(0.0), row.size.y + 6.0), - }, - 6.0, - theme.button_hover, + cx.backend + .fill_round_rect(thumb, SLIDE_THUMB_RADIUS, theme.card); + if !self.thumbnails_supported { + // No renderer will cover this plate. The chip already names + // the slide, so this is a texture, not a second label. + let size = (thumb.size.y * 0.3).min(32.0); + draw_icon( + cx.backend, + Icon::PresentationScreen, + Point2D::new( + thumb.origin.x + (thumb.size.x - size) / 2.0, + thumb.origin.y + (thumb.size.y - size) / 2.0, + ), + size, + fade(theme.muted_foreground, 0.4), + 1.5, ); } - // Thumbnail placeholder. The host paints its rendered board over - // this rect; on a host without a renderer the placeholder is - // what the user sees, so it has to stand on its own. - cx.backend - .fill_round_rect(thumb, THUMB_RADIUS, fade(theme.muted, alpha)); - cx.backend.stroke_round_rect( - thumb, - THUMB_RADIUS, - fade(if active { theme.primary } else { theme.border }, alpha), - if active { 2.0 } else { 1.0 }, - ); + } + /// The slide number, as a dark disc over the card's top-left corner. + fn paint_number_chip( + &self, + cx: &mut PaintCx<'_>, + layout: &SlidesPanelLayout, + index: usize, + ghosted: bool, + ) { + let alpha = if ghosted { GHOST_ALPHA } else { 1.0 }; + let chip = layout.chip_rect(index); + cx.backend.fill_round_rect( + chip, + CHIP_D / 2.0, + fade(CHIP_DISC, CHIP_SCRIM_ALPHA * alpha), + ); + cx.backend.stroke_round_rect( + chip, + CHIP_D / 2.0, + fade(CHIP_INK, CHIP_EDGE_ALPHA * alpha), + 1.0, + ); let number = format!("{}", index + 1); - let number_color = if active { - fade(theme.primary, alpha) - } else { - fade(theme.muted_foreground, alpha) - }; - let number_w = cx.backend.measure_text(&number, ROW_FONT); + let width = text_metrics::measure_chrome_weighted(cx.backend, &number, CHIP_FONT, 600); cx.backend.draw_text( &TextLayout::single_run( &number, "system-ui", - ROW_FONT, - number_color.to_jian(), + CHIP_FONT, + fade(CHIP_INK, alpha).to_jian(), Point2D::ZERO, ) .with_font_weight(600), Point2D::new( - row.origin.x + ROW_PAD_X + (INDEX_COL_W - number_w).max(0.0), - row.origin.y + ROW_FONT + 2.0, - ), - ); - if !self.thumbnails_supported { - // No renderer will cover this box, so fill it rather than - // leave a blank plate. - let size = (thumb.size.y * 0.34).min(34.0); - let width = cx.backend.measure_text(&number, size); - cx.backend.draw_text( - &TextLayout::single_run( - &number, - "system-ui", - size, - fade(theme.muted_foreground, alpha * 0.55).to_jian(), - Point2D::ZERO, - ) - .with_font_weight(600), - Point2D::new( - thumb.origin.x + (thumb.size.x - width) / 2.0, - thumb.origin.y + thumb.size.y / 2.0 + size / 2.0 - size * 0.15, - ), - ); - } - - let Some(chip) = self.chips.get(index) else { - return; - }; - if chip.name.is_empty() { - return; - } - // Anchored to the row's fixed box, not to the letterboxed - // picture: the names have to sit on one baseline down the list, - // and a tall board's narrow plate must not indent its own label. - let boxed = layout.thumb_box_rect(index); - let name = - crate::widgets::file_menu::truncate_to_width(cx, &chip.name, ROW_FONT, boxed.size.x); - if name.is_empty() { - return; - } - let color = if active { - fade(theme.primary, alpha) - } else { - fade(theme.card_foreground, alpha) - }; - cx.backend.draw_text( - &TextLayout::single_run(&name, "system-ui", ROW_FONT, color.to_jian(), Point2D::ZERO), - Point2D::new( - boxed.origin.x, - boxed.origin.y + boxed.size.y + NAME_GAP + ROW_FONT, + chip.origin.x + (chip.size.x - width) / 2.0, + centered_text_baseline_y(chip, CHIP_FONT), ), ); } } +/// The chip's disc and its label. Fixed rather than themed — see +/// [`CHIP_SCRIM_ALPHA`]. +const CHIP_DISC: Color = Color { + r: 0.06, + g: 0.06, + b: 0.07, + a: 1.0, +}; +const CHIP_INK: Color = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, +}; + /// The largest `aspect`-shaped rectangle that fits inside `boxed`. /// /// A non-finite or non-positive aspect falls back to 16:9 rather than diff --git a/crates/op-editor-ui/src/widgets/slides_panel_flow.rs b/crates/op-editor-ui/src/widgets/slides_panel_flow.rs index 67e9156a3..71decbda0 100644 --- a/crates/op-editor-ui/src/widgets/slides_panel_flow.rs +++ b/crates/op-editor-ui/src/widgets/slides_panel_flow.rs @@ -177,7 +177,6 @@ pub fn layout( /// The widget for the current state, ready to paint. pub fn widget<'a>( - chips: &'a [BoardChip], active: Option, state: &EditorState, layers_label: &'a str, @@ -187,7 +186,6 @@ pub fn widget<'a>( let panel = state.editor_ui.slides_panel; let dragging = panel.drag.is_some_and(|drag| drag_is_live(&drag)); SlidesPanel { - chips, active, // A hover wash under the drop bar reads as a second answer to // "where does this land", so the carried row owns the list diff --git a/crates/op-editor-ui/src/widgets/slides_panel_tests.rs b/crates/op-editor-ui/src/widgets/slides_panel_tests.rs index e056e7512..6126864c1 100644 --- a/crates/op-editor-ui/src/widgets/slides_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/slides_panel_tests.rs @@ -1,8 +1,9 @@ -//! Geometry tests for the slides panel: tab row, row stack, thumbnail -//! boxes, scrolling, and the reorder drop arithmetic. +//! Geometry tests for the slides panel: tab row, row stack, cards, +//! thumbnail boxes, number chips, scrolling, and the reorder drop +//! arithmetic. use super::*; -use crate::widgets::deck_boards::BoardChip; +use crate::widgets::test_capture_backend::CaptureBackend; use op_editor_core::LeftPanelTab; /// Real shipped tab labels, not synthetic strings: whether the row @@ -72,8 +73,9 @@ fn every_row_is_the_same_height_whatever_shape_its_board_is() { "rows differ in height: {heights:?}" ); assert!( - (mixed.row_height() - (THUMB_BOX_H + 4.0 + 16.0)).abs() < 0.01, - "row height {} is the box plus the name line", + (mixed.row_height() - (mixed.thumb_box.y + 20.0)).abs() < 0.01, + "row height {} is the thumbnail box plus the card's 10 px padding \ + either side — there is no name line to allow for", mixed.row_height() ); // The same three rows on a deck of one uniform aspect. @@ -84,9 +86,13 @@ fn every_row_is_the_same_height_whatever_shape_its_board_is() { #[test] fn each_board_is_fitted_into_the_row_box_on_its_own_aspect() { let mixed = layout_of(&[9.0 / 19.5, DEFAULT_BOARD_ASPECT, 1.0], 0.0); - // 240 - 10*2 - 20 - 6 = 194 wide box, THUMB_BOX_H tall. - assert!((mixed.thumb_box.x - 194.0).abs() < 0.01); + // 240 - (10 rail margin + 10 card padding) * 2 = 200 wide box. Its + // height is the constant, which happens to be 16:9 of that width — + // the reference rail is the shipped default, so a deck fills its + // card with no bars at all at the width the rail opens at. + assert!((mixed.thumb_box.x - 200.0).abs() < 0.01); assert!((mixed.thumb_box.y - THUMB_BOX_H).abs() < 0.01); + assert!((THUMB_BOX_H - 200.0 / DEFAULT_BOARD_ASPECT).abs() < 0.01); for (index, aspect) in [(0, 9.0 / 19.5), (1, DEFAULT_BOARD_ASPECT), (2, 1.0)] { let thumb = mixed.thumb_rect(index); @@ -114,10 +120,149 @@ fn each_board_is_fitted_into_the_row_box_on_its_own_aspect() { < 0.01 ); } - // A tall board is pillarboxed (narrower than the box), a wide one - // letterboxed (shorter than it). + // A tall board is pillarboxed — narrower than the box — and so is a + // square card; the 16:9 board the box is shaped for fills it. assert!(mixed.thumb_rect(0).size.x < mixed.thumb_box.x - 1.0); - assert!(mixed.thumb_rect(1).size.y < mixed.thumb_box.y - 1.0); + assert!(mixed.thumb_rect(2).size.x < mixed.thumb_box.x - 1.0); + assert!( + (mixed.thumb_rect(1).size.y - mixed.thumb_box.y).abs() < 0.01 + && (mixed.thumb_rect(1).size.x - mixed.thumb_box.x).abs() < 0.01, + "a deck board fills its plate edge to edge: {:?} in {:?}", + mixed.thumb_rect(1).size, + mixed.thumb_box + ); + // A board WIDER than the box still letterboxes rather than cropping. + let wide = layout_of(&[3.0], 0.0); + assert!(wide.thumb_rect(0).size.y < wide.thumb_box.y - 1.0); +} + +/// The card is the visual row, inset from the rail on both sides, with +/// the thumbnail plate inset again inside it and the number chip riding +/// its top-left corner. These four rects are the whole of the row's +/// look, so the arithmetic between them is asserted rather than left to +/// paint code. +#[test] +fn a_row_is_a_padded_card_with_a_chip_on_its_corner() { + let l = layout(3, 0.0); + for index in 0..3 { + let row = l.row_rect(index); + let card = l.card_rect(index); + let boxed = l.thumb_box_rect(index); + let chip = l.chip_rect(index); + + assert!( + (card.origin.x - row.origin.x - 10.0).abs() < 0.01 + && (card.size.x - (row.size.x - 20.0)).abs() < 0.01, + "the card is inset 10 px from each edge of the rail" + ); + assert!( + (card.origin.y - row.origin.y).abs() < 0.01 && (card.size.y - row.size.y).abs() < 0.01, + "the card IS the row's height — the gap between rows is outside it" + ); + assert!( + (boxed.origin.x - card.origin.x - 10.0).abs() < 0.01 + && (boxed.origin.y - card.origin.y - 10.0).abs() < 0.01, + "the plate is inset 10 px inside the card" + ); + assert!( + (card.origin.x + card.size.x - boxed.origin.x - boxed.size.x - 10.0).abs() < 0.01 + && (card.origin.y + card.size.y - boxed.origin.y - boxed.size.y - 10.0).abs() + < 0.01, + "and the padding is even on all four sides" + ); + + assert!( + (chip.size.x - 22.0).abs() < 0.01 && (chip.size.y - 22.0).abs() < 0.01, + "the chip is a 22 px circle" + ); + assert!( + (chip.origin.x - card.origin.x - 8.0).abs() < 0.01 + && (chip.origin.y - card.origin.y - 8.0).abs() < 0.01, + "inset 8 px from the card's top-left corner" + ); + // Which puts it OVER the picture, not beside it — the reason the + // chips paint in their own pass after the host's blit. + let thumb = l.thumb_rect(index); + assert!( + chip.origin.x < thumb.origin.x + thumb.size.x + && chip.origin.y < thumb.origin.y + thumb.size.y + && chip.origin.x + chip.size.x > thumb.origin.x + && chip.origin.y + chip.size.y > thumb.origin.y, + "the chip overlaps the thumbnail" + ); + } + // Cards stack with a real gap between them rather than touching. + let gap = l.card_rect(1).origin.y - (l.card_rect(0).origin.y + l.card_rect(0).size.y); + assert!((gap - 8.0).abs() < 0.01, "cards sit 8 px apart, got {gap}"); +} + +/// Dragging the rail must not resize the slides. The height is a +/// constant and only the margin either side of a picture moves, which is +/// the difference between a navigator and a wall of 500 px cards — the +/// exact regression a rail-derived height shipped. +#[test] +fn the_row_height_is_the_same_at_every_rail_width() { + let at = |width: f32| { + SlidesPanelLayout::new( + rail(width), + SlidesPanelTabs::new(rail(width), LeftPanelTab::Slides, EN.0, EN.1), + &[DEFAULT_BOARD_ASPECT, 9.0 / 19.5], + 0.0, + ) + .unwrap_or_else(|| panic!("a {width} px rail fits a row")) + }; + // The rail's own drag limits, plus the default it opens at. + let (min, default, max) = (at(180.0), at(240.0), at(480.0)); + assert!((min.row_height() - default.row_height()).abs() < 0.01); + assert!((max.row_height() - default.row_height()).abs() < 0.01); + assert!((default.row_height() - (THUMB_BOX_H + 20.0)).abs() < 0.01); + + // Widening past the reference width leaves the pictures alone and + // spends the space on margin. The box grows; what is drawn in it + // does not. + assert!(max.thumb_box.x > default.thumb_box.x); + for index in 0..2 { + let (was, now) = (default.thumb_rect(index), max.thumb_rect(index)); + assert!( + (was.size.x - now.size.x).abs() < 0.01 && (was.size.y - now.size.y).abs() < 0.01, + "row {index} resized with the rail: {:?} -> {:?}", + was.size, + now.size + ); + let margin = + |l: &SlidesPanelLayout| l.thumb_rect(index).origin.x - l.thumb_box_rect(index).origin.x; + assert!( + margin(&max) > margin(&default), + "row {index} should gain side margin instead" + ); + } + + // Narrowing past it is the one case width takes over: a 16:9 board + // letterboxes rather than spilling out of the box. + let deck = min.thumb_rect(0); + assert!((deck.size.x - min.thumb_box.x).abs() < 0.01); + assert!(deck.size.y < THUMB_BOX_H - 1.0); +} + +/// Height leads the fit: a board normally fills the row's full height +/// and takes whatever width its aspect asks for, so a phone screen +/// becomes a narrow strip centred in a wide card rather than a squat one +/// shrunk to the card's width. +#[test] +fn a_board_fills_the_rows_height_and_takes_the_width_it_needs() { + let l = layout_of(&[9.0 / 19.5, 1.0], 0.0); + for index in 0..2 { + let thumb = l.thumb_rect(index); + assert!( + (thumb.size.y - THUMB_BOX_H).abs() < 0.01, + "row {index} is short of the box height: {:?}", + thumb.size + ); + assert!( + thumb.size.x < l.thumb_box.x - 1.0, + "and narrower than the box, so it centres with margin" + ); + } } #[test] @@ -268,49 +413,63 @@ fn a_non_finite_aspect_falls_back_to_sixteen_by_nine() { } } +/// Paint the panel the way a host does — `paint` then `paint_overlay` — +/// and record every round fill and string. +fn painted(l: &SlidesPanelLayout, panel: &SlidesPanel<'_>) -> CaptureBackend { + let mut backend = CaptureBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint(&mut cx, l, &Theme::dark()); + panel.paint_overlay(&mut cx, l, &Theme::dark()); + backend +} + +fn panel_of(active: Option, hover: Option) -> SlidesPanel<'static> { + SlidesPanel { + active, + hover, + drag: None, + thumbnails_supported: true, + layers_label: "Layers", + slides_label: "Slides", + present_label: "Present", + } +} + +fn filled_at(backend: &CaptureBackend, expected: Rect) -> bool { + backend.round_fills.iter().any(|(rect, _, _)| { + (rect.origin.x - expected.origin.x).abs() < 0.01 + && (rect.origin.y - expected.origin.y).abs() < 0.01 + && (rect.size.x - expected.size.x).abs() < 0.01 + && (rect.size.y - expected.size.y).abs() < 0.01 + }) +} + /// The rect the widget paints its placeholder into IS the rect the host /// blits its rendered board into. If these two ever disagree a thumbnail /// lands off its own row, so the contract is asserted rather than /// assumed. #[test] fn the_placeholder_paints_exactly_where_the_host_blits() { - use crate::widgets::test_capture_backend::CaptureBackend; - let chips: Vec = (0..3) - .map(|i| BoardChip { - id: format!("slide-{i}"), - name: format!("Slide {i}"), - }) - .collect(); let l = layout(3, 0.0); - let panel = SlidesPanel { - chips: &chips, - active: Some(1), - hover: None, - drag: None, - thumbnails_supported: true, - layers_label: "Layers", - slides_label: "Slides", - present_label: "Present", - }; - let mut backend = CaptureBackend::default(); - let mut cx = PaintCx { - backend: &mut backend, - }; - panel.paint(&mut cx, &l, &Theme::dark()); + let backend = painted(&l, &panel_of(Some(1), None)); for index in 0..3 { - let expected = l.thumb_rect(index); assert!( - backend - .round_fills - .iter() - .any( - |(rect, _, _)| (rect.origin.x - expected.origin.x).abs() < 0.01 - && (rect.origin.y - expected.origin.y).abs() < 0.01 - && (rect.size.x - expected.size.x).abs() < 0.01 - && (rect.size.y - expected.size.y).abs() < 0.01 - ), - "row {index}'s placeholder is painted at its thumb_rect {expected:?}" + filled_at(&backend, l.thumb_rect(index)), + "row {index}'s placeholder is painted at its thumb_rect {:?}", + l.thumb_rect(index) + ); + assert!( + filled_at(&backend, l.card_rect(index)), + "and its card at card_rect {:?}", + l.card_rect(index) + ); + assert!( + filled_at(&backend, l.chip_rect(index)), + "and its number chip at chip_rect {:?}", + l.chip_rect(index) ); } // And the footer button, so the press target is a painted button. @@ -326,6 +485,85 @@ fn the_placeholder_paints_exactly_where_the_host_blits() { ); } +/// The chip disc is a circle — radius half its diameter — and the only +/// text a row paints is its number. A name label under the thumbnail is +/// exactly what this panel dropped; a reader who adds one back gets a +/// failing test rather than a taller row nobody asked for. +#[test] +fn a_row_paints_its_number_and_nothing_else() { + let l = layout(3, 0.0); + let backend = painted(&l, &panel_of(Some(1), None)); + + for index in 0..3 { + let chip = l.chip_rect(index); + let disc = backend + .round_fills + .iter() + .find(|(rect, _, _)| { + (rect.origin.x - chip.origin.x).abs() < 0.01 + && (rect.origin.y - chip.origin.y).abs() < 0.01 + }) + .expect("the chip disc paints"); + assert!( + (disc.1 - chip.size.x / 2.0).abs() < 0.01, + "the disc is a circle, not a rounded square: radius {}", + disc.1 + ); + } + + let row_texts: Vec<&String> = backend + .texts + .iter() + .filter(|(_, origin)| origin.y > l.list.origin.y && origin.y < l.footer.origin.y) + .map(|(text, _)| text) + .collect(); + assert_eq!( + row_texts, + vec!["1", "2", "3"], + "the list paints three slide numbers and no labels" + ); +} + +/// Selection and hover are card-level: the whole card changes tone, and +/// only the selected one gets a ring. The old design ringed the picture +/// itself, which read as a border on the slide rather than as a state of +/// the row. +#[test] +fn selection_and_hover_change_the_cards_own_tone() { + let l = layout(3, 0.0); + let theme = Theme::dark(); + let tone = |backend: &CaptureBackend, index: usize| { + let card = l.card_rect(index); + backend + .round_fills + .iter() + .find(|(rect, _, _)| { + (rect.origin.x - card.origin.x).abs() < 0.01 + && (rect.origin.y - card.origin.y).abs() < 0.01 + }) + .map(|(_, _, color)| *color) + .expect("every visible row paints a card") + }; + + let idle = painted(&l, &panel_of(None, None)); + assert_eq!(tone(&idle, 0).r, theme.muted.r); + // A card is a step off the rail, never the same surface as it. + assert_ne!(theme.muted.r, theme.card.r); + + let hovered = painted(&l, &panel_of(None, Some(SlidesPanelTarget::Slide(1)))); + assert_eq!(tone(&hovered, 0).r, theme.muted.r, "row 0 is untouched"); + assert!( + tone(&hovered, 1).r > theme.muted.r, + "the hovered card lifts half a step" + ); + + let selected = painted(&l, &panel_of(Some(2), None)); + assert!( + tone(&selected, 2).r > tone(&hovered, 1).r, + "the selected card lifts further still" + ); +} + #[test] fn a_thumbnail_blit_is_clipped_to_the_list_band() { let unscrolled = layout(20, 0.0); diff --git a/crates/op-host-native/src/widget_host/slides_panel.rs b/crates/op-host-native/src/widget_host/slides_panel.rs index 4aa158a69..f699cec67 100644 --- a/crates/op-host-native/src/widget_host/slides_panel.rs +++ b/crates/op-host-native/src/widget_host/slides_panel.rs @@ -14,7 +14,7 @@ use op_editor_core::LeftPanelTab; use op_editor_ui::widgets::host_canvas_geometry as canvas_geometry; use op_editor_ui::widgets::slides_panel_flow as flow; use op_editor_ui::widgets::{BoardChip, SlidesPanelLayout, SlidesPanelTabs}; -use op_editor_ui::{Point2D, Rect}; +use op_editor_ui::{Point2D, Rect, RenderBackend}; use crate::backend::NativeFrameBackend; @@ -103,7 +103,6 @@ impl WidgetHostNative { let present_label = op_editor_ui::widgets::editor_state_ext::translate(ui, "slidesPanel.present"); let widget = flow::widget( - &slides.chips, slides.active, &self.editor_state, layers_label, @@ -117,6 +116,15 @@ impl WidgetHostNative { widget.paint(&mut cx, &slides.layout, &self.theme); } self.blit_slide_thumbnails(frame, slides); + // After the blit, never before: the number chips and the carried + // row's ghost sit ON the picture, so painting them with the rest + // of the widget would bury them under the rasters below. + { + let mut cx = PaintCx { + backend: &mut *frame, + }; + widget.paint_overlay(&mut cx, &slides.layout, &self.theme); + } } /// Paint just the tab row, for the frames where the layer tree owns @@ -157,9 +165,16 @@ impl WidgetHostNative { // Clip to the band, draw at the full box: a half-scrolled row // shows the top of its board rather than a squashed copy, and // the last row's picture stops at the footer exactly where - // the widget's own placeholder does. + // the widget's own placeholder does. The extra round clip is + // the plate's own corners — the blit covers the placeholder + // exactly, so without it a square picture would paint over + // the rounded plate the widget just drew. let image = image.clone(); - frame.draw_offscreen_layer_to(&image, clip, slides.layout.thumb_rect(*index)); + let thumb = slides.layout.thumb_rect(*index); + frame.save(); + frame.clip_round_rect(thumb, op_editor_ui::widgets::SLIDE_THUMB_RADIUS); + frame.draw_offscreen_layer_to(&image, clip, thumb); + frame.restore(); } let revision = self.editor_state.document_revision(); diff --git a/crates/op-host-native/src/widget_host/slides_panel_tests.rs b/crates/op-host-native/src/widget_host/slides_panel_tests.rs index 4591eef1c..43d2e3b97 100644 --- a/crates/op-host-native/src/widget_host/slides_panel_tests.rs +++ b/crates/op-host-native/src/widget_host/slides_panel_tests.rs @@ -153,10 +153,12 @@ fn a_mixed_page_lists_every_board_at_the_same_row_height() { heights.windows(2).all(|w| (w[0] - w[1]).abs() < 0.01), "a phone screen and a dashboard must not give different row heights: {heights:?}" ); - // The phone is pillarboxed, the 16:9 board letterboxed — each keeps - // its own shape inside the shared box. + // The phone and the square card are pillarboxed; the 16:9 board the + // box is shaped for fills it. Each keeps its own shape inside the + // one shared box. let phone = slides.layout.thumb_rect(0); let board = slides.layout.thumb_rect(1); + let card = slides.layout.thumb_rect(2); assert!( phone.size.y > phone.size.x, "the phone screen stays portrait" @@ -166,7 +168,13 @@ fn a_mixed_page_lists_every_board_at_the_same_row_height() { "the deck board stays landscape" ); assert!(phone.size.x < slides.layout.thumb_box.x - 1.0); - assert!(board.size.y < slides.layout.thumb_box.y - 1.0); + assert!(card.size.x < slides.layout.thumb_box.x - 1.0); + assert!( + (board.size.y - slides.layout.thumb_box.y).abs() < 0.01, + "a deck board fills its plate: {:?} in {:?}", + board.size, + slides.layout.thumb_box + ); } #[test] diff --git a/crates/op-host-web/src/widget_host/slides_panel.rs b/crates/op-host-web/src/widget_host/slides_panel.rs index 150e5aa79..855ae1015 100644 --- a/crates/op-host-web/src/widget_host/slides_panel.rs +++ b/crates/op-host-web/src/widget_host/slides_panel.rs @@ -10,10 +10,10 @@ //! bundle has no such renderer, and the CanvasKit canvas the page paints //! into is not one it can allocate siblings from. The capability is //! therefore split exactly like `deck_html_export_supported`: the tab, -//! the numbers, the names, the highlight, the click-to-navigate and the -//! reorder all work here, and the thumbnail box paints as its -//! placeholder — the slide number at size, so a row reads as a slide -//! without a preview rather than as a broken plate. Routing the render +//! the cards, the number chips, the highlight, the click-to-navigate and +//! the reorder all work here, and the thumbnail plate paints as its +//! placeholder — a faint slide glyph, so a row reads as a slide without +//! a preview rather than as a broken plate. Routing the render //! to the `--serve-web` daemon (which already rasters boards for //! `export_nodes`) is the follow-up that closes the gap; it is a //! transport question, not a design one. @@ -99,7 +99,6 @@ impl WidgetHost { let present_label = op_editor_ui::widgets::editor_state_ext::translate(ui, "slidesPanel.present"); let widget = flow::widget( - &slides.chips, slides.active, &self.editor_state, layers_label, @@ -108,6 +107,10 @@ impl WidgetHost { ); let mut cx = PaintCx { backend }; widget.paint(&mut cx, &slides.layout, &self.theme); + // No blit comes between the two here — the browser has no board + // renderer — but the pair stays adjacent so the call shape is + // the one native uses, with its blit in the gap. + widget.paint_overlay(&mut cx, &slides.layout, &self.theme); } /// Paint just the tab row, for the frames where the layer tree owns