feat(editor): refine slide navigator cards
This commit is contained in:
parent
73732d4edb
commit
dd97ca1512
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue