openpencil/crates/op-editor-ui/src/widgets/slides_panel_tabs.rs

295 lines
11 KiB
Rust

//! The left rail's tab row — the two-tab header that switches the rail
//! between the Layers tree and the slides navigator.
//!
//! Split out of `slides_panel.rs` at the 800-line ceiling. Pure code
//! motion: `SlidesPanelTabs` is re-exported from the parent, so every
//! import path and test name is unchanged.
//!
//! The row has two modes and picks between them by MEASURING, never by
//! a width threshold — see [`SlidesPanelTabs::new`].
use op_editor_core::{LeftPanelTab, SlidesPanelTarget};
use super::{
contains, TAB_FONT, TAB_ICON_GAP, TAB_ICON_SIZE, TAB_INSET_X, TAB_INSET_Y, TAB_PAD_X,
TAB_RADIUS, TAB_ROW_HEIGHT,
};
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::text_metrics;
use crate::widgets::top_bar_geometry::estimated_text_width;
use crate::widgets::PaintCx;
use crate::{Point2D, Rect, TextLayout, Theme};
/// Rects of the two-tab row that heads the rail.
///
/// Resolved on its own (not only as part of the full slides layout)
/// because the row also paints — and takes clicks — while the LAYERS
/// tab owns the rest of the rail.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SlidesPanelTabs {
pub row: Rect,
pub layers: Rect,
pub slides: Rect,
/// True when the labels did not fit and the row fell back to
/// icons. The ACTIVE tab keeps its label either way.
pub compact: bool,
/// The tab on show — the one that stays a labelled pill in compact
/// mode. Stored because the two tabs then have different widths, so
/// the rects themselves depend on it.
pub active: LeftPanelTab,
}
impl SlidesPanelTabs {
/// Lay the tab row across the top of `panel`.
///
/// **Which mode the row is in is decided by measuring, never by a
/// width threshold.** A hardcoded "narrow means under 220 px" would
/// be wrong the moment a locale has longer words than English, and
/// wrong again the day a third tab lands. Instead the labels are
/// costed against the space available and the row drops to icons
/// only when they genuinely do not fit — so dragging the rail
/// between its 180 and 480 limits switches modes at the right
/// pixel, in every language.
///
/// The cost uses [`estimated_text_width`], not the backend's real
/// shaper, because this rect has to be identical at paint time and
/// at hit-test time and the hit-test has no backend. The estimate
/// charges a full em per non-ASCII character, so CJK labels are
/// over-costed — which is the safe direction: the row drops to
/// icons a few pixels early rather than painting a label that
/// clips.
pub fn new(panel: Rect, active: LeftPanelTab, layers_label: &str, slides_label: &str) -> Self {
let row = Rect {
origin: panel.origin,
size: Point2D::new(panel.size.x, TAB_ROW_HEIGHT),
};
let inner_w = (row.size.x - TAB_INSET_X * 2.0).max(0.0);
let x = row.origin.x + TAB_INSET_X;
let y = row.origin.y + TAB_INSET_Y;
let h = (TAB_ROW_HEIGHT - TAB_INSET_Y * 2.0).max(0.0);
let widest = estimated_text_width(layers_label, TAB_FONT)
.max(estimated_text_width(slides_label, TAB_FONT));
if text_tabs_fit(inner_w, 2, widest) {
let half = inner_w / 2.0;
return Self {
row,
layers: Rect {
origin: Point2D::new(x, y),
size: Point2D::new(half, h),
},
slides: Rect {
origin: Point2D::new(x + half, y),
size: Point2D::new(half, h),
},
compact: false,
active,
};
}
// Icon mode: the active tab keeps `[icon label]`, the rest
// shrink to their glyph. Laid out left to right from the inner
// edge, so the row reads as a toolbar rather than as two
// stretched halves with nothing in them.
let icon_only_w = TAB_ICON_SIZE + TAB_PAD_X * 2.0;
let labelled_w = |label: &str| {
TAB_ICON_SIZE + TAB_ICON_GAP + estimated_text_width(label, TAB_FONT) + TAB_PAD_X * 2.0
};
let (layers_w, slides_w) = match active {
LeftPanelTab::Layers => (labelled_w(layers_label), icon_only_w),
LeftPanelTab::Slides => (icon_only_w, labelled_w(slides_label)),
};
// Even one pill can overrun a very narrow rail, so each tab is
// capped at what its predecessors left rather than being
// allowed to hang off the row's edge.
let layers_w = layers_w.min(inner_w);
let slides_w = slides_w.min((inner_w - layers_w).max(0.0));
Self {
row,
layers: Rect {
origin: Point2D::new(x, y),
size: Point2D::new(layers_w, h),
},
slides: Rect {
origin: Point2D::new(x + layers_w, y),
size: Point2D::new(slides_w, h),
},
compact: true,
active,
}
}
/// Which tab `point` lands on, or `None` when it is off the row.
pub fn hit(&self, point: Point2D) -> Option<SlidesPanelTarget> {
if !contains(self.row, point) {
return None;
}
if contains(self.layers, point) {
return Some(SlidesPanelTarget::LayersTab);
}
contains(self.slides, point).then_some(SlidesPanelTarget::SlidesTab)
}
/// The rail below the tab row — what the Layers tree gets when it
/// is the tab on show.
pub fn content_rect(&self, panel: Rect) -> Rect {
Rect {
origin: Point2D::new(panel.origin.x, panel.origin.y + TAB_ROW_HEIGHT),
size: Point2D::new(panel.size.x, (panel.size.y - TAB_ROW_HEIGHT).max(0.0)),
}
}
/// Paint the tab row. `hover` is whichever tab the cursor is over,
/// if any.
///
/// The labels MUST be the same pair `new` was given — they decide
/// the pill's width there and are drawn inside it here, so a
/// different string would paint outside its own rect.
pub fn paint(
&self,
cx: &mut PaintCx<'_>,
theme: &Theme,
hover: Option<SlidesPanelTarget>,
layers_label: &str,
slides_label: &str,
) {
cx.backend.fill_rect(self.row, theme.card);
if !self.compact {
// The segmented track, so the two tabs read as one control
// rather than two loose buttons. Icon mode has no track:
// there the unselected tabs are bare glyphs, and a rail
// behind them would read as a button they are inside of.
cx.backend.fill_round_rect(
Rect {
origin: self.layers.origin,
size: Point2D::new(self.layers.size.x + self.slides.size.x, self.layers.size.y),
},
TAB_RADIUS,
theme.muted,
);
}
for (rect, tab, label, icon) in [
(
self.layers,
LeftPanelTab::Layers,
layers_label,
Icon::LayersStack,
),
(
self.slides,
LeftPanelTab::Slides,
slides_label,
Icon::PresentationScreen,
),
] {
if rect.size.x <= 0.0 {
continue;
}
let target = match tab {
LeftPanelTab::Layers => SlidesPanelTarget::LayersTab,
LeftPanelTab::Slides => SlidesPanelTarget::SlidesTab,
};
let selected = self.active == tab;
if selected {
cx.backend.fill_round_rect(rect, TAB_RADIUS, theme.card);
cx.backend
.stroke_round_rect(rect, TAB_RADIUS, theme.border, 1.0);
} else if hover == Some(target) {
cx.backend
.fill_round_rect(rect, TAB_RADIUS, theme.button_hover);
}
let color = if selected {
theme.foreground
} else {
theme.muted_foreground
};
let baseline = rect.origin.y + rect.size.y / 2.0 + TAB_FONT / 2.0 - 1.5;
let icon_y = rect.origin.y + (rect.size.y - TAB_ICON_SIZE) / 2.0;
if !self.compact {
let width = text_metrics::measure_chrome_weighted(
cx.backend,
label,
TAB_FONT,
if selected { 600 } else { 400 },
);
cx.backend.draw_text(
&TextLayout::single_run(
label,
"system-ui",
TAB_FONT,
color.to_jian(),
Point2D::ZERO,
)
.with_font_weight(if selected { 600 } else { 400 }),
Point2D::new(rect.origin.x + (rect.size.x - width) / 2.0, baseline),
);
continue;
}
if !selected {
// Glyph alone, centred in its square.
draw_icon(
cx.backend,
icon,
Point2D::new(rect.origin.x + (rect.size.x - TAB_ICON_SIZE) / 2.0, icon_y),
TAB_ICON_SIZE,
color,
1.6,
);
continue;
}
// The one labelled pill: glyph then label, the pair centred
// together so the pill does not look left-heavy.
let label = crate::widgets::file_menu::truncate_to_width(
cx,
label,
TAB_FONT,
(rect.size.x - TAB_PAD_X * 2.0 - TAB_ICON_SIZE - TAB_ICON_GAP).max(0.0),
);
let label_w = text_metrics::measure_chrome_weighted(cx.backend, &label, TAB_FONT, 600);
let content_w = TAB_ICON_SIZE + TAB_ICON_GAP + label_w;
let icon_x = rect.origin.x + (rect.size.x - content_w) / 2.0;
draw_icon(
cx.backend,
icon,
Point2D::new(icon_x, icon_y),
TAB_ICON_SIZE,
color,
1.6,
);
if !label.is_empty() {
cx.backend.draw_text(
&TextLayout::single_run(
&label,
"system-ui",
TAB_FONT,
color.to_jian(),
Point2D::ZERO,
)
.with_font_weight(600),
Point2D::new(icon_x + TAB_ICON_SIZE + TAB_ICON_GAP, baseline),
);
}
}
}
}
/// Whether `count` equal-width text tabs fit in `inner_w` when the
/// widest label needs `widest_label_w`.
///
/// Equal widths are what makes the widest label govern all of them: a
/// tab is only as roomy as its share, so the row is in text mode only
/// when the LONGEST label clears the padding in ITS share.
///
/// Written over a count rather than hardcoding two so a third tab —
/// Pencil ships five — flows through unchanged and simply switches the
/// row to icons sooner.
pub fn text_tabs_fit(inner_w: f32, count: usize, widest_label_w: f32) -> bool {
if count == 0 {
return true;
}
let share = inner_w / count as f32;
share >= widest_label_w + TAB_PAD_X * 2.0
}