makepad-widgets becomes a front crate over widgets/core and widgets/families (each family a crate of its own, linked by features, so an app compiles only the families it uses); widgets/feedback is the Send feedback panel. The dev PRs (#1241-#1264) are ported to the new layout, as in the merge of dev into work. Covers work ebf442a94..2b1a41df4 for draw widgets xr code_editor: - widgets, storybook: the widget catalogue app, some eighty new widgets, a theme store with mixable style sheets, and the rule that a press belongs to - wm, widgets: the style tween starts with all eight weights, and the new style sheet takes the last number instead of macOS's - platform: the Android desk swipes at 120 Hz — offscreen render passes and framebuffers cached, SVG meshes kept, font families that complete - Button: activate from the keyboard when focused (#1241) - app_main!: only ship the fonts an app declares (#1247) - platform, widgets, wm: every Vulkan pass renders through a negative-height viewport again, so a Wayland or X11 window stands upright, and no capture - widgets: the supersampled resolve and the map's shadow mask sample their textures as stored, like every other render texture - widgets, platform: the last twelve warnings go - cef, video, platform: what Stage's browser needs from the shared layers - apps: what the CI box found - ScrollBar: add `show_handle` for a view that scrolls without a grabbable bar (#1254) - widgets: children a lookup discovers reach the dump, the snapshot and the flood searches - tests: the workspace suite, run as a whole for the first time, passes outside the example UI tests - examples: the UI tests pass, for the reasons they failed - libs: no warnings in the workspace check on any row - tests: the last three failures of the CI box's night - Linux: fix window chrome button hovers and how maximized/fullscreen windows work (#1255) - Tooltip: position anchored tooltips in the same draw (#1256) - tests: the three binaries over ten seconds in release come under it - widgets: let the host install script mods into every Splash isolate - widgets: a pooled test context forgets the last case's Escape claim - widgets: the pooled test context's resets replace the globals - RadioButton: fix its touch hover state and click-off behavior (#1258) - HtmlLink: fix its hover and pressed states (#1257) - RadioButton: take key focus on the click, not on the press (#1259) - feat(text): CoreText outline fallback for hvgl-only fonts (macOS) - draw text: the CoreText outline fallback is only ever resolved for a face whose outlines live solely in hvgl, and font-family diagnostics are the `f - platform, widgets, wm: a touch that is taken away is cancelled, never released - wm on Android: every app is its own process - widgets: touch lists scroll with Android's physics - task, cargo-makepad, widgets, audio: the task manager chooses its columns, graphs every process' network and disk traffic and installs itself as a D - widgets, draw: skeuomorphic surfaces light each other through an optional relief buffer - widgets, platform: a texture can light the relief buffer, menus take colour chips, and a style reload recompiles changed shader functions - widgets: relief surfaces can travel like mechanical keys, and dark surfaces take less neighbour light - platform, widgets, audio_route, ai: window crossfades and whole-frame presents, caption controls that click, themed menus, a stereo-pair audio tap, - platform, widgets, ai: a restyle recompiles nothing it already has, shows only complete frames, and a window can own its caption - widgets: relief surfaces can swing their light toward a point - widgets, builder: the big widget families are features, all on by default, and the Builder takes none of them - ai: Claude Desktop drives any app with the F10 panel - Constrain Splash external I/O to the host service bridge (#1243) - PortalList: stop following the end when scrolling to an earlier item (#1261) - draw: glass of a view that stopped drawing leaves the screen - widgets: the Widget lookup methods and with_script_vm_id are compiled once, not once per widget type - widgets: the library moves to makepad-widgets-core in widgets/core, and makepad-widgets becomes its front crate - widgets: the Window, widget tree and panel theme reach the tweaker, voice, AI slot and dock through hooks, not through their modules - widgets: every widget family is a crate of its own under widgets/families, and makepad-widgets links, re-exports and registers the ones its features - aichat, widgets: the chat reads the design feedback through the tweaker's hook, so linking the chat no longer needs the tweaker - apps, examples, libs: each crate builds only the widget families it uses, and the design overlay is each app's own default feature - widgets: the build scripts rerun on their own edits only, and makepad-widgets no longer reads MAKEPAD - feedback: Send feedback, a caption icon and a small panel that shows exactly what is sent - feedback: the panel's wording reads right and draws in any font - feedback: takes makepad-widgets without its default families - widgets: the empty parts of an app's caption bar drag the window again - ScrollBar: fade out when idle, like macOS overlay scrollers (#1264) - platform, draw: web builds start again - widgets: the keyboard reaches boxes and modals - widgets: a hover tooltip never sits under the mouse cursor - feedback: Send clipboard, and the dialog works from the keyboard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2960 lines
115 KiB
Rust
2960 lines
115 KiB
Rust
//! LineMenu — a table of contents that takes almost no room.
|
|
//!
|
|
//! A vertical stack of short horizontal lines, one per section of a
|
|
//! document, whose lengths hint at the heading level. Resting the pointer on
|
|
//! the stack, or focusing it, lengthens the lines and shows each section's
|
|
//! name beside them on a small card. The line of the section being read is
|
|
//! lit and follows the linked scroll view as it scrolls, and pressing a row
|
|
//! scrolls that view so the section's heading sits near the top.
|
|
//!
|
|
//! It answers "where am I in this long page, and how do I get to the part I
|
|
//! want" without giving up a column to a list of headings.
|
|
//!
|
|
//! Sections are data naming widgets by id, not headings found by walking the
|
|
//! page: every heading preset is the same `Label` underneath, so a walk
|
|
//! cannot tell one level from another, the words in a contents list are often
|
|
//! shorter than the heading, and a section can start at a picture or a code
|
|
//! block as well as at a heading.
|
|
//!
|
|
//! The view it follows raises nothing the menu can hear when it scrolls, and
|
|
//! a scroll redraws only the view. So the menu looks: any event arms a watch
|
|
//! that reads the view's offset and each heading's place once a frame, and
|
|
//! stops three quiet frames after the last change. An idle page costs nothing.
|
|
//!
|
|
//! A menu inside the view it follows is not supported: that view is busy
|
|
//! dispatching to its children whenever the menu runs, so it can never be
|
|
//! asked where it is or told to move. Put the menu beside the view.
|
|
|
|
use crate::{
|
|
animator::Ease,
|
|
makepad_derive_widget::*,
|
|
makepad_draw::*,
|
|
makepad_platform::event::TouchState,
|
|
family_api::{askable_area, for_each_element, id_path, obj_field, obj_string, orphan_sweep_locks, release_orphaned_sweep_locks},
|
|
nav_list::DrawNavGround,
|
|
overlay_place::{claim_escape, span_inboard},
|
|
pill_nav::{smootherstep, DrawMorphSurface, UnitTween},
|
|
scroll_bars::ScrollExtent,
|
|
scroll_fade::ScrollShadowView,
|
|
view::View,
|
|
widget::*,
|
|
widget_tree::CxWidgetExt,
|
|
};
|
|
|
|
script_mod! {
|
|
use mod.prelude.widgets_internal.*
|
|
use mod.widgets.*
|
|
|
|
/** One section's line: `current` lights it, `ancestor` tints the lit
|
|
* section's parents, `hot` lifts the one under the pointer or keys. */
|
|
set_type_default() do #(DrawLineMenuLine::script_shader(vm)){
|
|
..mod.draw.DrawQuad
|
|
hot: 0.0
|
|
current: 0.0
|
|
ancestor: 0.0
|
|
opacity: 1.0
|
|
/** a line at rest */
|
|
color: uniform(theme.color_outline)
|
|
/** the lit section's parents */
|
|
color_ancestor: uniform(theme.color_on_surface_variant)
|
|
/** the line under the pointer or the keys */
|
|
color_hot: uniform(theme.color_on_surface)
|
|
/** the section being read */
|
|
color_current: uniform(theme.color_primary)
|
|
pixel: fn() {
|
|
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
|
let r = min(self.rect_size.x, self.rect_size.y) * 0.5
|
|
sdf.box(0.0, 0.0, self.rect_size.x, self.rect_size.y, r * 0.5)
|
|
let ink = self.color
|
|
.mix(self.color_ancestor, self.ancestor)
|
|
.mix(self.color_hot, self.hot)
|
|
.mix(self.color_current, self.current)
|
|
sdf.fill(ink)
|
|
return sdf.result * self.opacity
|
|
}
|
|
}
|
|
|
|
/** A section's name, faded with the card. */
|
|
set_type_default() do #(DrawLineMenuText::script_shader(vm)){
|
|
..mod.draw.DrawText
|
|
opacity: 1.0
|
|
get_color: fn() {
|
|
return vec4(self.color.rgb, self.color.a * self.opacity)
|
|
}
|
|
}
|
|
|
|
mod.widgets.LineMenuBase = #(LineMenu::register_widget(vm))
|
|
|
|
/** A table of contents as a stack of short lines; resting on it shows
|
|
* the names, the section being read is lit, and a press scrolls there. */
|
|
mod.widgets.LineMenu = set_type_default() do mod.widgets.LineMenuBase{
|
|
width: Fit
|
|
height: Fit
|
|
padding: Inset{top: 4. right: 4. bottom: 4. left: 4.}
|
|
|
|
/** dotted id path of the view to follow, searched outward; empty is unlinked */
|
|
scroll_view: ""
|
|
/** the sections: `{target: "intro" label: "Introduction" level: 1}` */
|
|
sections: []
|
|
/** deeper sections get no line but still count 1..6 step 1 */
|
|
max_level: 3.0
|
|
/** track the linked view; off, the host lights sections itself */
|
|
follow_scroll: true
|
|
/** the names always drawn inline, with no card */
|
|
always_show_labels: false
|
|
/** lines hug the right edge and the names open to their left */
|
|
mirror: false
|
|
/** one section's row 10..40 step 1 */
|
|
row_height: 18.0
|
|
/** a line's thickness 1..6 step 0.5 */
|
|
line_thickness: 2.0
|
|
/** a level-1 line at rest 4..80 step 1 */
|
|
line_length: 20.0
|
|
/** how much shorter each deeper level is 0..20 step 1 */
|
|
line_step: 5.0
|
|
/** no line is shorter than this 2..40 step 1 */
|
|
line_min: 6.0
|
|
/** added to every line while the names show 0..40 step 1 */
|
|
hover_extra: 10.0
|
|
/** added to the lit line at all times 0..20 step 1 */
|
|
current_extra: 4.0
|
|
/** from the longest line to the names 0..32 step 1 */
|
|
label_gap: 10.0
|
|
/** longer names end in an ellipsis 60..480 step 4 */
|
|
label_max_width: 220.0
|
|
/** room between the card's edge and the rows 0..24 step 1 */
|
|
card_pad: 8.0
|
|
/** the card's corner rounding 0..24 step 1 */
|
|
card_radius: theme.radius_l
|
|
/** how far down the view a heading counts as reached 0..1 step 0.05 */
|
|
spy_line: 0.25
|
|
/** room left above a heading after a jump 0..96 step 1 */
|
|
scroll_margin: 12.0
|
|
/** rest before the names appear 0..1 step 0.01 */
|
|
reveal_delay: 0.06
|
|
/** grace after the pointer leaves the stack and the card 0..1 step 0.01 */
|
|
conceal_delay: 0.2
|
|
// The names arrive fast and settle, and go slowly then quickly: the
|
|
// theme's enter and exit curves.
|
|
/** the names appearing 0..1 step 0.01 */
|
|
reveal_secs: theme.motion_short_4
|
|
/** the curve the lines lengthen and the names come in along; a theme easing */
|
|
reveal_ease: theme.motion_ease_emphasized_decelerate
|
|
/** the names going 0..1 step 0.01 */
|
|
conceal_secs: theme.motion_short_3
|
|
/** the curve the lines shorten and the names go along; a theme easing */
|
|
conceal_ease: theme.motion_ease_standard_accelerate
|
|
// The page starts and stops on screen, which is what the standard
|
|
// curve is for.
|
|
/** the scroll to a section 0..1.5 step 0.05 */
|
|
jump_secs: theme.motion_medium_4
|
|
/** the curve the page scrolls along; a theme easing */
|
|
jump_ease: theme.motion_ease_standard
|
|
/** the names and the jump land at once; delays still apply */
|
|
reduced_motion: false
|
|
/** alpha of a row whose target is missing 0..1 step 0.05 */
|
|
disabled_opacity: theme.state_disabled_content_opacity
|
|
|
|
/** the lines */
|
|
draw_line +: {
|
|
color: theme.color_outline
|
|
color_ancestor: theme.color_on_surface_variant
|
|
color_hot: theme.color_on_surface
|
|
color_current: theme.color_primary
|
|
}
|
|
/** the card the names show on */
|
|
draw_card +: {
|
|
color: theme.color_surface_container
|
|
color_from: theme.color_surface_container
|
|
border_color: theme.color_outline_variant
|
|
border_size: 1.0
|
|
shadow_color: theme.color_elevation_2
|
|
shadow_radius: theme.elevation_2_radius
|
|
shadow_offset: vec2(0.0, theme.elevation_2_offset_y)
|
|
}
|
|
/** the tint behind a hot or keyed row */
|
|
draw_row +: {
|
|
color: theme.color_surface_container_high
|
|
color_from: theme.color_surface_container_high
|
|
border_size: 0.0
|
|
shadow_radius: 0.0
|
|
shadow_color: vec4(0.0, 0.0, 0.0, 0.0)
|
|
}
|
|
/** the ring around the keyboard's row */
|
|
draw_focus +: {
|
|
color: vec4(0.0, 0.0, 0.0, 0.0)
|
|
color_from: vec4(0.0, 0.0, 0.0, 0.0)
|
|
border_color: theme.color_primary
|
|
border_size: theme.size_focus_ring
|
|
shadow_radius: 0.0
|
|
shadow_color: vec4(0.0, 0.0, 0.0, 0.0)
|
|
}
|
|
/** a section's name */
|
|
draw_label +: {
|
|
text_style: theme.font_regular{font_size: theme.font_size_p}
|
|
color: theme.color_on_surface_variant
|
|
}
|
|
/** the lit section's name, in the face every name is measured in */
|
|
draw_label_current +: {
|
|
text_style: theme.font_bold{font_size: theme.font_size_p}
|
|
color: theme.color_on_surface
|
|
}
|
|
}
|
|
|
|
/** The stack with its names always showing, as a plain table of contents. */
|
|
mod.widgets.LineMenuLabeled = mod.widgets.LineMenu{
|
|
always_show_labels: true
|
|
}
|
|
|
|
/** The stack for the right-hand side of a page: lines hug the right edge
|
|
* and the names open to their left. */
|
|
mod.widgets.LineMenuMirrored = mod.widgets.LineMenu{
|
|
mirror: true
|
|
}
|
|
}
|
|
|
|
#[derive(Script, ScriptHook)]
|
|
#[repr(C)]
|
|
pub struct DrawLineMenuLine {
|
|
#[deref]
|
|
draw_super: DrawQuad,
|
|
#[live]
|
|
pub hot: f32,
|
|
#[live]
|
|
pub current: f32,
|
|
#[live]
|
|
pub ancestor: f32,
|
|
#[live(1.0)]
|
|
pub opacity: f32,
|
|
}
|
|
|
|
#[derive(Script, ScriptHook)]
|
|
#[repr(C)]
|
|
pub struct DrawLineMenuText {
|
|
#[deref]
|
|
draw_super: DrawText,
|
|
#[live(1.0)]
|
|
pub opacity: f32,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One section of the document the menu follows.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct LineSection {
|
|
pub id: LiveId,
|
|
pub label: String,
|
|
/// 1 to 6; a section's parent is the nearest earlier one with a smaller
|
|
/// level.
|
|
pub level: u8,
|
|
/// Dotted id path of the widget the section starts at, inside the view.
|
|
pub target: String,
|
|
}
|
|
|
|
impl LineSection {
|
|
/// A section whose id is the last segment of `target`.
|
|
pub fn new(target: &str, label: &str, level: u8) -> Self {
|
|
Self { id: section_id(target, None), label: label.to_string(), level: level.clamp(1, 6), target: target.to_string() }
|
|
}
|
|
|
|
pub fn with_id(mut self, id: LiveId) -> Self {
|
|
self.id = id;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// What the menu reports.
|
|
#[derive(Clone, Debug, PartialEq, Default)]
|
|
pub enum LineMenuAction {
|
|
/// The lit section changed: by scrolling, by a jump, or by `set_current`.
|
|
Changed(LiveId),
|
|
/// A row was activated by pointer or key. Raised even when the menu
|
|
/// scrolls the view itself, and even when it is unlinked, so a host can
|
|
/// scroll something of its own.
|
|
Jumped(LiveId),
|
|
#[default]
|
|
None,
|
|
}
|
|
|
|
fn parse_sections(vm: &mut ScriptVm, value: ScriptValue) -> Vec<LineSection> {
|
|
let mut sections = Vec::new();
|
|
for_each_element(vm, value, &mut |vm, entry| {
|
|
let Some(obj) = entry.as_object() else {
|
|
return;
|
|
};
|
|
// A section with nothing to say has no row to say it in.
|
|
let Some(label) = obj_string(vm, obj, id!(label)) else {
|
|
return;
|
|
};
|
|
let target = obj_string(vm, obj, id!(target)).unwrap_or_default();
|
|
let explicit = obj_field(vm, obj, id!(id)).as_id();
|
|
let level = obj_field(vm, obj, id!(level)).as_number().map_or(1.0, |n| n.round()).clamp(1.0, 6.0) as u8;
|
|
sections.push(LineSection { id: section_id(&target, explicit), label, level, target });
|
|
});
|
|
sections
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// pure helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Inset kept between the card and the window's edges, as the popover keeps.
|
|
const EDGE: f64 = 6.0;
|
|
/// How far a focus ring stands off what it rings.
|
|
const RING_OUT: f64 = 2.0;
|
|
/// The narrowest the names shrink to before the card stops giving ground.
|
|
const MIN_LABEL: f64 = 40.0;
|
|
/// Frames the watch keeps looking after the last change it saw.
|
|
const QUIET_FRAMES: u8 = 3;
|
|
|
|
/// Which section is being read. `tops` is each section's heading top
|
|
/// relative to the viewport top, in document order; `None` for one not
|
|
/// drawn. `line` is how far down the viewport a heading must come to count
|
|
/// as reached.
|
|
pub fn current_section(tops: &[Option<f64>], viewport_height: f64, line: f64, at_end: bool) -> Option<usize> {
|
|
let mut drawn: Vec<(usize, f64)> = tops.iter().enumerate().filter_map(|(i, top)| top.map(|t| (i, t))).collect();
|
|
if drawn.is_empty() {
|
|
return None;
|
|
}
|
|
drawn.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
|
|
// A short final section can never climb to the line; at the very end the
|
|
// last heading on screen is what the reader is looking at.
|
|
if at_end {
|
|
if let Some((i, _)) = drawn.iter().rev().find(|(_, top)| *top < viewport_height) {
|
|
return Some(*i);
|
|
}
|
|
}
|
|
if let Some((i, _)) = drawn.iter().rev().find(|(_, top)| *top <= line + 0.5) {
|
|
return Some(*i);
|
|
}
|
|
// Nothing has reached the line yet: the first section, because a menu
|
|
// with nothing lit does not say where you are.
|
|
Some(drawn[0].0)
|
|
}
|
|
|
|
/// The sections a section sits under, nearest first.
|
|
pub fn ancestors(levels: &[u8], index: usize) -> Vec<usize> {
|
|
let mut out = Vec::new();
|
|
let Some(mut level) = levels.get(index).copied() else {
|
|
return out;
|
|
};
|
|
for i in (0..index).rev() {
|
|
if levels[i] < level {
|
|
out.push(i);
|
|
level = levels[i];
|
|
if level <= 1 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The section whose row stands for `index`: itself when its level has a
|
|
/// row, else its nearest ancestor that does.
|
|
pub fn shown_row(levels: &[u8], max_level: u8, index: usize) -> Option<usize> {
|
|
let level = *levels.get(index)?;
|
|
if level <= max_level {
|
|
return Some(index);
|
|
}
|
|
ancestors(levels, index).into_iter().find(|a| levels[*a] <= max_level)
|
|
}
|
|
|
|
/// Where to scroll so a heading `top_in_view` below the viewport's top sits
|
|
/// `margin` below it, kept inside what the view can scroll.
|
|
pub fn jump_target(scroll: f64, top_in_view: f64, margin: f64, total: f64, visible: f64) -> f64 {
|
|
(scroll + top_in_view - margin).clamp(0.0, (total - visible).max(0.0))
|
|
}
|
|
|
|
/// A jump's light is held until the reader moves the page by more than two
|
|
/// points: a view that settles a hair off its target has not been scrolled.
|
|
pub fn pin_released(landed: f64, now: f64) -> bool {
|
|
(now - landed).abs() > 2.0
|
|
}
|
|
|
|
/// Where a jump from `from` to `to` has the view `t` of the way through its
|
|
/// time, along `ease`. Exactly `to` once the time is up, because a curve read
|
|
/// at its last sample can land a hair off; and never outside `0..=furthest`,
|
|
/// so a curve that overshoots swings past the heading but never past either
|
|
/// end of the page.
|
|
pub fn jump_offset(from: f64, to: f64, t: f64, ease: Ease, furthest: f64) -> f64 {
|
|
if t >= 1.0 {
|
|
return to;
|
|
}
|
|
(from + (to - from) * ease.map(t.max(0.0))).clamp(0.0, furthest.max(0.0))
|
|
}
|
|
|
|
/// The line lengths, as `line_length` reads them.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct LineMetrics {
|
|
pub line_length: f64,
|
|
pub line_step: f64,
|
|
pub line_min: f64,
|
|
pub hover_extra: f64,
|
|
pub current_extra: f64,
|
|
}
|
|
|
|
/// How long a section's line is: shorter by level down to a floor, longer
|
|
/// while the names show, and a little longer again when it is lit. `reveal`
|
|
/// is already eased, so a spring that swings it past 1 lengthens the line
|
|
/// past its revealed length and back.
|
|
pub fn line_length(level: u8, m: &LineMetrics, reveal: f64, lit: bool) -> f64 {
|
|
let rest = (m.line_length - level.saturating_sub(1) as f64 * m.line_step).max(m.line_min);
|
|
rest + m.hover_extra * reveal + if lit { m.current_extra } else { 0.0 }
|
|
}
|
|
|
|
/// The lowest and the highest a run along `ease` goes: 0 and 1 for a curve
|
|
/// that stays between its ends. Sampled, because an easing is a curve the
|
|
/// theme names, not a formula this file knows.
|
|
pub fn ease_bounds(ease: Ease) -> (f64, f64) {
|
|
const SAMPLES: usize = 240;
|
|
(0..=SAMPLES)
|
|
.map(|i| ease.map(i as f64 / SAMPLES as f64))
|
|
.fold((0.0, 1.0), |(low, high), v| (low.min(v), high.max(v)))
|
|
}
|
|
|
|
/// The furthest the reveal can carry a line, in shares of `hover_extra`:
|
|
/// 1 unless a curve overshoots. A reveal from nothing reaches the top of its
|
|
/// curve; a conceal from fully shown reaches 1 less the bottom of its own.
|
|
pub fn reveal_reach(reveal_ease: Ease, conceal_ease: Ease) -> f64 {
|
|
ease_bounds(reveal_ease).1.max(1.0 - ease_bounds(conceal_ease).0)
|
|
}
|
|
|
|
/// A section's id: the one written, else the last segment of its target.
|
|
pub fn section_id(target: &str, explicit: Option<LiveId>) -> LiveId {
|
|
explicit.unwrap_or_else(|| LiveId::from_str(target.rsplit('.').next().unwrap_or(target)))
|
|
}
|
|
|
|
/// The card's spacing, as `card_layout` reads it.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct CardMetrics {
|
|
pub label_gap: f64,
|
|
pub card_pad: f64,
|
|
pub label_max_width: f64,
|
|
pub min_label: f64,
|
|
}
|
|
|
|
/// Where the card goes and where its names start.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct CardLayout {
|
|
pub card: Rect,
|
|
pub label_x: f64,
|
|
pub label_w: f64,
|
|
/// The names are left of the lines.
|
|
pub labels_before: bool,
|
|
}
|
|
|
|
/// Place the card beside a stack. The names open after the lines, or before
|
|
/// them when mirrored; a side that overruns `bounds` gives way to the other
|
|
/// when that one fits, and with room on neither the names shrink to the
|
|
/// roomier side. The card never moves vertically: the rows are where the
|
|
/// stack is. A `bounds` with no width is unbounded.
|
|
pub fn card_layout(
|
|
stack: Rect,
|
|
lines_start: f64,
|
|
lines_end: f64,
|
|
widest_label: f64,
|
|
m: &CardMetrics,
|
|
bounds: Rect,
|
|
mirror: bool,
|
|
) -> CardLayout {
|
|
let label_w = widest_label.min(m.label_max_width).max(0.0);
|
|
let unbounded = bounds.size.x <= 0.0;
|
|
let lo = bounds.pos.x;
|
|
let hi = bounds.pos.x + bounds.size.x;
|
|
// (label_x, card left, card right) for a side and a width.
|
|
let span = |before: bool, w: f64| {
|
|
if before {
|
|
let label_x = lines_start - m.label_gap - w;
|
|
(label_x, label_x - m.card_pad, stack.pos.x + stack.size.x)
|
|
} else {
|
|
let label_x = lines_end + m.label_gap;
|
|
(label_x, stack.pos.x, label_x + w + m.card_pad)
|
|
}
|
|
};
|
|
let fits = |before: bool, w: f64| {
|
|
let (_, left, right) = span(before, w);
|
|
unbounded || (left >= lo && right <= hi)
|
|
};
|
|
let room = |before: bool| {
|
|
if before {
|
|
lines_start - m.label_gap - m.card_pad - lo
|
|
} else {
|
|
hi - (lines_end + m.label_gap + m.card_pad)
|
|
}
|
|
};
|
|
let before = if fits(mirror, label_w) {
|
|
mirror
|
|
} else if fits(!mirror, label_w) {
|
|
!mirror
|
|
} else if room(!mirror) > room(mirror) {
|
|
!mirror
|
|
} else {
|
|
mirror
|
|
};
|
|
let w = if fits(before, label_w) { label_w } else { room(before).min(label_w).max(m.min_label) };
|
|
let (label_x, left, right) = span(before, w);
|
|
let card = Rect {
|
|
pos: dvec2(left, stack.pos.y - m.card_pad),
|
|
size: dvec2(right - left, stack.size.y + m.card_pad * 2.0),
|
|
};
|
|
let shifted = span_inboard(card.pos.x, card.size.x, lo, if unbounded { 0.0 } else { bounds.size.x });
|
|
let dx = shifted - card.pos.x;
|
|
CardLayout {
|
|
card: Rect { pos: dvec2(shifted, card.pos.y), size: card.size },
|
|
label_x: label_x + dx,
|
|
label_w: w,
|
|
labels_before: before,
|
|
}
|
|
}
|
|
|
|
/// Whether the names show. Pure: the widget feeds it the pointer, the focus
|
|
/// and the clock, and animates toward what it answers.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
pub struct Reveal {
|
|
pub shown: bool,
|
|
pub pending: Option<(bool, f64)>,
|
|
}
|
|
|
|
impl Reveal {
|
|
/// The pointer is over the stack or the card, or not, at `now`.
|
|
pub fn pointer(&mut self, over: bool, now: f64, reveal_delay: f64, conceal_delay: f64) -> Option<bool> {
|
|
if over == self.shown {
|
|
// Already what the pointer asks for: a change the other way is
|
|
// no longer wanted.
|
|
self.pending = None;
|
|
return None;
|
|
}
|
|
let delay = if over { reveal_delay } else { conceal_delay };
|
|
if delay <= 0.0 {
|
|
self.pending = None;
|
|
self.shown = over;
|
|
return Some(over);
|
|
}
|
|
if self.pending.map(|(want, _)| want) != Some(over) {
|
|
self.pending = Some((over, now + delay));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Focus shows the names at once. Losing it hides them at once, unless
|
|
/// the pointer is over them, which is a reason of its own to keep them.
|
|
pub fn focus(&mut self, focused: bool, pointer_over: bool) -> Option<bool> {
|
|
if focused {
|
|
self.pending = None;
|
|
if !self.shown {
|
|
self.shown = true;
|
|
return Some(true);
|
|
}
|
|
return None;
|
|
}
|
|
if pointer_over {
|
|
return None;
|
|
}
|
|
self.pending = None;
|
|
if self.shown {
|
|
self.shown = false;
|
|
return Some(false);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// A tap has no hover to rest with, so it flips the names at once.
|
|
pub fn touch_toggle(&mut self) -> Option<bool> {
|
|
self.pending = None;
|
|
self.shown = !self.shown;
|
|
Some(self.shown)
|
|
}
|
|
|
|
pub fn tick(&mut self, now: f64) -> Option<bool> {
|
|
match self.pending {
|
|
Some((want, at)) if at <= now => {
|
|
self.pending = None;
|
|
if self.shown != want {
|
|
self.shown = want;
|
|
Some(want)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn deadline(&self) -> Option<f64> {
|
|
self.pending.map(|(_, at)| at)
|
|
}
|
|
}
|
|
|
|
/// One line of text's drawn size.
|
|
fn measure(dt: &DrawText, cx: &mut Cx, text: &str) -> DVec2 {
|
|
if text.is_empty() {
|
|
return dvec2(0.0, 0.0);
|
|
}
|
|
let laidout = dt.layout(cx, 0.0, 0.0, None, false, Align::default(), text);
|
|
let scale = dt.font_scale as f64;
|
|
dvec2(laidout.size_in_lpxs.width as f64 * scale, laidout.size_in_lpxs.height as f64 * scale)
|
|
}
|
|
|
|
/// `text` cut to `max_w` with an ellipsis.
|
|
fn elide(dt: &DrawText, cx: &mut Cx, text: &str, max_w: f64) -> String {
|
|
if max_w <= 0.0 {
|
|
return String::new();
|
|
}
|
|
let full = measure(dt, cx, text).x;
|
|
if full <= max_w {
|
|
return text.to_string();
|
|
}
|
|
let chars: Vec<char> = text.chars().collect();
|
|
let mut keep = ((chars.len() as f64) * (max_w / full.max(1.0))).floor() as usize;
|
|
keep = keep.min(chars.len());
|
|
while keep > 0 {
|
|
let mut cut: String = chars[..keep].iter().collect();
|
|
cut.push('\u{2026}');
|
|
if measure(dt, cx, &cut).x <= max_w {
|
|
return cut;
|
|
}
|
|
keep -= 1;
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the widget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum LinkedKind {
|
|
View,
|
|
Shadow,
|
|
}
|
|
|
|
/// The view the menu follows, found once and kept.
|
|
#[derive(Clone)]
|
|
struct Linked {
|
|
widget: WidgetRef,
|
|
kind: LinkedKind,
|
|
/// Each section's target inside the view, in section order.
|
|
targets: Vec<WidgetRef>,
|
|
}
|
|
|
|
/// The box a followed view shows, window-absolute, from its area's rect.
|
|
///
|
|
/// A scrolling `View` records its area from its own scrolled turtle, which
|
|
/// is the box. A `ScrollShadowView` answers with its inner view's area, and
|
|
/// that view is drawn at full height inside the scroll: its rect is all of
|
|
/// the content, moved up by the offset. Headings measured against that never
|
|
/// move as the page scrolls, and a jump from anywhere but the top overshoots
|
|
/// by the offset, so the box is put back together from the extent. An axis
|
|
/// that does not scroll reads zero there and keeps the area's own size.
|
|
fn shown_box(kind: LinkedKind, area: Rect, extent: ScrollExtent) -> Rect {
|
|
match kind {
|
|
LinkedKind::View => area,
|
|
LinkedKind::Shadow => Rect {
|
|
pos: area.pos + extent.pos,
|
|
size: dvec2(
|
|
if extent.visible.x > 0.0 { extent.visible.x } else { area.size.x },
|
|
if extent.visible.y > 0.0 { extent.visible.y } else { area.size.y },
|
|
),
|
|
},
|
|
}
|
|
}
|
|
|
|
impl Linked {
|
|
fn extent(&self) -> Option<ScrollExtent> {
|
|
// A widget mid-dispatch cannot be asked anything.
|
|
self.widget.try_widget_uid()?;
|
|
match self.kind {
|
|
LinkedKind::View => self.widget.borrow::<View>()?.scroll_extent(),
|
|
LinkedKind::Shadow => Some(self.widget.borrow::<ScrollShadowView>()?.scroll_extent()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A section lit by a jump, held until the page is scrolled again.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Pin {
|
|
index: usize,
|
|
landed: f64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct JumpAnim {
|
|
from: f64,
|
|
to: f64,
|
|
start: f64,
|
|
secs: f64,
|
|
/// Taken when the jump starts, so a curve changed mid-jump does not bend
|
|
/// the one under way.
|
|
ease: Ease,
|
|
/// The furthest the view could scroll when the jump started.
|
|
furthest: f64,
|
|
}
|
|
|
|
/// What a watch frame compares to know whether anything moved.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Probe {
|
|
pos: f64,
|
|
total: f64,
|
|
visible: f64,
|
|
viewport: Rect,
|
|
first_top: Option<f64>,
|
|
}
|
|
|
|
/// Who showed the names, which decides whether Escape may take them away.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
enum RevealedBy {
|
|
#[default]
|
|
Pointer,
|
|
Keys,
|
|
Touch,
|
|
}
|
|
|
|
#[derive(Script, Widget)]
|
|
pub struct LineMenu {
|
|
#[uid]
|
|
uid: WidgetUid,
|
|
#[source]
|
|
source: ScriptObjectRef,
|
|
#[walk]
|
|
walk: Walk,
|
|
#[layout]
|
|
layout: Layout,
|
|
|
|
/// The menu's own rect, painted as nothing: the lines are drawn in Rust,
|
|
/// so without it the menu has no rect to focus, redraw or be found by.
|
|
#[redraw]
|
|
#[live]
|
|
draw_bg: DrawNavGround,
|
|
#[live]
|
|
draw_line: DrawLineMenuLine,
|
|
#[live]
|
|
draw_card: DrawMorphSurface,
|
|
#[live]
|
|
draw_row: DrawMorphSurface,
|
|
#[live]
|
|
draw_focus: DrawMorphSurface,
|
|
#[live]
|
|
draw_label: DrawLineMenuText,
|
|
#[live]
|
|
draw_label_current: DrawLineMenuText,
|
|
|
|
#[live]
|
|
pub scroll_view: String,
|
|
/// The sections as written in the DSL, parsed when the raw value changes.
|
|
#[live]
|
|
sections: ScriptValue,
|
|
#[live(3.0)]
|
|
pub max_level: f64,
|
|
#[live(true)]
|
|
pub follow_scroll: bool,
|
|
#[live(false)]
|
|
pub always_show_labels: bool,
|
|
#[live(false)]
|
|
pub mirror: bool,
|
|
#[live(18.0)]
|
|
pub row_height: f64,
|
|
#[live(2.0)]
|
|
pub line_thickness: f64,
|
|
#[live(20.0)]
|
|
pub line_length: f64,
|
|
#[live(5.0)]
|
|
pub line_step: f64,
|
|
#[live(6.0)]
|
|
pub line_min: f64,
|
|
#[live(10.0)]
|
|
pub hover_extra: f64,
|
|
#[live(4.0)]
|
|
pub current_extra: f64,
|
|
#[live(10.0)]
|
|
pub label_gap: f64,
|
|
#[live(220.0)]
|
|
pub label_max_width: f64,
|
|
#[live(8.0)]
|
|
pub card_pad: f64,
|
|
#[live(8.0)]
|
|
pub card_radius: f64,
|
|
#[live(0.25)]
|
|
pub spy_line: f64,
|
|
#[live(12.0)]
|
|
pub scroll_margin: f64,
|
|
#[live(0.06)]
|
|
pub reveal_delay: f64,
|
|
#[live(0.2)]
|
|
pub conceal_delay: f64,
|
|
#[live(0.2)]
|
|
pub reveal_secs: f64,
|
|
#[live(Ease::Bezier { cp0: 0.05, cp1: 0.7, cp2: 0.1, cp3: 1.0 })]
|
|
pub reveal_ease: Ease,
|
|
#[live(0.15)]
|
|
pub conceal_secs: f64,
|
|
#[live(Ease::Bezier { cp0: 0.3, cp1: 0.0, cp2: 1.0, cp3: 1.0 })]
|
|
pub conceal_ease: Ease,
|
|
#[live(0.4)]
|
|
pub jump_secs: f64,
|
|
#[live(Ease::Bezier { cp0: 0.2, cp1: 0.0, cp2: 0.0, cp3: 1.0 })]
|
|
pub jump_ease: Ease,
|
|
#[live(false)]
|
|
pub reduced_motion: bool,
|
|
#[live(0.38)]
|
|
pub disabled_opacity: f32,
|
|
#[live(true)]
|
|
#[visible]
|
|
visible: bool,
|
|
|
|
#[rust]
|
|
defs: Vec<LineSection>,
|
|
#[rust]
|
|
sections_source: ScriptValue,
|
|
#[rust]
|
|
linked: Option<Linked>,
|
|
/// The path the cached link was looked up for.
|
|
#[rust]
|
|
linked_path: Option<String>,
|
|
/// Each section's heading top in the viewport, from the last watch.
|
|
#[rust]
|
|
tops: Vec<Option<f64>>,
|
|
/// Sections whose target is not in the linked view or not drawn.
|
|
#[rust]
|
|
missing: Vec<bool>,
|
|
#[rust]
|
|
lit: Option<usize>,
|
|
#[rust]
|
|
pin: Option<Pin>,
|
|
#[rust]
|
|
jump: Option<JumpAnim>,
|
|
#[rust]
|
|
probe: Option<Probe>,
|
|
#[rust]
|
|
quiet: u8,
|
|
#[rust]
|
|
watch: NextFrame,
|
|
#[rust]
|
|
last_time: f64,
|
|
#[rust]
|
|
reveal_state: Reveal,
|
|
#[rust]
|
|
revealed_by: RevealedBy,
|
|
/// 0 collapsed, 1 names fully showing, and past either end while an
|
|
/// overshooting curve carries it.
|
|
#[rust]
|
|
reveal: UnitTween,
|
|
#[rust]
|
|
timer: Timer,
|
|
#[rust]
|
|
timer_deadline: f64,
|
|
/// Row positions, not section indices.
|
|
#[rust]
|
|
hot_row: Option<usize>,
|
|
#[rust]
|
|
key_row: Option<usize>,
|
|
#[rust]
|
|
keyboard_mode: bool,
|
|
#[rust]
|
|
focused: bool,
|
|
#[rust]
|
|
focus_by_press: bool,
|
|
#[rust]
|
|
bg_hovered: bool,
|
|
#[rust]
|
|
pointer_over: bool,
|
|
#[rust]
|
|
locked: bool,
|
|
/// The stack's rect as the event side reads it.
|
|
#[rust]
|
|
rect: Rect,
|
|
#[rust]
|
|
drawn_card: Option<CardLayout>,
|
|
/// The pass the card was last drawn over, which is all that cuts it.
|
|
#[rust]
|
|
overlay_bounds: Rect,
|
|
#[rust]
|
|
overlay: Option<DrawList2d>,
|
|
}
|
|
|
|
impl ScriptHook for LineMenu {
|
|
fn on_after_new(&mut self, vm: &mut ScriptVm) {
|
|
self.overlay = Some(DrawList2d::script_new(vm));
|
|
}
|
|
|
|
fn on_after_apply(&mut self, vm: &mut ScriptVm, _apply: &Apply, _scope: &mut Scope, _value: ScriptValue) {
|
|
let parsed = if self.sections.raw() != self.sections_source.raw() {
|
|
self.sections_source = self.sections;
|
|
Some(parse_sections(vm, self.sections))
|
|
} else {
|
|
None
|
|
};
|
|
let cx = vm.cx_mut();
|
|
if let Some(sections) = parsed {
|
|
self.set_sections(cx, sections);
|
|
}
|
|
if self.linked_path.as_deref() != Some(self.scroll_view.as_str()) {
|
|
self.linked = None;
|
|
self.linked_path = None;
|
|
}
|
|
if self.always_show_labels {
|
|
self.reveal = UnitTween::at(1.0);
|
|
}
|
|
self.quiet = 0;
|
|
self.watch = cx.new_next_frame();
|
|
self.repaint(cx);
|
|
}
|
|
}
|
|
|
|
impl LineMenu {
|
|
// -- reading ----------------------------------------------------------
|
|
|
|
fn line_metrics(&self) -> LineMetrics {
|
|
LineMetrics {
|
|
line_length: self.line_length,
|
|
line_step: self.line_step,
|
|
line_min: self.line_min,
|
|
hover_extra: self.hover_extra,
|
|
current_extra: self.current_extra,
|
|
}
|
|
}
|
|
|
|
fn max_level_u8(&self) -> u8 {
|
|
self.max_level.round().clamp(1.0, 6.0) as u8
|
|
}
|
|
|
|
fn levels(&self) -> Vec<u8> {
|
|
self.defs.iter().map(|s| s.level).collect()
|
|
}
|
|
|
|
/// The sections that get a row, in order.
|
|
fn rows(&self) -> Vec<usize> {
|
|
let max = self.max_level_u8();
|
|
(0..self.defs.len()).filter(|i| self.defs[*i].level <= max).collect()
|
|
}
|
|
|
|
|
|
fn section_enabled(&self, index: usize) -> bool {
|
|
!(self.is_linked() && self.missing.get(index).copied().unwrap_or(true))
|
|
}
|
|
|
|
/// The section whose row is lit: the lit one, or its shown parent.
|
|
fn lit_row_section(&self) -> Option<usize> {
|
|
shown_row(&self.levels(), self.max_level_u8(), self.lit?)
|
|
}
|
|
|
|
fn labeled(&self) -> bool {
|
|
self.always_show_labels
|
|
}
|
|
|
|
fn card_showing(&self) -> bool {
|
|
!self.labeled() && !self.reveal.is_at(0.0)
|
|
}
|
|
|
|
/// The row rects a pointer lands on right now: the card's rows while the
|
|
/// names show, else the stack's own rows.
|
|
fn row_rects(&self) -> Vec<Rect> {
|
|
let rows = self.rows();
|
|
let pad = self.layout.padding;
|
|
let (x, w) = match self.drawn_card.filter(|_| self.card_showing() && self.reveal_state.shown) {
|
|
Some(card) => (card.card.pos.x, card.card.size.x),
|
|
None => (self.rect.pos.x, self.rect.size.x),
|
|
};
|
|
(0..rows.len())
|
|
.map(|r| Rect {
|
|
pos: dvec2(x, self.rect.pos.y + pad.top + r as f64 * self.row_height),
|
|
size: dvec2(w, self.row_height),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The longest a line can ever be, revealed and lit, with room for a
|
|
/// curve that swings past fully revealed. The stack is that wide and the
|
|
/// names start past it, so no line is cut by the menu's own rect or runs
|
|
/// into the names mid-swing.
|
|
fn max_line(&self, rows: &[usize]) -> f64 {
|
|
let m = self.line_metrics();
|
|
let reach = if self.labeled() { 1.0 } else { reveal_reach(self.reveal_ease, self.conceal_ease) };
|
|
rows.iter().map(|i| line_length(self.defs[*i].level, &m, reach, true)).fold(0.0, f64::max)
|
|
}
|
|
|
|
fn row_at(&self, abs: DVec2) -> Option<usize> {
|
|
self.row_rects().iter().position(|r| r.contains(abs))
|
|
}
|
|
|
|
fn enabled_rows(&self) -> Vec<bool> {
|
|
self.rows().iter().map(|i| self.section_enabled(*i)).collect()
|
|
}
|
|
|
|
// -- motion -----------------------------------------------------------
|
|
|
|
fn repaint(&self, cx: &mut Cx) {
|
|
self.draw_bg.redraw(cx);
|
|
if let Some(list) = &self.overlay {
|
|
list.redraw(cx);
|
|
}
|
|
}
|
|
|
|
/// Read the stack's rect on the event side, where it is honest.
|
|
fn refresh_rect(&mut self, cx: &mut Cx) {
|
|
let rect = self.draw_bg.area().rect(cx);
|
|
if (rect.size.x > 0.0 || rect.size.y > 0.0) && rect != self.rect {
|
|
self.rect = rect;
|
|
// The card is placed from this rect, so it follows a page that
|
|
// moved the stack.
|
|
if self.card_showing() {
|
|
self.repaint(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn arm_watch(&mut self, cx: &mut Cx) {
|
|
self.quiet = 0;
|
|
self.watch = cx.new_next_frame();
|
|
}
|
|
|
|
fn arm_timer(&mut self, cx: &mut Cx) {
|
|
match self.reveal_state.deadline() {
|
|
Some(deadline) if deadline == self.timer_deadline && !self.timer.is_empty() => {}
|
|
Some(deadline) => {
|
|
cx.stop_timer(self.timer);
|
|
let now = cx.seconds_since_app_start();
|
|
self.timer = cx.start_timeout((deadline - now).max(0.001));
|
|
self.timer_deadline = deadline;
|
|
}
|
|
None => {
|
|
if !self.timer.is_empty() {
|
|
cx.stop_timer(self.timer);
|
|
self.timer = Timer::empty();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Animate the names toward what the reducer says, along the reveal's or
|
|
/// the conceal's curve. Answers whether they are still moving.
|
|
fn advance_reveal(&mut self, dt: f64) -> bool {
|
|
if self.labeled() {
|
|
self.reveal = UnitTween::at(1.0);
|
|
return false;
|
|
}
|
|
let (goal, secs, ease) = if self.reveal_state.shown {
|
|
(1.0, self.reveal_secs, self.reveal_ease)
|
|
} else {
|
|
(0.0, self.conceal_secs, self.conceal_ease)
|
|
};
|
|
// Aimed on every frame: names put away mid-reveal, or called back
|
|
// mid-conceal, turn back from where they are.
|
|
self.reveal.aim(goal, secs, ease);
|
|
if self.reduced_motion {
|
|
self.reveal.settle();
|
|
return false;
|
|
}
|
|
self.reveal.step(dt)
|
|
}
|
|
|
|
fn apply_reveal(&mut self, cx: &mut Cx, change: Option<bool>, by: RevealedBy) {
|
|
match change {
|
|
Some(true) => {
|
|
self.revealed_by = by;
|
|
// A card over the page takes the pointer, or a press on a
|
|
// name would also land on the words under it. Keys involve no
|
|
// pointer, so a keyboard reveal takes nothing.
|
|
if by != RevealedBy::Keys && !self.locked {
|
|
let area = self.draw_bg.area();
|
|
if !area.is_empty() {
|
|
// A lock a dropped overlay left behind goes first.
|
|
// The window does this on every event; this is for a
|
|
// tree with no window above it, where that lock would
|
|
// otherwise sit under this one and outlive it.
|
|
release_orphaned_sweep_locks(cx);
|
|
self.locked = true;
|
|
cx.sweep_lock(area);
|
|
}
|
|
}
|
|
self.arm_watch(cx);
|
|
self.repaint(cx);
|
|
}
|
|
Some(false) => {
|
|
self.unlock(cx);
|
|
self.hot_row = None;
|
|
self.arm_watch(cx);
|
|
self.repaint(cx);
|
|
}
|
|
None => {}
|
|
}
|
|
self.arm_timer(cx);
|
|
}
|
|
|
|
fn unlock(&mut self, cx: &mut Cx) {
|
|
if self.locked {
|
|
self.locked = false;
|
|
cx.sweep_unlock(self.draw_bg.area());
|
|
}
|
|
}
|
|
|
|
fn hide(&mut self, cx: &mut Cx) {
|
|
if self.reveal_state.shown {
|
|
self.reveal_state.shown = false;
|
|
self.reveal_state.pending = None;
|
|
self.apply_reveal(cx, Some(false), self.revealed_by);
|
|
}
|
|
}
|
|
|
|
// -- the linked view ----------------------------------------------------
|
|
|
|
fn resolve_linked(&mut self, cx: &Cx) {
|
|
if let Some(linked) = &self.linked {
|
|
if !linked.widget.is_empty() && self.linked_path.as_deref() == Some(self.scroll_view.as_str()) {
|
|
if linked.targets.len() == self.defs.len() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
self.linked = None;
|
|
let path = id_path(&self.scroll_view);
|
|
if path.is_empty() {
|
|
return;
|
|
}
|
|
// Outward from the menu, as a tour step finds its target; the menu
|
|
// has no children of its own to hand the tree.
|
|
let found = cx.widget_tree().find_flood_from_borrowed(self.uid, &path, |_| {});
|
|
if found.is_empty() || found.try_widget_uid().is_none() {
|
|
return;
|
|
}
|
|
let kind = if found.borrow::<View>().map_or(false, |view| view.scroll_extent().is_some()) {
|
|
LinkedKind::View
|
|
} else if found.borrow::<ScrollShadowView>().is_some() {
|
|
LinkedKind::Shadow
|
|
} else {
|
|
return;
|
|
};
|
|
let targets = self.defs.iter().map(|s| found.widget(cx, &id_path(&s.target))).collect();
|
|
self.linked = Some(Linked { widget: found, kind, targets });
|
|
self.linked_path = Some(self.scroll_view.clone());
|
|
}
|
|
|
|
/// Read where the view is and where each heading sits in it.
|
|
fn read_geometry(&mut self, cx: &Cx) -> Option<(Probe, ScrollExtent)> {
|
|
self.resolve_linked(cx);
|
|
let n = self.defs.len();
|
|
let Some(linked) = &self.linked else {
|
|
self.tops = vec![None; n];
|
|
self.missing = vec![false; n];
|
|
return None;
|
|
};
|
|
let extent = linked.extent()?;
|
|
let viewport = shown_box(linked.kind, askable_area(&linked.widget)?.rect(cx), extent);
|
|
let mut tops = Vec::with_capacity(n);
|
|
for target in &linked.targets {
|
|
let rect = if target.is_empty() { None } else { askable_area(target).map(|area| area.rect(cx)) };
|
|
tops.push(rect.filter(|r| r.size.x > 0.0 && r.size.y > 0.0).map(|r| r.pos.y - viewport.pos.y));
|
|
}
|
|
self.missing = tops.iter().map(|t| t.is_none()).collect();
|
|
let first_top = tops.iter().flatten().next().copied();
|
|
self.tops = tops;
|
|
Some((
|
|
Probe { pos: extent.pos.y, total: extent.total.y, visible: extent.visible.y, viewport, first_top },
|
|
extent,
|
|
))
|
|
}
|
|
|
|
fn scroll_linked(&mut self, cx: &mut Cx, y: f64) {
|
|
let Some(linked) = self.linked.clone() else {
|
|
return;
|
|
};
|
|
let Some(extent) = linked.extent() else {
|
|
return;
|
|
};
|
|
let to = dvec2(extent.pos.x, y);
|
|
match linked.kind {
|
|
LinkedKind::View => {
|
|
linked.widget.set_scroll_pos(cx, to);
|
|
cx.redraw_area_and_children(linked.widget.area());
|
|
}
|
|
LinkedKind::Shadow => {
|
|
if let Some(mut shadow) = linked.widget.borrow_mut::<ScrollShadowView>() {
|
|
shadow.set_scroll(cx, to);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_lit(&mut self, cx: &mut Cx, lit: Option<usize>) {
|
|
if lit == self.lit {
|
|
return;
|
|
}
|
|
self.lit = lit;
|
|
self.repaint(cx);
|
|
if let Some(section) = lit.and_then(|i| self.defs.get(i)) {
|
|
let uid = self.widget_uid();
|
|
cx.widget_action(uid, LineMenuAction::Changed(section.id));
|
|
}
|
|
}
|
|
|
|
fn watch_frame(&mut self, cx: &mut Cx, time: f64) {
|
|
let dt = if self.last_time > 0.0 { (time - self.last_time).clamp(0.0, 0.1) } else { 0.0 };
|
|
let before = self.reveal;
|
|
let revealing = self.advance_reveal(dt);
|
|
// Only what changed is redrawn. A watch that redrew every frame
|
|
// would wake itself: the redraw is an event, and every event arms it.
|
|
if self.reveal != before {
|
|
self.repaint(cx);
|
|
}
|
|
self.refresh_rect(cx);
|
|
|
|
// The jump moves the view before it is read, so what is read is where
|
|
// it went.
|
|
if let Some(jump) = self.jump {
|
|
let now = cx.seconds_since_app_start();
|
|
let t = if jump.secs > 0.0 { (now - jump.start) / jump.secs } else { 1.0 };
|
|
let y = jump_offset(jump.from, jump.to, t, jump.ease, jump.furthest);
|
|
self.scroll_linked(cx, y);
|
|
if t >= 1.0 {
|
|
self.jump = None;
|
|
}
|
|
}
|
|
|
|
let missing_before = self.missing.clone();
|
|
let read = self.read_geometry(cx);
|
|
let probe = read.map(|(p, _)| p);
|
|
if probe != self.probe {
|
|
self.probe = probe;
|
|
self.quiet = 0;
|
|
} else {
|
|
self.quiet = self.quiet.saturating_add(1);
|
|
}
|
|
if self.missing != missing_before {
|
|
self.repaint(cx);
|
|
}
|
|
|
|
if let Some((probe, extent)) = read {
|
|
if let Some(pin) = &mut self.pin {
|
|
if self.jump.is_none() && pin_released(pin.landed, probe.pos) {
|
|
self.pin = None;
|
|
}
|
|
}
|
|
let lit = if let Some(pin) = self.pin {
|
|
Some(pin.index)
|
|
} else if self.follow_scroll {
|
|
let line = (probe.viewport.size.y * self.spy_line).max(self.scroll_margin + 1.0);
|
|
current_section(&self.tops, probe.viewport.size.y, line, extent.at_end_y(1.0))
|
|
} else {
|
|
self.lit
|
|
};
|
|
self.set_lit(cx, lit);
|
|
}
|
|
|
|
if self.quiet < QUIET_FRAMES || self.jump.is_some() || revealing {
|
|
self.last_time = time;
|
|
self.watch = cx.new_next_frame();
|
|
} else {
|
|
self.last_time = 0.0;
|
|
}
|
|
}
|
|
|
|
fn start_jump(&mut self, cx: &mut Cx, index: usize, user: bool) {
|
|
let Some(id) = self.defs.get(index).map(|s| s.id) else {
|
|
return;
|
|
};
|
|
if user {
|
|
let uid = self.widget_uid();
|
|
cx.widget_action(uid, LineMenuAction::Jumped(id));
|
|
}
|
|
let Some((_, extent)) = self.read_geometry(cx) else {
|
|
return;
|
|
};
|
|
let Some(top) = self.tops.get(index).copied().flatten() else {
|
|
return;
|
|
};
|
|
let from = extent.pos.y;
|
|
let to = jump_target(from, top, self.scroll_margin, extent.total.y, extent.visible.y);
|
|
self.pin = Some(Pin { index, landed: to });
|
|
// Lit on the press rather than at the end of the scroll.
|
|
self.set_lit(cx, Some(index));
|
|
if self.reduced_motion || self.jump_secs <= 0.0 || (to - from).abs() < 1.0 {
|
|
self.jump = None;
|
|
self.scroll_linked(cx, to);
|
|
} else {
|
|
let start = cx.seconds_since_app_start();
|
|
self.jump = Some(JumpAnim { from, to, start, secs: self.jump_secs, ease: self.jump_ease, furthest: extent.max().y });
|
|
}
|
|
// Where the view actually stopped, which a view that clamps its own
|
|
// offset may make a hair different from where it was sent.
|
|
if let Some(landed) = self.linked.as_ref().and_then(|l| l.extent()).map(|e| e.pos.y) {
|
|
if self.jump.is_none() {
|
|
if let Some(pin) = &mut self.pin {
|
|
pin.landed = landed;
|
|
}
|
|
}
|
|
}
|
|
self.arm_watch(cx);
|
|
}
|
|
|
|
// -- the public surface -------------------------------------------------
|
|
|
|
/// Replace the sections. The lit section is kept by id when it is still
|
|
/// there; the pin and any jump are dropped.
|
|
pub fn set_sections(&mut self, cx: &mut Cx, sections: Vec<LineSection>) {
|
|
let lit_id = self.lit.and_then(|i| self.defs.get(i)).map(|s| s.id);
|
|
self.defs = sections;
|
|
self.lit = lit_id.and_then(|id| self.defs.iter().position(|s| s.id == id));
|
|
self.linked = None;
|
|
self.tops = vec![None; self.defs.len()];
|
|
self.missing = vec![false; self.defs.len()];
|
|
self.pin = None;
|
|
self.jump = None;
|
|
self.probe = None;
|
|
self.hot_row = None;
|
|
self.key_row = None;
|
|
self.arm_watch(cx);
|
|
self.repaint(cx);
|
|
}
|
|
|
|
pub fn sections(&self) -> &[LineSection] {
|
|
&self.defs
|
|
}
|
|
|
|
pub fn set_scroll_view(&mut self, cx: &mut Cx, path: &str) {
|
|
self.scroll_view = path.to_string();
|
|
self.linked = None;
|
|
self.linked_path = None;
|
|
self.pin = None;
|
|
self.jump = None;
|
|
self.arm_watch(cx);
|
|
}
|
|
|
|
pub fn current(&self) -> Option<LiveId> {
|
|
self.lit.and_then(|i| self.defs.get(i)).map(|s| s.id)
|
|
}
|
|
|
|
/// Light a section as a jump would, and hold it until the view has been
|
|
/// scrolled by more than 2 pt. Raises Changed.
|
|
pub fn set_current(&mut self, cx: &mut Cx, id: LiveId) {
|
|
let Some(index) = self.defs.iter().position(|s| s.id == id) else {
|
|
return;
|
|
};
|
|
// Found first: asked before any watch frame has looked for the view,
|
|
// the pin would hold against an offset of 0, and on a page already
|
|
// scrolled the first frame would let go of it at once.
|
|
self.read_geometry(cx);
|
|
let landed = self.linked.as_ref().and_then(|l| l.extent()).map_or(0.0, |e| e.pos.y);
|
|
self.pin = Some(Pin { index, landed });
|
|
self.set_lit(cx, Some(index));
|
|
self.arm_watch(cx);
|
|
}
|
|
|
|
/// Scroll the linked view to the section, as a press would, without
|
|
/// raising Jumped.
|
|
pub fn jump_to(&mut self, cx: &mut Cx, id: LiveId) {
|
|
if let Some(index) = self.defs.iter().position(|s| s.id == id) {
|
|
self.start_jump(cx, index, false);
|
|
}
|
|
}
|
|
|
|
/// Read the geometry again now, for a host that knows its content moved
|
|
/// without an event.
|
|
pub fn refresh(&mut self, cx: &mut Cx) {
|
|
self.linked = None;
|
|
self.arm_watch(cx);
|
|
}
|
|
|
|
/// Whether a view to follow was found.
|
|
pub fn is_linked(&self) -> bool {
|
|
self.linked.is_some()
|
|
}
|
|
|
|
// -- drawing ------------------------------------------------------------
|
|
|
|
fn draw_line_at(&mut self, cx: &mut Cx2d, x_left: f64, x_right: f64, mirror: bool, y: f64, len: f64, state: (f32, f32, f32, f32)) {
|
|
let x = if mirror { x_right - len } else { x_left };
|
|
self.draw_line.hot = state.0;
|
|
self.draw_line.current = state.1;
|
|
self.draw_line.ancestor = state.2;
|
|
self.draw_line.opacity = state.3;
|
|
self.draw_line.draw_abs(cx, Rect { pos: dvec2(x, y), size: dvec2(len.max(0.0), self.line_thickness) });
|
|
}
|
|
|
|
fn draw_overlay(&mut self, cx: &mut Cx2d, rows: &[usize], widest: f64) {
|
|
let Some(mut list) = self.overlay.take() else {
|
|
return;
|
|
};
|
|
// Begun on every draw, shown or not: a list that is not begun keeps
|
|
// showing what it showed last.
|
|
list.begin_overlay_reuse(cx);
|
|
let pass = cx.current_pass_size();
|
|
self.overlay_bounds = Rect { pos: dvec2(0.0, 0.0), size: pass };
|
|
cx.begin_root_turtle(pass, Layout::flow_down());
|
|
if self.card_showing() && !rows.is_empty() && self.rect.size.y > 0.0 {
|
|
self.draw_card_now(cx, pass, rows, widest);
|
|
} else {
|
|
self.drawn_card = None;
|
|
}
|
|
cx.end_pass_sized_turtle();
|
|
list.end(cx);
|
|
self.overlay = Some(list);
|
|
}
|
|
|
|
fn draw_card_now(&mut self, cx: &mut Cx2d, pass: DVec2, rows: &[usize], widest: f64) {
|
|
let m = self.line_metrics();
|
|
let pad = self.layout.padding;
|
|
let stack = self.rect;
|
|
let eased = self.reveal.value();
|
|
let levels = self.levels();
|
|
let lit = self.lit_row_section();
|
|
let lit_ancestors = lit.map(|l| ancestors(&levels, l)).unwrap_or_default();
|
|
let max_line = self.max_line(rows);
|
|
let x_left = stack.pos.x + pad.left;
|
|
let x_right = stack.pos.x + stack.size.x - pad.right;
|
|
let (lines_start, lines_end) = if self.mirror { (x_right - max_line, x_right) } else { (x_left, x_left + max_line) };
|
|
let card = card_layout(
|
|
stack,
|
|
lines_start,
|
|
lines_end,
|
|
widest,
|
|
&CardMetrics { label_gap: self.label_gap, card_pad: self.card_pad, label_max_width: self.label_max_width, min_label: MIN_LABEL },
|
|
Rect { pos: dvec2(EDGE, EDGE), size: pass - dvec2(EDGE * 2.0, EDGE * 2.0) },
|
|
self.mirror,
|
|
);
|
|
self.drawn_card = Some(card);
|
|
// Read off the furthest the reveal has come, so a bounce that dips
|
|
// back does not dim names it has already shown.
|
|
let alpha = smootherstep((self.reveal.reached() - 0.3) / 0.7) as f32;
|
|
|
|
self.draw_card.radius = self.card_radius as f32;
|
|
self.draw_card.morph = 1.0;
|
|
self.draw_card.opacity = alpha;
|
|
self.draw_card.draw_abs(cx, card.card);
|
|
|
|
let key = if self.keyboard_mode && self.focused { self.key_row } else { None };
|
|
for (r, §ion) in rows.iter().enumerate() {
|
|
let y = stack.pos.y + pad.top + r as f64 * self.row_height;
|
|
let band = Rect { pos: dvec2(card.card.pos.x, y), size: dvec2(card.card.size.x, self.row_height) };
|
|
let enabled = self.section_enabled(section);
|
|
let hot = enabled && self.hot_row == Some(r);
|
|
if hot || key == Some(r) {
|
|
self.draw_row.radius = (self.row_height * 0.25).max(2.0) as f32;
|
|
self.draw_row.morph = 1.0;
|
|
self.draw_row.opacity = alpha;
|
|
self.draw_row.draw_abs(cx, band.add_margin(dvec2(-self.card_pad * 0.5, 0.0)));
|
|
}
|
|
// The line again, over the card, exactly where the stack drew it,
|
|
// so nothing ghosts through while the card fades.
|
|
let is_lit = lit == Some(section);
|
|
let len = line_length(levels[section], &m, eased, is_lit).min(max_line);
|
|
let dim = if enabled { 1.0 } else { self.disabled_opacity };
|
|
let state = (
|
|
if hot || key == Some(r) { 1.0 } else { 0.0 },
|
|
if is_lit { 1.0 } else { 0.0 },
|
|
if lit_ancestors.contains(§ion) { 1.0 } else { 0.0 },
|
|
dim,
|
|
);
|
|
let line_y = y + (self.row_height - self.line_thickness) * 0.5;
|
|
self.draw_line_at(cx, x_left, x_right, self.mirror, line_y, len, state);
|
|
|
|
let label = self.defs[section].label.clone();
|
|
let on_surface = self.draw_label_current.color;
|
|
let draw = if is_lit { &mut self.draw_label_current } else { &mut self.draw_label };
|
|
let rest = draw.color;
|
|
if hot && !is_lit {
|
|
// The hot name takes the lit name's ink for this one draw.
|
|
draw.color = on_surface;
|
|
}
|
|
draw.opacity = alpha * dim;
|
|
let text = elide(draw, cx, &label, card.label_w);
|
|
let size = measure(draw, cx, &text);
|
|
let x = if card.labels_before { card.label_x + card.label_w - size.x } else { card.label_x };
|
|
draw.draw_abs(cx, dvec2(x, y + (self.row_height - size.y) * 0.5), &text);
|
|
draw.color = rest;
|
|
|
|
if key == Some(r) && self.reveal.is_at(1.0) {
|
|
self.draw_focus.radius = ((self.row_height * 0.25).max(2.0) + RING_OUT) as f32;
|
|
self.draw_focus.opacity = 1.0;
|
|
self.draw_focus.draw_abs(cx, band.add_margin(dvec2(RING_OUT - self.card_pad * 0.5, RING_OUT)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- events -------------------------------------------------------------
|
|
|
|
fn handle_key(&mut self, cx: &mut Cx, ke: &KeyEvent) {
|
|
let enabled = self.enabled_rows();
|
|
let first = enabled.iter().position(|e| *e);
|
|
let last = enabled.iter().rposition(|e| *e);
|
|
// Keys that start from nowhere start from the lit row: focus given by
|
|
// a press sets no row, and the first Down should be the row after the
|
|
// one being read, not the top of the list.
|
|
let rows = self.rows();
|
|
let lit_row = self
|
|
.lit_row_section()
|
|
.and_then(|s| rows.iter().position(|r| *r == s))
|
|
.filter(|r| enabled.get(*r).copied().unwrap_or(false));
|
|
let from = self.key_row.filter(|r| *r < enabled.len()).or(lit_row);
|
|
let moved = match ke.key_code {
|
|
// Clamped, not wrapped: a table of contents has a top and a
|
|
// bottom, and leaping from the end to the start is a surprise.
|
|
KeyCode::ArrowDown => Some(match from {
|
|
Some(r) => enabled[r + 1..].iter().position(|e| *e).map(|k| r + 1 + k).or(Some(r)),
|
|
None => first,
|
|
}),
|
|
KeyCode::ArrowUp => Some(match from {
|
|
Some(r) => enabled[..r].iter().rposition(|e| *e).or(Some(r)),
|
|
None => last,
|
|
}),
|
|
KeyCode::Home => Some(first),
|
|
KeyCode::End => Some(last),
|
|
KeyCode::ReturnKey | KeyCode::Space => {
|
|
if let Some(section) = from.and_then(|r| rows.get(r).copied()) {
|
|
if self.section_enabled(section) {
|
|
self.start_jump(cx, section, true);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
_ => return,
|
|
};
|
|
let was = self.keyboard_mode;
|
|
self.keyboard_mode = true;
|
|
if !was {
|
|
let change = self.reveal_state.focus(true, self.pointer_over);
|
|
self.apply_reveal(cx, change, RevealedBy::Keys);
|
|
}
|
|
if let Some(to) = moved {
|
|
self.key_row = to;
|
|
}
|
|
self.arm_watch(cx);
|
|
self.repaint(cx);
|
|
}
|
|
|
|
fn pointer_moved(&mut self, cx: &mut Cx, abs: Option<DVec2>) {
|
|
if !(self.bg_hovered || self.reveal_state.shown || self.reveal_state.pending.is_some()) {
|
|
return;
|
|
}
|
|
// A pointer another control holds is not this menu's to read. From
|
|
// the press until the release the interaction is locked to whatever
|
|
// took it, and a row lighting up — or a card of names unfolding —
|
|
// under a hand that is dragging a scroll bar somewhere else is
|
|
// exactly the hover the rule forbids. This is read off the raw move,
|
|
// which never learns of that press the way `hits` does.
|
|
//
|
|
// Held still rather than cleared: the drag ends, the next move finds
|
|
// the pointer where it is and the menu catches up. Clearing would
|
|
// fold the names away mid-drag, which is the same flinch seen from
|
|
// the other side.
|
|
let mine = [self.draw_bg.area(), self.draw_card.area()];
|
|
if cx.fingers.is_mouse_held_outside(&mine) {
|
|
return;
|
|
}
|
|
let over = abs.map_or(false, |abs| {
|
|
(self.bg_hovered && self.rect.contains(abs))
|
|
|| (self.reveal_state.shown && self.drawn_card.map_or(false, |card| card.card.contains(abs)))
|
|
});
|
|
self.pointer_over = over;
|
|
let hot = if over { abs.and_then(|abs| self.row_at(abs)) } else { None };
|
|
let hot = hot.filter(|r| self.rows().get(*r).map_or(false, |s| self.section_enabled(*s)));
|
|
if over && self.reveal_state.shown {
|
|
cx.set_cursor(if hot.is_some() { MouseCursor::Hand } else { MouseCursor::Default });
|
|
}
|
|
if hot != self.hot_row {
|
|
self.hot_row = hot;
|
|
self.repaint(cx);
|
|
}
|
|
if self.labeled() {
|
|
return;
|
|
}
|
|
// Keys that showed the names keep them; the pointer leaving is no
|
|
// reason to take them from someone reading with the keys.
|
|
if self.keyboard_mode && self.focused {
|
|
return;
|
|
}
|
|
let now = cx.seconds_since_app_start();
|
|
let change = self.reveal_state.pointer(over, now, self.reveal_delay, self.conceal_delay);
|
|
self.apply_reveal(cx, change, RevealedBy::Pointer);
|
|
}
|
|
|
|
fn press_row(&mut self, cx: &mut Cx, row: usize, touch: bool) {
|
|
let Some(section) = self.rows().get(row).copied() else {
|
|
return;
|
|
};
|
|
if !self.section_enabled(section) {
|
|
return;
|
|
}
|
|
self.start_jump(cx, section, true);
|
|
// A touch jump puts the names away; a pointer jump leaves them while
|
|
// the pointer is still over them, so the reader can go straight on.
|
|
if touch && !self.labeled() {
|
|
self.hide(cx);
|
|
}
|
|
}
|
|
|
|
fn handle_card_hits(&mut self, cx: &mut Cx, event: &Event) {
|
|
if !(self.card_showing() && self.reveal_state.shown) {
|
|
return;
|
|
}
|
|
match event.hits_with_sweep_area(cx, self.draw_card.area(), self.draw_bg.area()) {
|
|
Hit::FingerDown(fe) if fe.is_primary_hit() => {
|
|
self.focus_by_press = !self.focused;
|
|
cx.set_key_focus(self.draw_bg.area());
|
|
self.keyboard_mode = false;
|
|
}
|
|
Hit::FingerUp(fe) if fe.is_primary_hit() && fe.is_over && fe.was_tap() => {
|
|
if let Some(row) = self.row_at(fe.abs) {
|
|
self.press_row(cx, row, fe.device.is_touch());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_bg_hits(&mut self, cx: &mut Cx, event: &Event) {
|
|
// Its own sweep area, so the lock this menu takes does not turn its
|
|
// own stack away.
|
|
let bg = self.draw_bg.area();
|
|
match event.hits_with_sweep_area(cx, bg, bg) {
|
|
Hit::KeyFocus(_) => {
|
|
self.focused = true;
|
|
if !self.focus_by_press {
|
|
// Arrived by Tab: show the names and stand on the lit row.
|
|
self.keyboard_mode = true;
|
|
let rows = self.rows();
|
|
let enabled = self.enabled_rows();
|
|
self.key_row = self
|
|
.lit_row_section()
|
|
.and_then(|s| rows.iter().position(|r| *r == s))
|
|
.filter(|r| enabled.get(*r).copied().unwrap_or(false))
|
|
.or_else(|| enabled.iter().position(|e| *e));
|
|
let change = self.reveal_state.focus(true, self.pointer_over);
|
|
self.apply_reveal(cx, change, RevealedBy::Keys);
|
|
}
|
|
self.focus_by_press = false;
|
|
self.repaint(cx);
|
|
}
|
|
Hit::KeyFocusLost(_) => {
|
|
self.focused = false;
|
|
self.keyboard_mode = false;
|
|
if !self.labeled() {
|
|
let change = self.reveal_state.focus(false, self.pointer_over);
|
|
self.apply_reveal(cx, change, self.revealed_by);
|
|
}
|
|
self.repaint(cx);
|
|
}
|
|
Hit::KeyDown(ke) => self.handle_key(cx, &ke),
|
|
Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => {
|
|
self.bg_hovered = true;
|
|
}
|
|
Hit::FingerHoverOut(_) => {
|
|
self.bg_hovered = false;
|
|
}
|
|
Hit::FingerDown(fe) if fe.is_primary_hit() => {
|
|
self.focus_by_press = !self.focused;
|
|
cx.set_key_focus(self.draw_bg.area());
|
|
self.keyboard_mode = false;
|
|
}
|
|
Hit::FingerUp(fe) if fe.is_primary_hit() && fe.is_over && fe.was_tap() => {
|
|
let touch = fe.device.is_touch();
|
|
if touch && !self.labeled() && !self.reveal_state.shown {
|
|
// A tap has no hover to rest with: the first one shows the
|
|
// names, and the row is chosen on the card.
|
|
let change = self.reveal_state.touch_toggle();
|
|
self.apply_reveal(cx, change, RevealedBy::Touch);
|
|
} else if let Some(row) = self.row_at(fe.abs) {
|
|
self.press_row(cx, row, touch);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn inside(&self, abs: DVec2) -> bool {
|
|
self.rect.contains(abs) || self.drawn_card.map_or(false, |card| card.card.contains(abs))
|
|
}
|
|
|
|
fn handle_raw(&mut self, cx: &mut Cx, event: &Event) {
|
|
// A jump hands the scroll back to the reader the moment they reach
|
|
// for the page.
|
|
if self.jump.is_some() {
|
|
let viewport = self.probe.map(|p| p.viewport).unwrap_or_default();
|
|
let cancel = match event {
|
|
Event::Scroll(se) => viewport.contains(se.abs),
|
|
Event::MouseDown(me) => viewport.contains(me.abs),
|
|
Event::TouchUpdate(te) => te.touches.iter().any(|t| t.state == TouchState::Start && viewport.contains(t.abs)),
|
|
_ => false,
|
|
};
|
|
if cancel {
|
|
self.jump = None;
|
|
self.pin = None;
|
|
self.arm_watch(cx);
|
|
}
|
|
}
|
|
match event {
|
|
Event::MouseMove(me) => {
|
|
if self.keyboard_mode {
|
|
self.keyboard_mode = false;
|
|
self.repaint(cx);
|
|
}
|
|
self.pointer_moved(cx, Some(me.abs));
|
|
}
|
|
Event::MouseLeave(_) | Event::ClearHover => self.pointer_moved(cx, None),
|
|
Event::MouseDown(me) if self.locked => {
|
|
if !self.inside(me.abs) {
|
|
if me.handled.get().is_empty() {
|
|
me.handled.set(self.draw_bg.area());
|
|
}
|
|
self.hide(cx);
|
|
}
|
|
}
|
|
Event::TouchUpdate(te) => {
|
|
if self.keyboard_mode {
|
|
self.keyboard_mode = false;
|
|
self.repaint(cx);
|
|
}
|
|
if self.locked
|
|
&& !te.touches.is_empty()
|
|
&& te.touches.iter().all(|t| t.state == TouchState::Start && !self.inside(t.abs))
|
|
{
|
|
self.hide(cx);
|
|
}
|
|
}
|
|
Event::KeyDown(ke) if ke.key_code == KeyCode::Escape && self.reveal_state.shown && !self.labeled() => {
|
|
// Only names the keys or a tap showed are the keys' to take
|
|
// away; hovered names go when the pointer does.
|
|
if matches!(self.revealed_by, RevealedBy::Keys | RevealedBy::Touch) {
|
|
let top = cx.sweep_lock_area();
|
|
let mine = top.is_none() || (self.locked && top == Some(self.draw_bg.area()));
|
|
if mine && claim_escape(cx) {
|
|
self.hide(cx);
|
|
}
|
|
}
|
|
}
|
|
Event::WindowLostFocus(_) => {
|
|
if self.locked {
|
|
self.hide(cx);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for LineMenu {
|
|
fn drop(&mut self) {
|
|
// Dropped holding the pointer, as when its page is swapped for
|
|
// another while the card is out: nothing else would ever let go of
|
|
// the lock, and every press in the window would go on being turned
|
|
// away. A drop has no `Cx`, so the lock is left for the next event to
|
|
// release.
|
|
if self.locked {
|
|
orphan_sweep_locks(&[self.draw_bg.area()]);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for LineMenu {
|
|
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
|
|
if !self.visible {
|
|
self.draw_overlay(cx, &[], 0.0);
|
|
return DrawStep::done();
|
|
}
|
|
let m = self.line_metrics();
|
|
let pad = self.layout.padding;
|
|
let rows = self.rows();
|
|
let levels = self.levels();
|
|
let max_line = self.max_line(&rows);
|
|
// Every name is measured in the bold face, so the column does not
|
|
// shift when the lit name, drawn bold, moves.
|
|
let labels: Vec<String> = rows.iter().map(|i| self.defs[*i].label.clone()).collect();
|
|
let widest = labels.iter().map(|label| measure(&self.draw_label_current, cx, label).x).fold(0.0, f64::max);
|
|
let labeled = self.labeled();
|
|
let label_w = widest.min(self.label_max_width);
|
|
let natural = dvec2(
|
|
pad.left + pad.right + max_line + if labeled { self.label_gap + label_w } else { 0.0 },
|
|
pad.top + pad.bottom + rows.len() as f64 * self.row_height,
|
|
);
|
|
let walk = Walk {
|
|
width: match walk.width {
|
|
Size::Fit { .. } => Size::Fixed(natural.x),
|
|
other => other,
|
|
},
|
|
height: match walk.height {
|
|
Size::Fit { .. } => Size::Fixed(natural.y),
|
|
other => other,
|
|
},
|
|
..walk
|
|
};
|
|
let rect = cx.walk_turtle(walk);
|
|
self.draw_bg.draw_abs(cx, rect);
|
|
if labeled {
|
|
self.reveal = UnitTween::at(1.0);
|
|
}
|
|
|
|
let lit = self.lit_row_section();
|
|
let lit_ancestors = lit.map(|l| ancestors(&levels, l)).unwrap_or_default();
|
|
let key = if self.keyboard_mode && self.focused { self.key_row } else { None };
|
|
let x_left = rect.pos.x + pad.left;
|
|
let x_right = rect.pos.x + rect.size.x - pad.right;
|
|
let eased = self.reveal.value();
|
|
for (r, §ion) in rows.iter().enumerate() {
|
|
let y = rect.pos.y + pad.top + r as f64 * self.row_height;
|
|
let enabled = self.section_enabled(section);
|
|
let hot = enabled && self.hot_row == Some(r);
|
|
let is_lit = lit == Some(section);
|
|
let band = Rect { pos: dvec2(rect.pos.x, y), size: dvec2(rect.size.x, self.row_height) };
|
|
if labeled && (hot || key == Some(r)) {
|
|
self.draw_row.radius = (self.row_height * 0.25).max(2.0) as f32;
|
|
self.draw_row.morph = 1.0;
|
|
self.draw_row.opacity = 1.0;
|
|
self.draw_row.draw_abs(cx, band);
|
|
}
|
|
let len = line_length(levels[section], &m, eased, is_lit).min(max_line);
|
|
let state = (
|
|
if hot || key == Some(r) { 1.0 } else { 0.0 },
|
|
if is_lit { 1.0 } else { 0.0 },
|
|
if lit_ancestors.contains(§ion) { 1.0 } else { 0.0 },
|
|
if enabled { 1.0 } else { self.disabled_opacity },
|
|
);
|
|
let line_y = y + (self.row_height - self.line_thickness) * 0.5;
|
|
self.draw_line_at(cx, x_left, x_right, self.mirror, line_y, len, state);
|
|
|
|
if labeled {
|
|
let label = labels[r].clone();
|
|
let on_surface = self.draw_label_current.color;
|
|
let dim = if enabled { 1.0 } else { self.disabled_opacity };
|
|
let draw = if is_lit { &mut self.draw_label_current } else { &mut self.draw_label };
|
|
let rest = draw.color;
|
|
if hot && !is_lit {
|
|
draw.color = on_surface;
|
|
}
|
|
draw.opacity = dim;
|
|
let text = elide(draw, cx, &label, label_w);
|
|
let size = measure(draw, cx, &text);
|
|
let x = if self.mirror { x_right - max_line - self.label_gap - size.x } else { x_left + max_line + self.label_gap };
|
|
draw.draw_abs(cx, dvec2(x, y + (self.row_height - size.y) * 0.5), &text);
|
|
draw.color = rest;
|
|
}
|
|
if key == Some(r) && !self.card_showing() {
|
|
self.draw_focus.radius = ((self.row_height * 0.25).max(2.0) + RING_OUT) as f32;
|
|
self.draw_focus.opacity = 1.0;
|
|
self.draw_focus.draw_abs(cx, band.add_margin(dvec2(0.0, RING_OUT)));
|
|
}
|
|
}
|
|
|
|
cx.add_nav_stop(self.draw_bg.area(), NavRole::TextInput, Inset::default());
|
|
if self.locked {
|
|
cx.sweep_lock(self.draw_bg.area());
|
|
}
|
|
// The first draw arms the watch, so a page opened part way down lights
|
|
// the right section without waiting for the reader to move.
|
|
if self.probe.is_none() && !self.defs.is_empty() {
|
|
self.watch = cx.new_next_frame();
|
|
}
|
|
self.draw_overlay(cx, &rows, widest);
|
|
DrawStep::done()
|
|
}
|
|
|
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
|
|
if let Some(ne) = self.watch.is_event(event) {
|
|
self.watch_frame(cx, ne.time);
|
|
}
|
|
if self.timer.is_event(event).is_some() {
|
|
self.timer = Timer::empty();
|
|
let now = cx.seconds_since_app_start();
|
|
let change = self.reveal_state.tick(now);
|
|
self.apply_reveal(cx, change, RevealedBy::Pointer);
|
|
}
|
|
// Scrolling, dragging, flinging, resizing and a host's own
|
|
// `set_scroll_pos` all arrive with or after an event of some kind;
|
|
// frames and timers are the watch's own and do not re-arm it.
|
|
if !matches!(event, Event::NextFrame(_) | Event::Timer(_)) {
|
|
self.arm_watch(cx);
|
|
}
|
|
self.refresh_rect(cx);
|
|
if !self.visible {
|
|
return;
|
|
}
|
|
self.handle_card_hits(cx, event);
|
|
self.handle_bg_hits(cx, event);
|
|
self.handle_raw(cx, event);
|
|
}
|
|
|
|
/// The lit section's label.
|
|
fn text(&self) -> String {
|
|
self.lit.and_then(|i| self.defs.get(i)).map(|s| s.label.clone()).unwrap_or_default()
|
|
}
|
|
|
|
fn snapshot_selected(&self, _cx: &Cx) -> Option<String> {
|
|
self.lit.and_then(|i| self.defs.get(i)).map(|s| s.label.clone())
|
|
}
|
|
|
|
/// The lit section's index, so a test can wait on a number.
|
|
fn snapshot_value(&self, _cx: &Cx) -> Option<String> {
|
|
Some(self.lit.map(|i| i.to_string()).unwrap_or_default())
|
|
}
|
|
|
|
/// Each row where it can be pressed now, cut to what shows: an ancestor
|
|
/// that clips the stack clips its rows, and the card, on an overlay, is
|
|
/// cut only by the window. A row none of which shows is left out.
|
|
fn snapshot_parts(&self, cx: &Cx) -> Vec<SnapshotPart> {
|
|
let area = self.draw_bg.area();
|
|
// Not drawn this frame (another tab, a page swapped away): a stale
|
|
// area reads as a zero rect, and rows placed from it would send a
|
|
// test to press empty space by the window's corner.
|
|
if !area.is_valid(cx) {
|
|
return Vec::new();
|
|
}
|
|
let stack = area.rect(cx);
|
|
let pad = self.layout.padding;
|
|
let lit = self.lit_row_section();
|
|
let card = self.drawn_card.filter(|_| self.card_showing() && self.reveal.is_at(1.0));
|
|
let (x, w, shown) = match card {
|
|
Some(card) => (card.card.pos.x, card.card.size.x, self.overlay_bounds),
|
|
None => (stack.pos.x, stack.size.x, area.clipped_rect(cx)),
|
|
};
|
|
self.rows()
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(r, section)| {
|
|
let row = Rect {
|
|
pos: dvec2(x, stack.pos.y + pad.top + r as f64 * self.row_height),
|
|
size: dvec2(w, self.row_height),
|
|
};
|
|
let rect = row.clip((shown.pos, shown.pos + shown.size));
|
|
(rect.size.x > 0.0 && rect.size.y > 0.0).then(|| SnapshotPart {
|
|
id: self.defs[section].id,
|
|
widget_type: "LineMenuSection",
|
|
rect,
|
|
text: self.defs[section].label.clone(),
|
|
selected: lit == Some(section),
|
|
enabled: self.section_enabled(section),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl LineMenuRef {
|
|
fn each_action(&self, actions: &Actions) -> Vec<LineMenuAction> {
|
|
let uid = self.widget_uid();
|
|
actions
|
|
.iter()
|
|
.filter_map(|action| action.as_widget_action())
|
|
.filter(|action| action.widget_uid == uid)
|
|
.map(|action| action.cast::<LineMenuAction>())
|
|
.collect()
|
|
}
|
|
|
|
/// The section lit this pass, if it changed.
|
|
pub fn changed(&self, actions: &Actions) -> Option<LiveId> {
|
|
self.each_action(actions).into_iter().rev().find_map(|a| match a {
|
|
LineMenuAction::Changed(id) => Some(id),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The section a row was activated for this pass.
|
|
pub fn jumped(&self, actions: &Actions) -> Option<LiveId> {
|
|
self.each_action(actions).into_iter().find_map(|a| match a {
|
|
LineMenuAction::Jumped(id) => Some(id),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
pub fn set_sections(&self, cx: &mut Cx, sections: Vec<LineSection>) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_sections(cx, sections);
|
|
}
|
|
}
|
|
|
|
pub fn sections(&self) -> Vec<LineSection> {
|
|
self.borrow().map(|inner| inner.sections().to_vec()).unwrap_or_default()
|
|
}
|
|
|
|
pub fn set_scroll_view(&self, cx: &mut Cx, path: &str) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_scroll_view(cx, path);
|
|
}
|
|
}
|
|
|
|
pub fn current(&self) -> Option<LiveId> {
|
|
self.borrow().and_then(|inner| inner.current())
|
|
}
|
|
|
|
pub fn set_current(&self, cx: &mut Cx, id: LiveId) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_current(cx, id);
|
|
}
|
|
}
|
|
|
|
pub fn jump_to(&self, cx: &mut Cx, id: LiveId) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.jump_to(cx, id);
|
|
}
|
|
}
|
|
|
|
pub fn refresh(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.refresh(cx);
|
|
}
|
|
}
|
|
|
|
pub fn is_linked(&self) -> bool {
|
|
self.borrow().map_or(false, |inner| inner.is_linked())
|
|
}
|
|
|
|
/// The label of the section with `id`, for a host turning a report into
|
|
/// words.
|
|
pub fn label_of(&self, id: LiveId) -> String {
|
|
self.borrow()
|
|
.and_then(|inner| inner.sections().iter().find(|s| s.id == id).map(|s| s.label.clone()))
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
fn test_cx() -> crate::PooledCx {
|
|
crate::checkout_test_cx()
|
|
}
|
|
use super::*;
|
|
use crate::makepad_script::script;
|
|
use crate::makepad_script::trap::NoTrap;
|
|
|
|
fn metrics() -> LineMetrics {
|
|
LineMetrics { line_length: 20.0, line_step: 5.0, line_min: 6.0, hover_extra: 10.0, current_extra: 4.0 }
|
|
}
|
|
|
|
fn card_metrics() -> CardMetrics {
|
|
CardMetrics { label_gap: 10.0, card_pad: 8.0, label_max_width: 220.0, min_label: 40.0 }
|
|
}
|
|
|
|
/// The easing a theme token names, read out of the loaded theme as the
|
|
/// Foundations page reads it, so these checks follow the theme and never
|
|
/// restate it.
|
|
fn theme_ease(vm: &mut ScriptVm, token: &str) -> Ease {
|
|
let theme = vm.module(id!(theme));
|
|
let value = vm.bx.heap.value(theme, LiveId::from_str(token).into(), NoTrap);
|
|
Ease::script_from_value(vm, value)
|
|
}
|
|
|
|
fn theme_number(vm: &mut ScriptVm, token: &str) -> f64 {
|
|
let theme = vm.module(id!(theme));
|
|
vm.bx
|
|
.heap
|
|
.value(theme, LiveId::from_str(token).into(), NoTrap)
|
|
.as_f64()
|
|
.unwrap_or_else(|| panic!("the theme has no number {token}"))
|
|
}
|
|
|
|
/// The reveal's curves and times are the theme's tokens, and a host can
|
|
/// name another token for either curve.
|
|
#[test]
|
|
fn the_reveal_takes_its_curves_and_times_from_the_theme() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
cx.with_vm(|vm| {
|
|
let value = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
LineMenu{}
|
|
});
|
|
let menu = LineMenu::script_from_value(vm, value);
|
|
assert_eq!(menu.reveal_ease, theme_ease(vm, "motion_ease_emphasized_decelerate"));
|
|
assert_eq!(menu.conceal_ease, theme_ease(vm, "motion_ease_standard_accelerate"));
|
|
assert_eq!(menu.reveal_secs, theme_number(vm, "motion_short_4"));
|
|
assert_eq!(menu.conceal_secs, theme_number(vm, "motion_short_3"));
|
|
assert_eq!(menu.jump_ease, theme_ease(vm, "motion_ease_standard"), "the jump's curve is a theme easing");
|
|
assert_eq!(menu.jump_secs, theme_number(vm, "motion_medium_4"));
|
|
let value = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
LineMenu{reveal_ease: theme.motion_ease_spring conceal_ease: theme.motion_ease_linear jump_ease: theme.motion_ease_bounce}
|
|
});
|
|
let sprung = LineMenu::script_from_value(vm, value);
|
|
assert_eq!(sprung.reveal_ease, theme_ease(vm, "motion_ease_spring"));
|
|
assert_eq!(sprung.conceal_ease, theme_ease(vm, "motion_ease_linear"));
|
|
assert_eq!(sprung.jump_ease, theme_ease(vm, "motion_ease_bounce"));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_curve_that_overshoots_widens_the_stack() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
let (decelerate, accelerate, spring, bounce) = cx.with_vm(|vm| {
|
|
(
|
|
theme_ease(vm, "motion_ease_emphasized_decelerate"),
|
|
theme_ease(vm, "motion_ease_standard_accelerate"),
|
|
theme_ease(vm, "motion_ease_spring"),
|
|
theme_ease(vm, "motion_ease_bounce"),
|
|
)
|
|
});
|
|
let near = |a: f64, b: f64| (a - b).abs() < 1e-3;
|
|
assert!(near(reveal_reach(decelerate, accelerate), 1.0), "the default curves stay between their ends");
|
|
assert!(near(reveal_reach(bounce, bounce), 1.0), "a bounce never goes past its end");
|
|
let (low, high) = ease_bounds(spring);
|
|
assert!(high > 1.2, "a spring swings past its end: {high}");
|
|
assert_eq!(low, 0.0, "and never below its start");
|
|
assert_eq!(reveal_reach(spring, accelerate), high);
|
|
let m = metrics();
|
|
assert!(line_length(1, &m, high, false) > line_length(1, &m, 1.0, false), "the swing lengthens a line past its revealed length");
|
|
});
|
|
}
|
|
|
|
/// The DSL only fails at eval time, so the gate is a real registration:
|
|
/// build the menu from `mod.widgets.LineMenu` with the sections a host
|
|
/// writes and read back what the apply parsed.
|
|
#[test]
|
|
fn the_dsl_registers_and_the_sections_parse() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
let menu = cx.with_vm(|vm| {
|
|
let value = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
LineMenu{
|
|
scroll_view: "a"
|
|
max_level: 2
|
|
sections: [
|
|
{target: "page.intro" label: "Introduction" level: 1}
|
|
{target: "setup" id: @getting_ready label: "Setup" level: 2}
|
|
{target: "deep" label: "Deep" level: 9}
|
|
{target: "flat" label: "No level"}
|
|
{target: "silent" level: 2}
|
|
]
|
|
}
|
|
});
|
|
LineMenu::script_from_value(vm, value)
|
|
});
|
|
assert_eq!(menu.defs.len(), 4, "the section without a label is dropped");
|
|
assert_eq!(menu.defs[0].id, LiveId::from_str("intro"), "the id is the target's last segment");
|
|
assert_eq!(menu.defs[0].target, "page.intro");
|
|
assert_eq!(menu.defs[1].id, live_id!(getting_ready));
|
|
assert_eq!(menu.defs[2].level, 6, "a level past six is six");
|
|
assert_eq!(menu.defs[3].level, 1, "no level is level one");
|
|
assert_eq!(menu.scroll_view, "a");
|
|
assert_eq!(menu.max_level_u8(), 2);
|
|
assert_eq!(menu.rows(), vec![0, 1, 3]);
|
|
|
|
let (labeled, mirrored) = cx.with_vm(|vm| {
|
|
let labeled = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
LineMenuLabeled{}
|
|
});
|
|
let mirrored = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
LineMenuMirrored{}
|
|
});
|
|
(LineMenu::script_from_value(vm, labeled), LineMenu::script_from_value(vm, mirrored))
|
|
});
|
|
assert!(labeled.always_show_labels);
|
|
assert!(mirrored.mirror);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn the_lines_and_the_names_compile() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
cx.with_vm(|vm| {
|
|
crate::script_mod(vm);
|
|
for (name, value) in [
|
|
("line", crate::script_eval!(vm, {
|
|
mod.shader.test_compile_draw_source(mod.widgets.LineMenu.draw_line, "glsl", false)
|
|
})),
|
|
("card", crate::script_eval!(vm, {
|
|
mod.shader.test_compile_draw_source(mod.widgets.LineMenu.draw_card, "glsl", false)
|
|
})),
|
|
("label", crate::script_eval!(vm, {
|
|
mod.shader.test_compile_draw_source(mod.widgets.LineMenu.draw_label, "glsl", false)
|
|
})),
|
|
] {
|
|
let text = vm
|
|
.bx
|
|
.heap
|
|
.string_with(value, |_heap, text| text.to_string())
|
|
.expect("the compiler answers with source");
|
|
assert!(!text.starts_with("ERRORS:"), "{name} did not compile: {text}");
|
|
assert!(!text.is_empty(), "{name} compiled to nothing");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// The tracking rule stays a free function the tests can reach, and both
|
|
/// presets are declared once.
|
|
#[test]
|
|
fn the_tracking_rule_and_the_presets_are_in_the_source() {
|
|
crate::on_test_cx(|| {
|
|
let source = include_str!("line_menu.rs");
|
|
assert!(source.contains(&["pub fn ", "current_section("].concat()));
|
|
let labeled = ["mod.widgets.LineMenuLabeled = ", "mod.widgets.LineMenu{"].concat();
|
|
let mirrored = ["mod.widgets.LineMenuMirrored = ", "mod.widgets.LineMenu{"].concat();
|
|
assert_eq!(source.matches(&labeled).count(), 1);
|
|
assert_eq!(source.matches(&mirrored).count(), 1);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_drawn_lights_nothing() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(current_section(&[], 400.0, 100.0, false), None);
|
|
assert_eq!(current_section(&[None, None], 400.0, 100.0, true), None);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn before_any_heading_reaches_the_line_the_first_section_is_lit() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(current_section(&[Some(150.0), Some(600.0)], 400.0, 100.0, false), Some(0));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn the_deepest_heading_past_the_line_is_lit() {
|
|
crate::on_test_cx(|| {
|
|
let tops = [Some(-500.0), Some(-120.0), Some(40.0), Some(300.0)];
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, false), Some(2));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_heading_exactly_on_the_line_counts_as_reached() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(current_section(&[Some(-20.0), Some(100.0)], 400.0, 100.0, false), Some(1));
|
|
assert_eq!(current_section(&[Some(-20.0), Some(100.5)], 400.0, 100.0, false), Some(1), "half a point of rounding");
|
|
assert_eq!(current_section(&[Some(-20.0), Some(101.0)], 400.0, 100.0, false), Some(0));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn undrawn_sections_are_skipped_not_counted() {
|
|
crate::on_test_cx(|| {
|
|
let tops = [Some(-300.0), None, Some(-10.0), None];
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, false), Some(2));
|
|
assert_eq!(current_section(&[None, Some(250.0)], 400.0, 100.0, false), Some(1));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn sections_out_of_order_are_read_by_position() {
|
|
crate::on_test_cx(|| {
|
|
let tops = [Some(50.0), Some(-200.0), Some(500.0)];
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, false), Some(0));
|
|
let tops = [Some(-50.0), Some(-200.0), Some(500.0)];
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, false), Some(0), "the lower heading on the page wins");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn at_the_end_the_last_heading_on_screen_is_lit() {
|
|
crate::on_test_cx(|| {
|
|
let tops = [Some(-900.0), Some(20.0), Some(320.0)];
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, false), Some(1));
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, true), Some(2));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn at_the_end_a_heading_below_the_view_is_not_lit() {
|
|
crate::on_test_cx(|| {
|
|
let tops = [Some(-900.0), Some(20.0), Some(420.0)];
|
|
assert_eq!(current_section(&tops, 400.0, 100.0, true), Some(1));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn ancestors_are_the_nearest_smaller_levels_back_to_the_top() {
|
|
crate::on_test_cx(|| {
|
|
let levels = [1, 2, 3, 2, 3, 1];
|
|
assert_eq!(ancestors(&levels, 4), vec![3, 0]);
|
|
assert_eq!(ancestors(&levels, 2), vec![1, 0]);
|
|
assert_eq!(ancestors(&levels, 3), vec![0]);
|
|
assert_eq!(ancestors(&levels, 5), Vec::<usize>::new());
|
|
assert_eq!(ancestors(&levels, 0), Vec::<usize>::new());
|
|
assert_eq!(ancestors(&levels, 9), Vec::<usize>::new());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_level_jump_still_finds_its_parent() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(ancestors(&[1, 3], 1), vec![0]);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_hidden_section_lights_its_nearest_shown_parent() {
|
|
crate::on_test_cx(|| {
|
|
let levels = [1, 2, 3, 2, 3, 1];
|
|
assert_eq!(shown_row(&levels, 1, 4), Some(0));
|
|
assert_eq!(shown_row(&levels, 2, 4), Some(3));
|
|
assert_eq!(shown_row(&levels, 3, 4), Some(4));
|
|
assert_eq!(shown_row(&levels, 1, 5), Some(5));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_first_section_deeper_than_max_level_has_no_row() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(shown_row(&[3, 1, 2], 2, 0), None);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn line_length_shortens_by_level_and_stops_at_the_minimum() {
|
|
crate::on_test_cx(|| {
|
|
let m = metrics();
|
|
assert_eq!(line_length(1, &m, 0.0, false), 20.0);
|
|
assert_eq!(line_length(2, &m, 0.0, false), 15.0);
|
|
assert_eq!(line_length(3, &m, 0.0, false), 10.0);
|
|
assert_eq!(line_length(4, &m, 0.0, false), 6.0);
|
|
assert_eq!(line_length(6, &m, 0.0, false), 6.0);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn revealing_and_lighting_add_their_extras() {
|
|
crate::on_test_cx(|| {
|
|
let m = metrics();
|
|
assert_eq!(line_length(2, &m, 1.0, false), 25.0);
|
|
assert_eq!(line_length(2, &m, 0.0, true), 19.0);
|
|
assert_eq!(line_length(2, &m, 1.0, true), 29.0);
|
|
assert!(line_length(2, &m, 0.5, false) > 15.0 && line_length(2, &m, 0.5, false) < 25.0);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_jump_leaves_the_margin_above_the_heading() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(jump_target(100.0, 300.0, 12.0, 2000.0, 400.0), 388.0);
|
|
assert_eq!(jump_target(500.0, -200.0, 12.0, 2000.0, 400.0), 288.0, "a heading above the view scrolls back");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_jump_near_the_end_stops_at_the_end() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(jump_target(1400.0, 300.0, 12.0, 2000.0, 400.0), 1600.0);
|
|
assert_eq!(jump_target(0.0, 5.0, 12.0, 2000.0, 400.0), 0.0, "nor before the start");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_jump_in_content_that_fits_goes_nowhere() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(jump_target(0.0, 200.0, 12.0, 300.0, 400.0), 0.0);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn the_pin_holds_within_two_points_and_lets_go_past_them() {
|
|
crate::on_test_cx(|| {
|
|
assert!(!pin_released(388.0, 388.0));
|
|
assert!(!pin_released(388.0, 390.0));
|
|
assert!(!pin_released(388.0, 386.0));
|
|
assert!(pin_released(388.0, 390.5));
|
|
assert!(pin_released(388.0, 385.0));
|
|
});
|
|
}
|
|
|
|
/// A jump reads each theme easing straight off its clock, lands exactly
|
|
/// on its target when the time is up, and an overshooting curve swings
|
|
/// past the heading but never past either end of the page.
|
|
#[test]
|
|
fn a_jump_follows_its_curve_and_lands_on_its_target() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
let tokens = [
|
|
"motion_ease_standard",
|
|
"motion_ease_standard_decelerate",
|
|
"motion_ease_standard_accelerate",
|
|
"motion_ease_emphasized_decelerate",
|
|
"motion_ease_emphasized_accelerate",
|
|
"motion_ease_linear",
|
|
"motion_ease_spring",
|
|
"motion_ease_bounce",
|
|
];
|
|
let eases: Vec<(&str, Ease)> = cx.with_vm(|vm| tokens.iter().map(|token| (*token, theme_ease(vm, token))).collect());
|
|
for (token, ease) in &eases {
|
|
assert_eq!(jump_offset(100.0, 500.0, 0.0, *ease, 2000.0), 100.0 + 400.0 * ease.map(0.0), "{token} at the start");
|
|
for t in [0.25, 0.5, 0.75] {
|
|
let want = (100.0 + 400.0 * ease.map(t)).clamp(0.0, 2000.0);
|
|
assert!((jump_offset(100.0, 500.0, t, *ease, 2000.0) - want).abs() < 1e-9, "{token} at {t}");
|
|
}
|
|
assert_eq!(jump_offset(100.0, 500.0, 1.0, *ease, 2000.0), 500.0, "{token} lands on its target");
|
|
assert_eq!(jump_offset(100.0, 500.0, 1.7, *ease, 2000.0), 500.0, "{token} stays there");
|
|
}
|
|
let spring = eases.iter().find(|(token, _)| *token == "motion_ease_spring").unwrap().1;
|
|
let peak = (1..100).map(|i| i as f64 / 100.0).max_by(|a, b| spring.map(*a).total_cmp(&spring.map(*b))).unwrap();
|
|
assert!(jump_offset(0.0, 1000.0, peak, spring, 5000.0) > 1000.0, "a spring swings past the heading");
|
|
assert_eq!(jump_offset(0.0, 1000.0, peak, spring, 1000.0), 1000.0, "but not past the end of the page");
|
|
assert_eq!(jump_offset(1000.0, 0.0, peak, spring, 1000.0), 0.0, "nor past its start");
|
|
});
|
|
}
|
|
|
|
/// The jump runs along the menu's own `jump_ease`, taken when it
|
|
/// starts: a quarter of the way through its time the view is where that
|
|
/// curve says, not where a fixed one would put it.
|
|
#[test]
|
|
fn the_jump_scrolls_the_view_along_the_menus_curve() {
|
|
crate::on_test_cx(|| {
|
|
let (mut cx, root, mut target) = followed_article();
|
|
let decelerate = cx.with_vm(|vm| theme_ease(vm, "motion_ease_emphasized_decelerate"));
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
{
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
menu.jump_ease = decelerate;
|
|
menu.jump_secs = 1000.0;
|
|
menu.watch_frame(&mut cx, 1.0);
|
|
assert!(menu.is_linked());
|
|
menu.start_jump(&mut cx, 1, true);
|
|
let jump = menu.jump.as_mut().expect("a jump is under way");
|
|
assert_eq!(jump.ease, decelerate, "the jump took the menu's curve");
|
|
assert_eq!(jump.to, 488.0, "the second block's top less the margin");
|
|
// A quarter of the way through a long jump, on the real clock.
|
|
jump.start -= 250.0;
|
|
menu.watch_frame(&mut cx, 1.1);
|
|
}
|
|
target.draw(&mut cx, &root);
|
|
let pos = root.widget(&cx, ids!(article)).borrow::<ScrollShadowView>().unwrap().scroll_extent().pos.y;
|
|
let want = 488.0 * decelerate.map(0.25);
|
|
assert!((pos - want).abs() < 0.5, "the view is at {pos}, the curve says {want}");
|
|
assert!((pos - 488.0 * 0.0625).abs() > 50.0, "and not where the old fixed curve put it");
|
|
});
|
|
}
|
|
|
|
fn stack() -> Rect {
|
|
Rect { pos: dvec2(100.0, 100.0), size: dvec2(42.0, 224.0) }
|
|
}
|
|
|
|
#[test]
|
|
fn the_card_opens_after_the_lines_when_there_is_room() {
|
|
crate::on_test_cx(|| {
|
|
let bounds = Rect { pos: dvec2(6.0, 6.0), size: dvec2(1000.0, 800.0) };
|
|
let layout = card_layout(stack(), 104.0, 138.0, 150.0, &card_metrics(), bounds, false);
|
|
assert!(!layout.labels_before);
|
|
assert_eq!(layout.label_x, 148.0);
|
|
assert_eq!(layout.label_w, 150.0);
|
|
assert_eq!(layout.card, Rect { pos: dvec2(100.0, 92.0), size: dvec2(148.0 + 150.0 + 8.0 - 100.0, 240.0) });
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn the_card_takes_the_other_side_near_the_window_edge() {
|
|
crate::on_test_cx(|| {
|
|
let stack = Rect { pos: dvec2(900.0, 100.0), size: dvec2(42.0, 224.0) };
|
|
let bounds = Rect { pos: dvec2(6.0, 6.0), size: dvec2(1000.0, 800.0) };
|
|
let layout = card_layout(stack, 904.0, 938.0, 150.0, &card_metrics(), bounds, false);
|
|
assert!(layout.labels_before);
|
|
assert_eq!(layout.label_x, 904.0 - 10.0 - 150.0);
|
|
assert_eq!(layout.card.pos.x + layout.card.size.x, 942.0, "the card still ends at the stack");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_mirrored_card_opens_before_the_lines() {
|
|
crate::on_test_cx(|| {
|
|
let stack = Rect { pos: dvec2(600.0, 100.0), size: dvec2(42.0, 224.0) };
|
|
let bounds = Rect { pos: dvec2(6.0, 6.0), size: dvec2(1000.0, 800.0) };
|
|
let layout = card_layout(stack, 604.0, 638.0, 150.0, &card_metrics(), bounds, true);
|
|
assert!(layout.labels_before);
|
|
assert_eq!(layout.label_x, 604.0 - 10.0 - 150.0);
|
|
assert_eq!(layout.card.pos.x, 604.0 - 10.0 - 150.0 - 8.0);
|
|
let unbounded = card_layout(stack, 604.0, 638.0, 150.0, &card_metrics(), Rect::default(), true);
|
|
assert_eq!(unbounded, layout, "no bounds is no flip");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn with_room_on_neither_side_the_names_shrink_to_the_roomier_one() {
|
|
crate::on_test_cx(|| {
|
|
// 100 points either side is not room for 220 of names plus gap and pad.
|
|
let stack = Rect { pos: dvec2(106.0, 100.0), size: dvec2(42.0, 224.0) };
|
|
let bounds = Rect { pos: dvec2(6.0, 6.0), size: dvec2(290.0, 800.0) };
|
|
let layout = card_layout(stack, 110.0, 144.0, 400.0, &card_metrics(), bounds, false);
|
|
assert!(!layout.labels_before, "after the lines is the roomier side");
|
|
let room_after = 296.0 - (144.0 + 10.0 + 8.0);
|
|
assert_eq!(layout.label_w, room_after);
|
|
assert!(layout.card.pos.x + layout.card.size.x <= 296.0 + 1e-9);
|
|
// 26 points before and 28 after: the names keep their floor, and the
|
|
// card is pulled back inside rather than running off.
|
|
let narrow = Rect { pos: dvec2(46.0, 100.0), size: dvec2(42.0, 224.0) };
|
|
let tight = Rect { pos: dvec2(6.0, 6.0), size: dvec2(124.0, 800.0) };
|
|
let floored = card_layout(narrow, 50.0, 84.0, 400.0, &card_metrics(), tight, false);
|
|
assert!(!floored.labels_before);
|
|
assert_eq!(floored.label_w, 40.0, "never narrower than the floor");
|
|
assert!(floored.card.pos.x + floored.card.size.x <= 130.0 + 1e-9);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn names_show_only_after_the_reveal_delay_and_hide_after_the_grace() {
|
|
crate::on_test_cx(|| {
|
|
let mut reveal = Reveal::default();
|
|
assert_eq!(reveal.pointer(true, 0.0, 0.06, 0.2), None);
|
|
assert_eq!(reveal.tick(0.05), None);
|
|
assert_eq!(reveal.pointer(true, 0.04, 0.06, 0.2), None);
|
|
assert_eq!(reveal.deadline(), Some(0.06), "moving on the stack does not push the deadline back");
|
|
assert_eq!(reveal.tick(0.06), Some(true));
|
|
assert_eq!(reveal.pointer(false, 1.0, 0.06, 0.2), None);
|
|
assert_eq!(reveal.pointer(true, 1.1, 0.06, 0.2), None);
|
|
assert_eq!(reveal.tick(1.3), None, "coming back inside the grace keeps the names");
|
|
assert_eq!(reveal.pointer(false, 2.0, 0.06, 0.2), None);
|
|
assert_eq!(reveal.tick(2.19), None);
|
|
assert_eq!(reveal.tick(2.2), Some(false));
|
|
let mut instant = Reveal::default();
|
|
assert_eq!(instant.pointer(true, 0.0, 0.0, 0.0), Some(true));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn focus_shows_at_once_and_does_not_hide_under_the_pointer() {
|
|
crate::on_test_cx(|| {
|
|
let mut reveal = Reveal::default();
|
|
reveal.pointer(true, 0.0, 0.06, 0.2);
|
|
assert_eq!(reveal.focus(true, true), Some(true));
|
|
assert_eq!(reveal.pending, None);
|
|
assert_eq!(reveal.focus(false, true), None, "the pointer is still over the names");
|
|
assert!(reveal.shown);
|
|
assert_eq!(reveal.focus(false, false), Some(false));
|
|
assert_eq!(reveal.touch_toggle(), Some(true));
|
|
assert_eq!(reveal.touch_toggle(), Some(false));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn a_section_id_comes_from_the_target_unless_one_is_written() {
|
|
crate::on_test_cx(|| {
|
|
assert_eq!(section_id("article.body.intro", None), LiveId::from_str("intro"));
|
|
assert_eq!(section_id("intro", Some(live_id!(start))), live_id!(start));
|
|
assert_eq!(LineSection::new("a.b", "B", 9).level, 6);
|
|
assert_eq!(LineSection::new("a.b", "B", 2).with_id(live_id!(x)).id, live_id!(x));
|
|
});
|
|
}
|
|
|
|
/// Hosted here because it is the contract the menu depends on: a view
|
|
/// with scroll bars can say where it is, and a plain view has nothing to
|
|
/// say.
|
|
#[test]
|
|
fn a_view_with_scroll_bars_reports_an_extent_and_a_plain_view_does_not() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
let (scrolling, plain) = cx.with_vm(|vm| {
|
|
let scrolling = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
ScrollYView{}
|
|
});
|
|
let plain = vm.eval(script! {
|
|
use mod.prelude.widgets.*
|
|
View{}
|
|
});
|
|
(View::script_from_value(vm, scrolling), View::script_from_value(vm, plain))
|
|
});
|
|
assert!(scrolling.scroll_extent().is_some());
|
|
assert!(plain.scroll_extent().is_none());
|
|
});
|
|
}
|
|
|
|
/// A scrolling view is its own box. A shadow view answers with all of
|
|
/// its content moved up by the offset, so its box is put back together
|
|
/// from the extent, and an axis that does not scroll keeps the area's
|
|
/// own size.
|
|
#[test]
|
|
fn a_shadow_views_box_is_rebuilt_from_its_extent() {
|
|
crate::on_test_cx(|| {
|
|
let extent = ScrollExtent { pos: dvec2(0.0, 250.0), total: dvec2(0.0, 1200.0), visible: dvec2(0.0, 300.0) };
|
|
let content = Rect { pos: dvec2(40.0, 60.0 - 250.0), size: dvec2(500.0, 1200.0) };
|
|
assert_eq!(shown_box(LinkedKind::View, content, extent), content);
|
|
let shown = shown_box(LinkedKind::Shadow, content, extent);
|
|
assert_eq!(shown, Rect { pos: dvec2(40.0, 60.0), size: dvec2(500.0, 300.0) });
|
|
// A heading 400 into the content sits 150 below the box's top here.
|
|
assert_eq!(content.pos.y + 400.0 - shown.pos.y, 150.0);
|
|
});
|
|
}
|
|
|
|
/// One frame of `root` into a window-less pass, with the overlay a
|
|
/// window gives the tree to hang its own lists off.
|
|
struct Target {
|
|
pass: DrawPass,
|
|
draw_list: DrawList2d,
|
|
overlay: Overlay,
|
|
}
|
|
|
|
impl Target {
|
|
fn new(cx: &mut Cx) -> Self {
|
|
let overlay = cx.with_vm(|vm| Overlay::script_new(vm));
|
|
Target { pass: DrawPass::new(cx), draw_list: DrawList2d::new(cx), overlay }
|
|
}
|
|
|
|
fn draw(&mut self, cx: &mut Cx, root: &WidgetRef) {
|
|
let size = dvec2(800.0, 400.0);
|
|
self.pass.set_size(cx, size);
|
|
let event = DrawEvent::default();
|
|
let mut draw = crate::makepad_draw::cx_draw::CxDraw::new(cx, &event);
|
|
let mut cx2d = Cx2d::new(&mut draw);
|
|
cx2d.begin_pass(&self.pass, None);
|
|
self.draw_list.begin_always(&mut cx2d);
|
|
self.overlay.begin(&mut cx2d);
|
|
cx2d.begin_root_turtle(size, Layout::flow_down());
|
|
root.draw_all(&mut cx2d, &mut Scope::empty());
|
|
cx2d.end_pass_sized_turtle();
|
|
self.overlay.end(&mut cx2d);
|
|
self.draw_list.end(&mut cx2d);
|
|
cx2d.end_pass(&self.pass);
|
|
}
|
|
}
|
|
|
|
fn press(abs: DVec2) -> Event {
|
|
Event::MouseDown(MouseDownEvent {
|
|
abs,
|
|
button: MouseButton::PRIMARY,
|
|
window_id: WindowId(1, 1),
|
|
modifiers: KeyModifiers::default(),
|
|
handled: std::cell::Cell::new(Area::Empty),
|
|
time: 0.0,
|
|
})
|
|
}
|
|
|
|
fn moved(abs: DVec2) -> Event {
|
|
Event::MouseMove(MouseMoveEvent {
|
|
abs,
|
|
lock_delta: DVec2::default(),
|
|
window_id: WindowId(1, 1),
|
|
modifiers: KeyModifiers::default(),
|
|
time: 0.0,
|
|
handled: std::cell::Cell::new(Area::Empty),
|
|
})
|
|
}
|
|
|
|
/// A menu of two sections following a 300-point shadow view whose two
|
|
/// 500-point blocks are the sections' targets, drawn once.
|
|
fn followed_article() -> (crate::PooledCx, WidgetRef, Target) {
|
|
let mut cx = test_cx();
|
|
let root = article_root(&mut cx);
|
|
let mut target = Target::new(&mut cx);
|
|
target.draw(&mut cx, &root);
|
|
(cx, root, target)
|
|
}
|
|
|
|
/// The tree `followed_article` draws, built into a `Cx` already loaded.
|
|
fn article_root(cx: &mut Cx) -> WidgetRef {
|
|
cx.with_vm(|vm| {
|
|
let value = crate::script_eval!(vm, {
|
|
use mod.prelude.widgets.*
|
|
use mod.widgets.*
|
|
View{
|
|
width: 800
|
|
height: 400
|
|
flow: Right
|
|
toc := LineMenu{
|
|
scroll_view: "article"
|
|
sections: [
|
|
{target: "first" label: "First" level: 1}
|
|
{target: "second" label: "Second" level: 1}
|
|
]
|
|
}
|
|
article := ScrollShadowView{
|
|
width: Fill
|
|
height: 300
|
|
first := View{width: Fill height: 500}
|
|
second := View{width: Fill height: 500}
|
|
}
|
|
}
|
|
});
|
|
WidgetRef::script_from_value(vm, value)
|
|
})
|
|
}
|
|
|
|
/// Show the card as the pointer would once the reveal delay is over.
|
|
fn reveal_by_pointer(cx: &mut Cx, root: &WidgetRef) {
|
|
let toc = root.widget(cx, ids!(toc));
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
menu.reveal_state.shown = true;
|
|
menu.apply_reveal(cx, Some(true), RevealedBy::Pointer);
|
|
assert!(menu.locked, "the card takes the pointer");
|
|
}
|
|
|
|
/// A menu dropped with its card out, as when the page holding it is
|
|
/// swapped for another while the pointer rests on the stack, cannot let
|
|
/// go of the pointer itself: a drop has no `Cx`. A lock nobody holds
|
|
/// turns every press in the window away, so the menu leaves it for the
|
|
/// next event to release, and a menu taking a lock of its own in a tree
|
|
/// with no window above it releases what was left first.
|
|
#[test]
|
|
fn a_menu_dropped_with_its_card_out_leaves_its_lock_for_the_next_event() {
|
|
crate::on_test_cx(|| {
|
|
let (mut cx, root, _target) = followed_article();
|
|
reveal_by_pointer(&mut cx, &root);
|
|
assert!(cx.sweep_lock_area().is_some(), "the card holds the pointer");
|
|
drop(root);
|
|
assert!(cx.sweep_lock_area().is_some(), "nothing could let go of it on drop");
|
|
crate::family_api::release_orphaned_sweep_locks(&mut cx);
|
|
assert_eq!(cx.sweep_lock_area(), None, "the next event does");
|
|
|
|
let dropped = article_root(&mut cx);
|
|
let survivor = article_root(&mut cx);
|
|
let (mut dropped_target, mut survivor_target) = (Target::new(&mut cx), Target::new(&mut cx));
|
|
dropped_target.draw(&mut cx, &dropped);
|
|
survivor_target.draw(&mut cx, &survivor);
|
|
reveal_by_pointer(&mut cx, &dropped);
|
|
drop(dropped);
|
|
reveal_by_pointer(&mut cx, &survivor);
|
|
survivor.widget(&cx, ids!(toc)).borrow_mut::<LineMenu>().unwrap().hide(&mut cx);
|
|
assert_eq!(cx.sweep_lock_area(), None, "the other menu let go of its own lock and of the one left behind");
|
|
});
|
|
}
|
|
|
|
/// The test tree is told where the rows are on screen. A parent that
|
|
/// clips the stack cuts the rows it reports, and a row it hides entirely
|
|
/// is not reported, so a test is never sent to press a row through what
|
|
/// lies over the page.
|
|
#[test]
|
|
fn rows_are_reported_cut_to_what_a_clipping_parent_shows() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
let root = cx.with_vm(|vm| {
|
|
let value = crate::script_eval!(vm, {
|
|
use mod.prelude.widgets.*
|
|
use mod.widgets.*
|
|
View{
|
|
width: 800
|
|
height: 400
|
|
flow: Down
|
|
band := View{
|
|
width: 200
|
|
height: 50
|
|
toc := LineMenu{
|
|
sections: [
|
|
{target: "a" label: "A" level: 1}
|
|
{target: "b" label: "B" level: 1}
|
|
{target: "c" label: "C" level: 1}
|
|
{target: "d" label: "D" level: 1}
|
|
{target: "e" label: "E" level: 1}
|
|
{target: "f" label: "F" level: 1}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
});
|
|
WidgetRef::script_from_value(vm, value)
|
|
});
|
|
let mut target = Target::new(&mut cx);
|
|
target.draw(&mut cx, &root);
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
let menu = toc.borrow::<LineMenu>().expect("toc is a line menu");
|
|
let full = menu.draw_bg.area().rect(&cx);
|
|
let shown = menu.draw_bg.area().clipped_rect(&cx);
|
|
assert!(full.size.y > shown.size.y + 50.0, "the band clips the stack: {full:?} shown as {shown:?}");
|
|
let parts = menu.snapshot_parts(&cx);
|
|
let within = |r: Rect| {
|
|
r.size.x > 0.0
|
|
&& r.size.y > 0.0
|
|
&& r.pos.x >= shown.pos.x - 1e-9
|
|
&& r.pos.y >= shown.pos.y - 1e-9
|
|
&& r.pos.x + r.size.x <= shown.pos.x + shown.size.x + 1e-9
|
|
&& r.pos.y + r.size.y <= shown.pos.y + shown.size.y + 1e-9
|
|
};
|
|
for part in &parts {
|
|
assert!(within(part.rect), "{:?} is reported at {:?}, outside {shown:?}", part.text, part.rect);
|
|
}
|
|
// Rows at 4..22, 22..40 and 40..58 meet the band's 50 points; the rest do not.
|
|
let names: Vec<&str> = parts.iter().map(|part| part.text.as_str()).collect();
|
|
assert_eq!(names, vec!["A", "B", "C"]);
|
|
assert_eq!(parts[2].rect.size.y, shown.pos.y + shown.size.y - parts[2].rect.pos.y, "the last row cut at the band's edge");
|
|
});
|
|
}
|
|
|
|
/// Following a shadow view, a heading's top moves with the scroll, and
|
|
/// a section made current before any watch frame has looked for the
|
|
/// view is held against where the view is, not against the top.
|
|
#[test]
|
|
fn a_followed_shadow_view_moves_its_headings_and_holds_a_pin_where_it_is() {
|
|
crate::on_test_cx(|| {
|
|
let (mut cx, root, mut target) = followed_article();
|
|
root.widget(&cx, ids!(article))
|
|
.borrow_mut::<ScrollShadowView>()
|
|
.expect("article is a shadow view")
|
|
.set_scroll(&mut cx, dvec2(0.0, 250.0));
|
|
target.draw(&mut cx, &root);
|
|
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
{
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
let (probe, extent) = menu.read_geometry(&cx).expect("the article is followed");
|
|
assert_eq!(extent.pos.y, 250.0);
|
|
assert_eq!(probe.viewport.size.y, 300.0, "the box, not the content");
|
|
assert_eq!(menu.tops, vec![Some(-250.0), Some(250.0)], "the headings moved up with the page");
|
|
menu.linked = None;
|
|
}
|
|
toc.as_line_menu().set_current(&mut cx, LiveId::from_str("second"));
|
|
let pin = toc.borrow::<LineMenu>().unwrap().pin.expect("pinned");
|
|
assert_eq!(pin.landed, 250.0, "held where the page is");
|
|
});
|
|
}
|
|
|
|
/// With nothing moving the watch repaints nothing and stops arming
|
|
/// itself after a few frames. A repaint on every frame would wake it
|
|
/// for good: the repaint is an event, and every event arms the watch.
|
|
#[test]
|
|
fn a_still_page_lets_the_watch_go_quiet() {
|
|
crate::on_test_cx(|| {
|
|
let (mut cx, root, _target) = followed_article();
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
// The first frame finds the view and lights a section.
|
|
menu.watch_frame(&mut cx, 1.0);
|
|
assert!(menu.is_linked());
|
|
for frame in 0..QUIET_FRAMES {
|
|
cx.new_draw_event = DrawEvent::default();
|
|
menu.watch_frame(&mut cx, 1.1 + frame as f64 * 0.1);
|
|
assert!(!cx.new_draw_event.will_redraw(), "frame {frame} repainted a page that had not moved");
|
|
}
|
|
assert_eq!(menu.last_time, 0.0, "the watch stopped arming itself");
|
|
});
|
|
}
|
|
|
|
/// A pointer another control holds scrubs no row here. The names are out
|
|
/// by the keyboard, which takes no pointer of its own (see
|
|
/// `apply_reveal`), so a button can still hold a press while the card is
|
|
/// over the page — and the raw move would otherwise drag the highlight
|
|
/// across it under a hand that is dragging something else entirely.
|
|
#[test]
|
|
fn a_pointer_another_control_holds_scrubs_no_row() {
|
|
crate::on_test_cx(|| {
|
|
let mut cx = test_cx();
|
|
let root = cx.with_vm(|vm| {
|
|
let value = crate::script_eval!(vm, {
|
|
use mod.prelude.widgets.*
|
|
use mod.widgets.*
|
|
View{
|
|
width: 800
|
|
height: 400
|
|
flow: Right
|
|
toc := LineMenu{
|
|
sections: [
|
|
{target: "a" label: "Alpha" level: 1}
|
|
{target: "b" label: "Beta" level: 1}
|
|
]
|
|
}
|
|
held := Button{width: 200. height: 40. text: "Held"}
|
|
}
|
|
});
|
|
WidgetRef::script_from_value(vm, value)
|
|
});
|
|
let mut target = Target::new(&mut cx);
|
|
target.draw(&mut cx, &root);
|
|
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
{
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
menu.refresh_rect(&mut cx);
|
|
menu.reveal_state.shown = true;
|
|
menu.apply_reveal(&mut cx, Some(true), RevealedBy::Keys);
|
|
let _ = menu.advance_reveal(0.0);
|
|
menu.reveal.settle();
|
|
assert!(!menu.locked, "a keyboard reveal takes no pointer");
|
|
}
|
|
target.draw(&mut cx, &root);
|
|
let row = {
|
|
let menu = toc.borrow::<LineMenu>().unwrap();
|
|
assert!(menu.drawn_card.is_some(), "the card is drawn");
|
|
menu.row_rects()[1]
|
|
};
|
|
let over = row.pos + row.size * 0.5;
|
|
|
|
// With nothing holding the pointer the move reads as it always did.
|
|
root.handle_event(&mut cx, &moved(over), &mut Scope::empty());
|
|
assert_eq!(toc.borrow::<LineMenu>().unwrap().hot_row, Some(1), "the row under the pointer lights");
|
|
|
|
// Put out by hand, so what follows cannot pass on leftover state:
|
|
// held still means held still, and a frozen menu keeps whatever it
|
|
// had, so it must be given nothing.
|
|
{
|
|
let mut menu = toc.borrow_mut::<LineMenu>().unwrap();
|
|
menu.hot_row = None;
|
|
menu.pointer_over = false;
|
|
}
|
|
|
|
// The button takes a press and holds the mouse with it.
|
|
let button = root.widget(&cx, ids!(held)).area();
|
|
let on_button = button.rect(&cx);
|
|
assert!(on_button.size.x > 0.0, "the button is drawn");
|
|
root.handle_event(&mut cx, &press(on_button.pos + on_button.size * 0.5), &mut Scope::empty());
|
|
assert!(cx.fingers.is_area_captured(button), "the button holds the mouse");
|
|
|
|
root.handle_event(&mut cx, &moved(over), &mut Scope::empty());
|
|
let menu = toc.borrow::<LineMenu>().unwrap();
|
|
assert_eq!(menu.hot_row, None, "no row lit under a pointer the button holds");
|
|
assert!(!menu.pointer_over, "and the menu does not count itself hovered");
|
|
});
|
|
}
|
|
|
|
/// A spring swings the lines past their revealed length and back. The
|
|
/// stack is widened once, by the curve, so the swing never leaves it;
|
|
/// the card and the rows a pointer lands on stay where they rest, and a
|
|
/// test is told of the card's rows only once they have arrived.
|
|
#[test]
|
|
fn a_spring_swings_the_lines_while_the_rows_and_the_card_stay_where_they_rest() {
|
|
crate::on_test_cx(|| {
|
|
let (mut cx, root, mut target) = followed_article();
|
|
let spring = cx.with_vm(|vm| theme_ease(vm, "motion_ease_spring"));
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
let calm = toc.borrow::<LineMenu>().unwrap().draw_bg.area().rect(&cx);
|
|
{
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
menu.reveal_ease = spring;
|
|
menu.reveal_secs = 1.0;
|
|
}
|
|
target.draw(&mut cx, &root);
|
|
let stack = {
|
|
let mut menu = toc.borrow_mut::<LineMenu>().unwrap();
|
|
menu.refresh_rect(&mut cx);
|
|
let reach = reveal_reach(menu.reveal_ease, menu.conceal_ease);
|
|
let widened = menu.rect.size.x - calm.size.x;
|
|
assert!((widened - menu.hover_extra * (reach - 1.0)).abs() < 1e-6, "the stack widens by the swing, {widened}");
|
|
menu.reveal_state.shown = true;
|
|
assert!(menu.advance_reveal(0.0), "the reveal has started");
|
|
assert!(menu.advance_reveal(0.15));
|
|
assert!(menu.reveal.value() > 1.2, "the spring carries the reveal past 1: {}", menu.reveal.value());
|
|
menu.rect
|
|
};
|
|
target.draw(&mut cx, &root);
|
|
let (rows, card) = {
|
|
let menu = toc.borrow::<LineMenu>().unwrap();
|
|
assert!(menu.card_showing());
|
|
let card = menu.drawn_card.expect("the card is drawn while the names come in");
|
|
let longest = line_length(1, &menu.line_metrics(), menu.reveal.value(), true);
|
|
assert!(longest <= menu.max_line(&menu.rows()), "the longest line mid-swing still fits the stack");
|
|
assert!(
|
|
menu.snapshot_parts(&cx).iter().all(|part| part.rect.pos.x == stack.pos.x),
|
|
"rows still arriving are reported on the stack, not the card"
|
|
);
|
|
(menu.row_rects(), card)
|
|
};
|
|
toc.borrow_mut::<LineMenu>().unwrap().reveal.settle();
|
|
target.draw(&mut cx, &root);
|
|
let menu = toc.borrow::<LineMenu>().unwrap();
|
|
assert!(menu.reveal.is_at(1.0));
|
|
assert_eq!(menu.row_rects(), rows, "the rows are hit where they rest");
|
|
assert_eq!(menu.drawn_card, Some(card), "and the card does not move with the swing");
|
|
assert!(
|
|
menu.snapshot_parts(&cx).iter().all(|part| part.rect.pos.x == card.card.pos.x),
|
|
"settled, the rows are reported on the card"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn reduced_motion_shows_and_hides_the_names_at_once_on_any_curve() {
|
|
crate::on_test_cx(|| {
|
|
let (mut cx, root, _target) = followed_article();
|
|
let spring = cx.with_vm(|vm| theme_ease(vm, "motion_ease_spring"));
|
|
let toc = root.widget(&cx, ids!(toc));
|
|
let mut menu = toc.borrow_mut::<LineMenu>().expect("toc is a line menu");
|
|
menu.reveal_ease = spring;
|
|
menu.conceal_ease = spring;
|
|
menu.reduced_motion = true;
|
|
menu.reveal_state.shown = true;
|
|
assert!(!menu.advance_reveal(0.0));
|
|
assert!(menu.reveal.is_at(1.0), "shown whole on the first frame");
|
|
menu.reveal_state.shown = false;
|
|
assert!(!menu.advance_reveal(0.0));
|
|
assert!(menu.reveal.is_at(0.0) && !menu.card_showing(), "and gone on the next");
|
|
});
|
|
}
|
|
}
|