feat(editor): refine slide navigator cards

This commit is contained in:
Fini 2026-08-07 01:53:17 +08:00
parent 73732d4edb
commit dd97ca1512
6 changed files with 582 additions and 209 deletions

View file

@ -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<Self> {
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<usize>,
pub hover: Option<SlidesPanelTarget>,
pub drag: Option<SlidesDrag>,
/// 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

View file

@ -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<usize>,
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

View file

@ -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<usize>, hover: Option<SlidesPanelTarget>) -> 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<BoardChip> = (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);

View file

@ -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();

View file

@ -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]

View file

@ -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