feat: typed settings fields, timeline zoom, faster dev builds
Rust checks / windows-cross-check (push) Successful in 1m2s
Rust checks / linux (push) Failing after 5m49s

Footage budget, clip hotkey length and bookmark before/after are now typed
fields (m:ss or seconds), validated on Enter or blur and reverted when out of
range. Scrolling over the session timeline zooms around the pointer (down to
10 s) and scrolling sideways or with Shift pans, with a Fit button.

Settings felt slow because GPUI ran unoptimised: dev builds now optimise
dependencies, taking first frames from 200-300 ms to about 15 ms. Adds
rig-pointer, a virtual pointer for driving the interface in the headless
rig, which verified the fields, zoom and timeline dragging.
This commit is contained in:
2026-10-08 16:19:14 +02:00
parent e20b2680cc
commit 98fe898efb
12 changed files with 1098 additions and 136 deletions
+30
View File
@@ -134,6 +134,24 @@ pub fn format_clock(time: Duration) -> String {
}
}
/// Reads a duration typed as `m:ss`, `h:mm:ss`, or plain seconds.
pub fn parse_duration(text: &str) -> Option<Duration> {
let parts: Vec<u64> = text
.trim()
.split(':')
.map(|part| part.parse().ok())
.collect::<Option<_>>()?;
let (seconds, rest) = parts.split_last()?;
if rest.len() > 2
|| (!rest.is_empty() && *seconds >= 60)
|| rest.iter().skip(1).any(|minutes| *minutes >= 60)
{
return None;
}
let seconds = rest.iter().fold(0, |total, part| total * 60 + part) * 60 + seconds;
Some(Duration::from_secs(seconds))
}
/// `1h 12m`, `12m`, `1m 34s`, or `45s`: seconds only for clip-sized lengths.
pub fn format_length(time: Duration) -> String {
let seconds = time.as_secs();
@@ -357,6 +375,18 @@ mod tests {
);
}
#[test]
fn durations_are_typed_as_clock_times_or_seconds() {
let seconds = Duration::from_secs;
assert_eq!(parse_duration("2:00"), Some(seconds(120)));
assert_eq!(parse_duration(" 90 "), Some(seconds(90)));
assert_eq!(parse_duration("1:02:03"), Some(seconds(3723)));
assert_eq!(parse_duration("0:30"), Some(seconds(30)));
for invalid in ["", "1:75", "1:60:00", "a", "1::2", "1:2:3:4", "-5"] {
assert_eq!(parse_duration(invalid), None, "{invalid}");
}
}
#[test]
fn formatting() {
assert_eq!(format_clock(Duration::from_secs(3723)), "1:02:03");
+140 -7
View File
@@ -7,15 +7,21 @@ use std::{
time::{Duration, SystemTime},
};
use clipbin_core::{Library, RecordingStatus, Settings, library::format_bytes};
use clipbin_core::{
Library, RecordingStatus, Settings,
library::{format_bytes, format_clock, parse_duration},
};
use gpui::{
AnyElement, Bounds, ClickEvent, Context, FontWeight, IntoElement, Pixels, Render, SharedString,
Window, div, prelude::*, px,
AnyElement, Bounds, ClickEvent, Context, Entity, FontWeight, IntoElement, Pixels, Render,
SharedString, Window, div, prelude::*, px,
};
use crate::{
components::{icon, meter},
screens, theme,
screens,
text_field::{Committed, TextField},
theme,
timeline_view::TimelineView,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -36,6 +42,7 @@ pub struct Recorder {
/// The open session's playhead and clip selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Editor {
pub view: TimelineView,
pub playhead: Duration,
pub selection: (Duration, Duration),
pub playing: bool,
@@ -60,11 +67,127 @@ pub struct ClipbinApp {
pub drag: Option<Drag>,
/// Where the timeline was last laid out, to turn pointer positions into times.
pub timeline_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
pub fields: SettingsFields,
}
/// The typed settings and the fields that edit them.
pub struct SettingsFields {
pub budget: Entity<TextField>,
pub clip: Entity<TextField>,
pub bookmark_before: Entity<TextField>,
pub bookmark_after: Entity<TextField>,
}
const GIGABYTE: u64 = 1_000_000_000;
fn digits(c: char) -> bool {
c.is_ascii_digit()
}
fn clock(c: char) -> bool {
c.is_ascii_digit() || c == ':'
}
/// Reads a duration within `min..=max`.
fn duration_in(text: &str, min: u64, max: u64) -> Option<Duration> {
parse_duration(text).filter(|time| (min..=max).contains(&time.as_secs()))
}
impl SettingsFields {
fn new(settings: &Settings, window: &mut Window, cx: &mut Context<ClipbinApp>) -> Self {
let field = |value: String,
accepts,
apply: fn(&mut Settings, &str) -> bool,
window: &mut Window,
cx: &mut Context<ClipbinApp>| {
let field = cx.new(|cx| TextField::new(value, accepts, window, cx));
cx.subscribe_in(
&field,
window,
move |app, _, Committed(text), window, cx| {
if apply(&mut app.settings, text) {
app.fields.refresh(&app.settings, window, cx);
cx.notify();
}
},
)
.detach();
field
};
Self {
budget: field(
(settings.unprotected_session_limit_bytes / GIGABYTE).to_string(),
digits,
|settings, text| match text.parse::<u64>() {
Ok(gigabytes @ 1..=10_000) => {
settings.unprotected_session_limit_bytes = gigabytes * GIGABYTE;
true
}
_ => false,
},
window,
cx,
),
clip: field(
format_clock(settings.clip_duration),
clock,
|settings, text| {
duration_in(text, 5, 30 * 60)
.map(|time| settings.clip_duration = time)
.is_some()
},
window,
cx,
),
bookmark_before: field(
format_clock(settings.bookmark_before),
clock,
|settings, text| {
duration_in(text, 0, 30 * 60)
.map(|time| settings.bookmark_before = time)
.is_some()
},
window,
cx,
),
bookmark_after: field(
format_clock(settings.bookmark_after),
clock,
|settings, text| {
duration_in(text, 0, 10 * 60)
.map(|time| settings.bookmark_after = time)
.is_some()
},
window,
cx,
),
}
}
/// Shows accepted values in their normal form (`90` becomes `1:30`).
fn refresh(&self, settings: &Settings, window: &mut Window, cx: &mut Context<ClipbinApp>) {
let values = [
(
&self.budget,
(settings.unprotected_session_limit_bytes / GIGABYTE).to_string(),
),
(&self.clip, format_clock(settings.clip_duration)),
(
&self.bookmark_before,
format_clock(settings.bookmark_before),
),
(&self.bookmark_after, format_clock(settings.bookmark_after)),
];
for (field, value) in values {
field.update(cx, |field, cx| field.set_value(value, window, cx));
}
}
}
impl ClipbinApp {
pub fn new() -> Self {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = Settings::default();
let fields = SettingsFields::new(&settings, window, cx);
// Development aid: CLIPBIN_SAMPLE_LIBRARY=1 fills the screens with
// example sessions; CLIPBIN_SAMPLE_STATUS picks the recorder state and
// CLIPBIN_ROUTE the first screen, for screenshots.
@@ -100,6 +223,7 @@ impl ClipbinApp {
settings,
recorder,
editor: Editor {
view: TimelineView::full(Duration::ZERO),
playhead: Duration::ZERO,
selection: (Duration::ZERO, Duration::ZERO),
playing: false,
@@ -107,11 +231,12 @@ impl ClipbinApp {
notice: None,
drag: None,
timeline_bounds: Rc::default(),
fields,
};
match std::env::var("CLIPBIN_ROUTE").as_deref() {
Ok("clips") => app.route = Route::Clips,
Ok("settings") => app.route = Route::Settings,
Ok(route) if route.starts_with("session") => {
Ok(route) if route.starts_with("session:") || route == "session" => {
let id = route.trim_start_matches("session").trim_start_matches(':');
if let Some(id) = id
.parse()
@@ -149,6 +274,7 @@ impl ClipbinApp {
}
};
self.editor = Editor {
view: TimelineView::full(session.length),
playhead,
selection,
playing: false,
@@ -415,7 +541,14 @@ impl ClipbinApp {
}
impl Render for ClipbinApp {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Development aid: CLIPBIN_TRACE_FRAMES logs how long each frame takes
// from building the view to the next frame starting.
if std::env::var_os("CLIPBIN_TRACE_FRAMES").is_some() {
let started = std::time::Instant::now();
let route = self.route;
window.on_next_frame(move |_, _| eprintln!("frame {route:?} {:?}", started.elapsed()));
}
let screen = match self.route {
Route::Sessions => screens::sessions::render(self, cx),
Route::Session(id) => screens::editor::render(self, id, cx),
+4 -1
View File
@@ -7,7 +7,9 @@ mod app;
mod assets;
mod components;
mod screens;
mod text_field;
mod theme;
mod timeline_view;
actions!(clipbin, [Quit]);
@@ -20,6 +22,7 @@ fn main() {
}
cx.on_action(|_: &Quit, cx| cx.quit());
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
text_field::bind_keys(cx);
// This shell has no background recorder yet.
cx.on_window_closed(|cx| {
@@ -41,7 +44,7 @@ fn main() {
app_id: Some("dev.clipbin.Clipbin".into()),
..Default::default()
},
|_, cx| cx.new(|_| app::ClipbinApp::new()),
|window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx)),
);
if let Err(error) = result {
+149 -63
View File
@@ -7,8 +7,8 @@ use clipbin_core::library::{
};
use gpui::{
AnyElement, Bounds, ClickEvent, Context, FontWeight, Hsla, IntoElement, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, canvas, div, pattern_slash,
prelude::*, px, relative,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, canvas, div,
pattern_slash, prelude::*, px, relative,
};
use crate::{
@@ -16,12 +16,17 @@ use crate::{
components::{ButtonStyle, aspect_ratio, button, chip, icon, panel, row},
screens::backdrop,
theme,
timeline_view::TimelineView,
};
/// Keyframes come every second (clipbin-obs records with keyint_sec = 1).
const KEYFRAME_INTERVAL: Duration = Duration::from_secs(1);
/// Selection handles respond within this distance of the pointer.
const HANDLE_REACH: f32 = 10.;
/// Pixels per scroll-wheel line, for wheels that report lines.
const SCROLL_LINE: f32 = 40.;
/// How strongly each scrolled pixel zooms the timeline.
const ZOOM_PER_PIXEL: f32 = 0.0025;
pub fn render(app: &ClipbinApp, id: u64, cx: &mut Context<ClipbinApp>) -> AnyElement {
let Some(session) = app.library.session(id) else {
@@ -387,84 +392,92 @@ fn legend(colour: Hsla, label: &'static str) -> impl IntoElement {
.child(label)
}
/// The fraction of the session at `time`.
fn fraction(time: Duration, length: Duration) -> f32 {
(time.as_secs_f32() / length.as_secs_f32().max(1.)).clamp(0., 1.)
/// Places an element across the timeline from `from` to `to`, clipped to the
/// visible view. `None` when it is entirely out of view.
fn placed(view: &TimelineView, from: Duration, to: Duration) -> Option<(f32, f32)> {
let left = view.position(from).max(0.);
let right = view.position(to).min(1.);
(right > left).then_some((left, right - left))
}
fn time_at(position: Point<Pixels>, bounds: Bounds<Pixels>, length: Duration) -> Duration {
let x = (position.x - bounds.left()) / bounds.size.width;
length.mul_f32(x.clamp(0., 1.))
fn position_in(position: Point<Pixels>, bounds: Bounds<Pixels>) -> f32 {
(position.x - bounds.left()) / bounds.size.width
}
/// The session timeline: footage and gaps, protected bookmark ranges, the
/// clip selection with its handles, and the playhead. Click to seek; drag the
/// selection's edges to change the clip.
/// selection's edges to change the clip; scroll to zoom around the pointer,
/// and scroll sideways (or hold Shift) to pan.
fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -> impl IntoElement {
let length = session.length;
let view = app.editor.view;
let (start, end) = app.editor.selection;
let bounds = app.timeline_bounds.clone();
let at = |time: Duration| relative(view.position(time));
let track = div()
.absolute()
.left_0()
.right_0()
.top(px(30.))
.h(px(8.))
.flex()
.rounded_full()
.overflow_hidden()
.children(session.spans.iter().map(|span| {
let width = fraction(span.end, length) - fraction(span.start, length);
let piece = div().h_full().w(relative(width));
match span.kind {
SpanKind::Footage => piece.bg(theme::footage()),
SpanKind::Missing => piece.bg(pattern_slash(theme::destructive_alpha(0.7), 2., 4.)),
SpanKind::Removed => piece.bg(pattern_slash(Hsla::from(theme::faint()), 2., 4.)),
}
}));
let spans = session.spans.iter().filter_map(|span| {
let (left, width) = placed(&view, span.start, span.end)?;
let piece = div()
.absolute()
.top(px(30.))
.h(px(8.))
.left(relative(left))
.w(relative(width));
Some(match span.kind {
SpanKind::Footage => piece.bg(theme::footage()),
SpanKind::Missing => piece.bg(pattern_slash(theme::destructive_alpha(0.7), 2., 4.)),
SpanKind::Removed => piece.bg(pattern_slash(Hsla::from(theme::faint()), 2., 4.)),
})
});
let protected = session
.bookmarks
.iter()
.filter(|b| b.protected)
.map(|bookmark| {
.filter_map(|bookmark| {
let (from, to) = bookmark.range(length);
div()
.absolute()
.top(px(26.))
.h_4()
.left(relative(fraction(from, length)))
.w(relative(fraction(to, length) - fraction(from, length)))
.rounded_sm()
.bg(Hsla::from(theme::warning()).opacity(0.12))
let (left, width) = placed(&view, from, to)?;
Some(
div()
.absolute()
.top(px(26.))
.h_4()
.left(relative(left))
.w(relative(width))
.rounded_sm()
.bg(Hsla::from(theme::warning()).opacity(0.12)),
)
});
let markers = session.bookmarks.iter().map(|bookmark| {
div()
.absolute()
.top(px(4.))
.left(relative(fraction(bookmark.at, length)))
.flex()
.flex_col()
.items_center()
.ml(px(-6.))
.child(icon("bookmark").size_3().text_color(theme::warning()))
.child(
div()
.w(px(1.))
.h(px(34.))
.bg(Hsla::from(theme::warning()).opacity(0.6)),
)
});
let markers = session
.bookmarks
.iter()
.filter(|bookmark| (0. ..=1.).contains(&view.position(bookmark.at)))
.map(|bookmark| {
div()
.absolute()
.top(px(4.))
.left(at(bookmark.at))
.flex()
.flex_col()
.items_center()
.ml(px(-6.))
.child(icon("bookmark").size_3().text_color(theme::warning()))
.child(
div()
.w(px(1.))
.h(px(34.))
.bg(Hsla::from(theme::warning()).opacity(0.6)),
)
});
let handle = || div().w(px(4.)).h_5().rounded_sm().bg(theme::foreground());
let selection = div()
.absolute()
.top(px(22.))
.h_6()
.left(relative(fraction(start, length)))
.w(relative(fraction(end, length) - fraction(start, length)))
.left(at(start))
.w(relative(view.position(end) - view.position(start)))
.flex()
.items_center()
.justify_between()
@@ -479,7 +492,7 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
.absolute()
.top(px(14.))
.bottom(px(10.))
.left(relative(fraction(app.editor.playhead, length)))
.left(at(app.editor.playhead))
.w(px(2.))
.ml(px(-1.))
.rounded_full()
@@ -489,11 +502,12 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
div()
.text_xs()
.text_color(theme::faint())
.child(format_clock(length.mul_f32(quarter as f32 / 4.)))
.child(format_clock(view.time_at(quarter as f32 / 4.)))
});
let on_down_bounds = bounds.clone();
let on_move_bounds = bounds.clone();
let on_scroll_bounds = bounds.clone();
div()
.flex()
.flex_col()
@@ -513,12 +527,22 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
div()
.relative()
.size_full()
.overflow_hidden()
.child(
canvas(move |area, _, _| bounds.set(Some(area)), |_, _, _, _| {})
.absolute()
.size_full(),
)
.child(track)
.child(
div()
.absolute()
.top(px(30.))
.h(px(8.))
.w_full()
.rounded_full()
.bg(theme::raised()),
)
.children(spans)
.children(protected)
.child(selection)
.children(markers)
@@ -530,9 +554,11 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
let Some(area) = on_down_bounds.get() else {
return;
};
let time = time_at(event.position, area, length);
let view = app.editor.view;
let time = view.time_at(position_in(event.position, area));
let (start, end) = app.editor.selection;
let reach = length.mul_f32(HANDLE_REACH / f32::from(area.size.width));
let reach = (view.end() - view.start())
.mul_f32(HANDLE_REACH / f32::from(area.size.width));
let near = |edge: Duration| time.abs_diff(edge) <= reach;
app.drag = Some(if near(start) {
Drag::SelectionStart
@@ -553,7 +579,7 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
app.drag = None;
return;
}
let time = time_at(event.position, area, length);
let time = app.editor.view.time_at(position_in(event.position, area));
let (start, end) = app.editor.selection;
let minimum = Duration::from_secs(1);
match drag {
@@ -573,7 +599,67 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
app.drag = None;
cx.notify();
}),
),
)
.on_scroll_wheel(cx.listener(move |app, event: &ScrollWheelEvent, _, cx| {
let Some(area) = on_scroll_bounds.get() else {
return;
};
let delta = event.delta.pixel_delta(px(SCROLL_LINE));
let width = f32::from(area.size.width);
let (dx, dy) = (f32::from(delta.x), f32::from(delta.y));
if event.modifiers.shift || dx.abs() > dy.abs() {
let sideways = if dx.abs() > dy.abs() { dx } else { dy };
app.editor.view.pan_by(-sideways / width);
} else {
// Scrolling up (positive) zooms in around the pointer.
app.editor.view.zoom_by(
(-dy * ZOOM_PER_PIXEL).exp(),
position_in(event.position, area),
);
}
cx.stop_propagation();
cx.notify();
})),
)
.child(
div()
.flex()
.items_center()
.gap_3()
.px_3()
.child(div().flex_1().flex().justify_between().children(ticks))
.when(view.is_zoomed(), |row| {
row.child(
div()
.flex()
.items_center()
.gap_2()
.text_xs()
.text_color(theme::subtle())
.child(format!("{:.1}×", view.zoom()))
.child(
div()
.id("zoom-fit")
.px_1p5()
.rounded_sm()
.cursor_pointer()
.text_color(theme::primary_light())
.hover(|style| style.bg(theme::hover()))
.child("Fit")
.on_click(cx.listener(move |app, _: &ClickEvent, _, cx| {
app.editor.view = TimelineView::full(length);
cx.notify();
})),
),
)
})
.when(!view.is_zoomed(), |row| {
row.child(
div()
.text_xs()
.text_color(theme::faint())
.child("Scroll to zoom"),
)
}),
)
.child(div().flex().justify_between().px_3().children(ticks))
}
+38 -64
View File
@@ -1,20 +1,16 @@
//! Recording, audio, clip and storage settings. Changes apply to this run;
//! saving them comes with settings persistence.
use std::time::Duration;
use clipbin_core::{
Settings, VideoCodec,
library::{format_bytes, format_length},
};
use clipbin_core::{Settings, VideoCodec, library::format_bytes};
use gpui::{
AnyElement, ClickEvent, Context, Div, ElementId, FontWeight, IntoElement, SharedString, div,
prelude::*, px,
AnyElement, ClickEvent, Context, Div, ElementId, Entity, FontWeight, IntoElement, SharedString,
div, prelude::*, px,
};
use crate::{
app::ClipbinApp,
components::{icon, page_header},
text_field::TextField,
theme,
};
@@ -22,7 +18,6 @@ type Update = fn(&mut Settings);
pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
let settings = &app.settings;
let seconds = Duration::from_secs;
let resolution = segmented(
"resolution",
@@ -75,53 +70,6 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
}),
],
);
let clip_length = segmented(
"clip",
cx,
&[
(
"30s",
settings.clip_duration == seconds(30),
|s: &mut Settings| s.clip_duration = Duration::from_secs(30),
),
("1m", settings.clip_duration == seconds(60), |s| {
s.clip_duration = Duration::from_secs(60)
}),
("2m", settings.clip_duration == seconds(120), |s| {
s.clip_duration = Duration::from_secs(120)
}),
("5m", settings.clip_duration == seconds(300), |s| {
s.clip_duration = Duration::from_secs(300)
}),
],
);
let budget = segmented(
"budget",
cx,
&[
(
"25 GB",
settings.unprotected_session_limit_bytes == 25_000_000_000,
|s: &mut Settings| s.unprotected_session_limit_bytes = 25_000_000_000,
),
(
"50 GB",
settings.unprotected_session_limit_bytes == 50_000_000_000,
|s| s.unprotected_session_limit_bytes = 50_000_000_000,
),
(
"100 GB",
settings.unprotected_session_limit_bytes == 100_000_000_000,
|s| s.unprotected_session_limit_bytes = 100_000_000_000,
),
(
"200 GB",
settings.unprotected_session_limit_bytes == 200_000_000_000,
|s| s.unprotected_session_limit_bytes = 200_000_000_000,
),
],
);
div()
.flex()
.flex_col()
@@ -158,15 +106,20 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
)
.child(
section("bookmark", "Clips and bookmarks")
.child(setting("Clip hotkey saves", "How far back the clip hotkey reaches.", clip_length))
.child(setting(
"Clip hotkey saves",
"How far back the clip hotkey reaches, as m:ss. Between 0:05 and 30:00.",
field(&app.fields.clip, "back"),
))
.child(setting(
"Bookmarks keep",
"Footage protected around each bookmark.",
value(format!(
"{} before, {} after",
format_length(settings.bookmark_before),
format_length(settings.bookmark_after)
)),
"Footage protected around each bookmark until you release it, as m:ss.",
div()
.flex()
.items_center()
.gap_3()
.child(labelled("Before", &app.fields.bookmark_before))
.child(labelled("After", &app.fields.bookmark_after)),
))
.child(setting("Hotkeys", "Global hotkeys arrive with the hotkey integration.", value("Not set yet".into()))),
)
@@ -175,7 +128,7 @@ pub fn render(app: &ClipbinApp, cx: &mut Context<ClipbinApp>) -> AnyElement {
.child(setting(
"Session footage budget",
"Oldest unprotected footage is removed beyond this. Bookmarks and clips don't count.",
budget,
field(&app.fields.budget, "GB"),
))
.child(setting(
"In use",
@@ -230,6 +183,7 @@ fn setting(title: &'static str, description: &'static str, control: impl IntoEle
.flex()
.flex_col()
.flex_1()
.min_w_0()
.gap_0p5()
.child(div().text_sm().text_color(theme::text()).child(title))
.child(
@@ -242,6 +196,26 @@ fn setting(title: &'static str, description: &'static str, control: impl IntoEle
.child(control)
}
/// A text field with a unit or label after it.
fn field(field: &Entity<TextField>, suffix: &'static str) -> Div {
div()
.flex()
.items_center()
.gap_2()
.child(field.clone())
.child(div().text_xs().text_color(theme::subtle()).child(suffix))
}
/// A text field with a label before it.
fn labelled(label: &'static str, field: &Entity<TextField>) -> Div {
div()
.flex()
.items_center()
.gap_2()
.child(div().text_xs().text_color(theme::subtle()).child(label))
.child(field.clone())
}
fn value(text: String) -> Div {
div().text_sm().text_color(theme::muted()).child(text)
}
+468
View File
@@ -0,0 +1,468 @@
//! A single-line text field for short values such as sizes and durations.
//!
//! Adapted from GPUI's `examples/input.rs` (Apache-2.0), restricted to the
//! characters a field accepts so cursor movement can work on bytes.
use std::ops::Range;
use gpui::{
App, Bounds, ClipboardItem, Context, CursorStyle, ElementId, ElementInputHandler, Entity,
EntityInputHandler, EventEmitter, FocusHandle, Focusable, GlobalElementId, KeyBinding,
LayoutId, MouseButton, MouseDownEvent, PaintQuad, Pixels, Point, ShapedLine, SharedString,
Style, TextRun, UTF16Selection, Window, actions, div, fill, point, prelude::*, px, relative,
size,
};
use crate::theme;
actions!(
text_field,
[
Backspace, Delete, Left, Right, SelectAll, Home, End, Paste, Copy, Cut, Commit, Cancel
]
);
const CONTEXT: &str = "TextField";
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
KeyBinding::new("delete", Delete, Some(CONTEXT)),
KeyBinding::new("left", Left, Some(CONTEXT)),
KeyBinding::new("right", Right, Some(CONTEXT)),
KeyBinding::new("home", Home, Some(CONTEXT)),
KeyBinding::new("end", End, Some(CONTEXT)),
KeyBinding::new("secondary-a", SelectAll, Some(CONTEXT)),
KeyBinding::new("secondary-v", Paste, Some(CONTEXT)),
KeyBinding::new("secondary-c", Copy, Some(CONTEXT)),
KeyBinding::new("secondary-x", Cut, Some(CONTEXT)),
KeyBinding::new("enter", Commit, Some(CONTEXT)),
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
]);
}
/// Sent when the user confirms a value with Enter or by leaving the field.
pub struct Committed(pub String);
pub struct TextField {
focus_handle: FocusHandle,
content: String,
/// The value shown when not editing; Escape and rejected values return to it.
value: String,
accepts: fn(char) -> bool,
selected_range: Range<usize>,
last_layout: Option<ShapedLine>,
last_bounds: Option<Bounds<Pixels>>,
}
impl EventEmitter<Committed> for TextField {}
impl TextField {
pub fn new(
value: impl Into<String>,
accepts: fn(char) -> bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
cx.on_blur(&focus_handle, window, |field, _, cx| {
field.commit(&Commit, cx)
})
.detach();
// Focusing a field selects its value, ready to be replaced.
cx.on_focus(&focus_handle, window, |field, _, cx| {
field.selected_range = 0..field.content.len();
cx.notify();
})
.detach();
let value = value.into();
Self {
focus_handle,
content: value.clone(),
value,
accepts,
selected_range: 0..0,
last_layout: None,
last_bounds: None,
}
}
/// Shows a new value, unless the user has unsaved edits in the field.
pub fn set_value(&mut self, value: String, window: &Window, cx: &mut Context<Self>) {
let editing = self.focus_handle.is_focused(window) && self.content != self.value;
if editing || value == self.value {
return;
}
self.content = value.clone();
self.value = value;
self.selected_range = 0..0;
cx.notify();
}
fn commit(&mut self, _: &Commit, cx: &mut Context<Self>) {
if self.content != self.value {
cx.emit(Committed(self.content.clone()));
}
// The owner calls set_value with the accepted value; otherwise this
// restores the previous one.
self.content = self.value.clone();
self.selected_range = self.content.len()..self.content.len();
cx.notify();
}
fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.content = self.value.clone();
self.selected_range = 0..0;
window.blur();
cx.notify();
}
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
let offset = match self.selected_range.is_empty() {
true => self.selected_range.start.saturating_sub(1),
false => self.selected_range.start,
};
self.move_to(offset, cx);
}
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
let offset = match self.selected_range.is_empty() {
true => (self.selected_range.end + 1).min(self.content.len()),
false => self.selected_range.end,
};
self.move_to(offset, cx);
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.content.len(), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.selected_range = 0..self.content.len();
cx.notify();
}
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.selected_range.start = self.selected_range.start.saturating_sub(1);
}
self.replace_text_in_range(None, "", window, cx);
}
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.selected_range.end = (self.selected_range.end + 1).min(self.content.len());
}
self.replace_text_in_range(None, "", window, cx);
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
self.replace_text_in_range(None, &text, window, cx);
}
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(
self.content[self.selected_range.clone()].to_owned(),
));
}
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
self.copy(&Copy, window, cx);
self.replace_text_in_range(None, "", window, cx);
}
fn on_mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.focus_handle.is_focused(window) {
self.move_to(self.index_for_position(event.position), cx);
} else {
window.focus(&self.focus_handle);
}
}
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.selected_range = offset..offset;
cx.notify();
}
fn index_for_position(&self, position: Point<Pixels>) -> usize {
match (&self.last_bounds, &self.last_layout) {
(Some(bounds), Some(line)) => line.closest_index_for_x(position.x - bounds.left()),
_ => self.content.len(),
}
}
}
// Every accepted character is ASCII, so UTF-16 and byte offsets coincide.
impl EntityInputHandler for TextField {
fn text_for_range(
&mut self,
range: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let range = range.start.min(self.content.len())..range.end.min(self.content.len());
actual_range.replace(range.clone());
Some(self.content[range].to_owned())
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.selected_range.clone(),
reversed: false,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
None
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {}
fn replace_text_in_range(
&mut self,
range: Option<Range<usize>>,
new_text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = range.unwrap_or(self.selected_range.clone());
let range = range.start.min(self.content.len())..range.end.min(self.content.len());
let accepted: String = new_text.chars().filter(|c| (self.accepts)(*c)).collect();
self.content.replace_range(range.clone(), &accepted);
let cursor = range.start + accepted.len();
self.selected_range = cursor..cursor;
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range: Option<Range<usize>>,
new_text: &str,
_: Option<Range<usize>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.replace_text_in_range(range, new_text, window, cx);
}
fn bounds_for_range(
&mut self,
range: Range<usize>,
bounds: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let line = self.last_layout.as_ref()?;
Some(Bounds::from_corners(
point(bounds.left() + line.x_for_index(range.start), bounds.top()),
point(bounds.left() + line.x_for_index(range.end), bounds.bottom()),
))
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
let local = self.last_bounds?.localize(&point)?;
self.last_layout.as_ref()?.index_for_x(local.x)
}
}
impl Focusable for TextField {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TextField {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focused = self.focus_handle.is_focused(window);
div()
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.cursor(CursorStyle::IBeam)
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::copy))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(|field, action, _, cx| field.commit(action, cx)))
.on_action(cx.listener(Self::cancel))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.w(px(76.))
.h_8()
.px_2p5()
.flex()
.items_center()
.rounded_md()
.border_1()
.border_color(if focused {
theme::primary()
} else {
theme::border()
})
.bg(theme::surface())
.text_sm()
.text_color(theme::foreground())
.child(TextLine { field: cx.entity() })
}
}
/// Lays out and paints the field's text, selection and cursor.
struct TextLine {
field: Entity<TextField>,
}
struct Painted {
line: ShapedLine,
cursor: Option<PaintQuad>,
selection: Option<PaintQuad>,
}
impl IntoElement for TextLine {
type Element = Self;
fn into_element(self) -> Self {
self
}
}
impl Element for TextLine {
type RequestLayoutState = ();
type PrepaintState = Option<Painted>;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = window.line_height().into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
window: &mut Window,
cx: &mut App,
) -> Option<Painted> {
let field = self.field.read(cx);
let style = window.text_style();
let text = SharedString::from(field.content.clone());
let run = TextRun {
len: text.len(),
font: style.font(),
color: style.color,
background_color: None,
underline: None,
strikethrough: None,
};
let font_size = style.font_size.to_pixels(window.rem_size());
let line = window
.text_system()
.shape_line(text, font_size, &[run], None);
let range = field.selected_range.clone();
let (cursor, selection) = if range.is_empty() {
let x = bounds.left() + line.x_for_index(range.start);
(
Some(fill(
Bounds::new(point(x, bounds.top()), size(px(1.5), bounds.size.height)),
theme::primary_light(),
)),
None,
)
} else {
(
None,
Some(fill(
Bounds::from_corners(
point(bounds.left() + line.x_for_index(range.start), bounds.top()),
point(bounds.left() + line.x_for_index(range.end), bounds.bottom()),
),
theme::primary_alpha(0.4),
)),
)
};
Some(Painted {
line,
cursor,
selection,
})
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
painted: &mut Option<Painted>,
window: &mut Window,
cx: &mut App,
) {
let Some(painted) = painted.take() else {
return;
};
let focus_handle = self.field.read(cx).focus_handle.clone();
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.field.clone()),
cx,
);
let focused = focus_handle.is_focused(window);
if focused && let Some(selection) = painted.selection {
window.paint_quad(selection);
}
let _ = painted
.line
.paint(bounds.origin, window.line_height(), window, cx);
if focused && let Some(cursor) = painted.cursor {
window.paint_quad(cursor);
}
self.field.update(cx, |field, _| {
field.last_layout = Some(painted.line);
field.last_bounds = Some(bounds);
});
}
}
+119
View File
@@ -0,0 +1,119 @@
//! The visible part of a session timeline, zoomed and panned with the wheel.
use std::time::Duration;
/// The narrowest window the timeline zooms to.
const MIN_SPAN: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimelineView {
start: Duration,
span: Duration,
length: Duration,
}
impl TimelineView {
pub fn full(length: Duration) -> Self {
Self {
start: Duration::ZERO,
span: length.max(Duration::from_secs(1)),
length,
}
}
pub fn start(&self) -> Duration {
self.start
}
pub fn end(&self) -> Duration {
self.start + self.span
}
/// How many times closer than the whole session the view is.
pub fn zoom(&self) -> f32 {
self.length.as_secs_f32() / self.span.as_secs_f32()
}
pub fn is_zoomed(&self) -> bool {
self.span < self.length
}
/// Where `time` sits across the view: 0 at its left edge, 1 at its right.
/// Times outside the view fall outside 0..=1.
pub fn position(&self, time: Duration) -> f32 {
(time.as_secs_f32() - self.start.as_secs_f32()) / self.span.as_secs_f32()
}
/// The time at a position across the view, kept within the session.
pub fn time_at(&self, position: f32) -> Duration {
(self.start + self.span.mul_f32(position.clamp(0., 1.))).min(self.length)
}
/// Scales the visible span by `factor` (below 1 zooms in), keeping the
/// time under `anchor` (a position across the view) where it is.
pub fn zoom_by(&mut self, factor: f32, anchor: f32) {
let anchor = anchor.clamp(0., 1.);
let anchor_time = self.start.as_secs_f32() + self.span.as_secs_f32() * anchor;
let span = (self.span.as_secs_f32() * factor).clamp(
MIN_SPAN.min(self.length).as_secs_f32(),
self.length.as_secs_f32(),
);
self.span = Duration::from_secs_f32(span);
self.set_start(anchor_time - span * anchor);
}
/// Moves the view by a fraction of its width (positive moves later).
pub fn pan_by(&mut self, fraction: f32) {
self.set_start(self.start.as_secs_f32() + self.span.as_secs_f32() * fraction);
}
fn set_start(&mut self, start: f32) {
let latest = self.length.saturating_sub(self.span).as_secs_f32();
self.start = Duration::from_secs_f32(start.clamp(0., latest));
}
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: Duration = Duration::from_secs(3600);
#[test]
fn zooming_keeps_the_time_under_the_pointer() {
let mut view = TimelineView::full(HOUR);
let anchor = 0.25;
let before = view.time_at(anchor);
view.zoom_by(0.5, anchor);
assert_eq!(view.zoom(), 2.);
assert!(view.time_at(anchor).abs_diff(before) < Duration::from_millis(10));
}
#[test]
fn the_view_stays_within_the_session() {
let mut view = TimelineView::full(HOUR);
view.zoom_by(0.1, 1.);
assert_eq!(view.end(), HOUR);
view.pan_by(5.);
assert_eq!(view.end(), HOUR);
view.pan_by(-50.);
assert_eq!(view.start(), Duration::ZERO);
view.zoom_by(100., 0.5);
assert!(!view.is_zoomed());
}
#[test]
fn zoom_stops_at_ten_seconds() {
let mut view = TimelineView::full(HOUR);
view.zoom_by(0.000_001, 0.5);
assert_eq!(view.end() - view.start(), MIN_SPAN);
}
#[test]
fn positions_outside_the_view_fall_outside_its_edges() {
let mut view = TimelineView::full(HOUR);
view.zoom_by(0.5, 1.);
assert!(view.position(Duration::ZERO) < 0.);
assert_eq!(view.position(HOUR), 1.);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "rig-pointer"
description = "Development tool: drives a virtual pointer in the headless test rig."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[dependencies]
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
[lints]
workspace = true
+104
View File
@@ -0,0 +1,104 @@
//! Drives a virtual pointer on a wlroots compositor (the headless rig's Sway),
//! which has no input devices of its own. Reads one command per line on stdin:
//!
//! move <x> <y> move to output coordinates
//! click press and release the left button
//! down / up press or release the left button
//! scroll <dy> scroll vertically (negative scrolls up), in wheel steps
//! sleep <seconds>
//!
//! echo -e "move 100 200\nclick" | rig-pointer <output-width> <output-height>
use std::{io::BufRead, thread, time::Duration};
use wayland_client::{
Connection, Dispatch, QueueHandle,
globals::{GlobalListContents, registry_queue_init},
protocol::{wl_pointer, wl_registry, wl_seat::WlSeat},
};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
const BUTTON_LEFT: u32 = 0x110;
struct State;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
fn event(
_: &mut Self,
_: &wl_registry::WlRegistry,
_: wl_registry::Event,
_: &GlobalListContents,
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
macro_rules! ignore_events {
($($proxy:ty),*) => {$(
impl Dispatch<$proxy, ()> for State {
fn event(_: &mut Self, _: &$proxy, _: <$proxy as wayland_client::Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
)*};
}
ignore_events!(WlSeat, ZwlrVirtualPointerManagerV1, ZwlrVirtualPointerV1);
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<u32> = std::env::args()
.skip(1)
.map(|arg| arg.parse())
.collect::<Result<_, _>>()?;
let [width, height] = args[..] else {
return Err("usage: rig-pointer <output-width> <output-height> < commands".into());
};
let connection = Connection::connect_to_env()?;
let (globals, mut queue) = registry_queue_init::<State>(&connection)?;
let handle = queue.handle();
let seat: WlSeat = globals.bind(&handle, 1..=7, ())?;
let manager: ZwlrVirtualPointerManagerV1 = globals.bind(&handle, 1..=2, ())?;
let pointer = manager.create_virtual_pointer(Some(&seat), &handle, ());
queue.roundtrip(&mut State)?;
let start = std::time::Instant::now();
let time = || start.elapsed().as_millis() as u32;
for line in std::io::stdin().lock().lines() {
let line = line?;
let words: Vec<&str> = line.split_whitespace().collect();
let button = |state| {
pointer.button(time(), BUTTON_LEFT, state);
pointer.frame();
};
match words[..] {
["move", x, y] => {
pointer.motion_absolute(time(), x.parse()?, y.parse()?, width, height);
pointer.frame();
}
["down"] => button(wl_pointer::ButtonState::Pressed),
["up"] => button(wl_pointer::ButtonState::Released),
["click"] => {
button(wl_pointer::ButtonState::Pressed);
button(wl_pointer::ButtonState::Released);
}
["scroll", steps] => {
let steps: i32 = steps.parse()?;
pointer.axis_source(wl_pointer::AxisSource::Wheel);
pointer.axis_discrete(
time(),
wl_pointer::Axis::VerticalScroll,
15. * steps as f64,
steps,
);
pointer.frame();
}
["sleep", seconds] => thread::sleep(Duration::from_secs_f64(seconds.parse()?)),
[] => continue,
_ => return Err(format!("unknown command: {line}").into()),
}
queue.roundtrip(&mut State)?;
}
thread::sleep(Duration::from_millis(200));
Ok(())
}