diff --git a/Cargo.lock b/Cargo.lock index e2702b7..638f492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4414,6 +4414,14 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rig-pointer" +version = "0.1.0" +dependencies = [ + "wayland-client", + "wayland-protocols-wlr", +] + [[package]] name = "ring" version = "0.17.14" @@ -6156,6 +6164,19 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.13", + "wayland-scanner", +] + [[package]] name = "wayland-scanner" version = "0.31.11" diff --git a/Cargo.toml b/Cargo.toml index 3e5cf88..4b247a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/clipbin-desktop", "crates/clipbin-obs", "crates/clipbin-probe", + "crates/rig-pointer", ] default-members = ["crates/clipbin-desktop"] resolver = "3" @@ -30,6 +31,11 @@ unsafe_code = "forbid" [profile.dev] debug = 1 +# GPUI is far too slow to use unoptimised; optimise dependencies even in dev +# builds (our own crates stay quick to rebuild). +[profile.dev.package."*"] +opt-level = 2 + [profile.release] lto = "thin" codegen-units = 1 diff --git a/README.md b/README.md index 7e9b920..7f300d6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,9 @@ cargo test --locked --workspace cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings ``` -Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). +Recording validation on Linux, headless: `scripts/validate/linux-rig.sh`. Interface screenshots, headless: `scripts/rig/screenshot-ui.sh` (screens: sessions, session, clips, settings, failed, empty). The rig has no input devices; `rig-pointer` drives a virtual pointer (`move`, `click`, `down`/`up`, `scroll`, `sleep` on stdin) and `wtype` types. `CLIPBIN_TRACE_FRAMES=1` logs frame times. + +Development builds optimise dependencies (`[profile.dev.package."*"]`): unoptimised GPUI made screens noticeably slow, with first frames of 200–300 ms. The core can also be checked without native UI dependencies: diff --git a/crates/clipbin-core/src/library.rs b/crates/clipbin-core/src/library.rs index 258d3a2..3780d96 100644 --- a/crates/clipbin-core/src/library.rs +++ b/crates/clipbin-core/src/library.rs @@ -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 { + let parts: Vec = text + .trim() + .split(':') + .map(|part| part.parse().ok()) + .collect::>()?; + 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"); diff --git a/crates/clipbin-desktop/src/app.rs b/crates/clipbin-desktop/src/app.rs index 7adb3d2..417b405 100644 --- a/crates/clipbin-desktop/src/app.rs +++ b/crates/clipbin-desktop/src/app.rs @@ -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, /// Where the timeline was last laid out, to turn pointer positions into times. pub timeline_bounds: Rc>>>, + pub fields: SettingsFields, +} + +/// The typed settings and the fields that edit them. +pub struct SettingsFields { + pub budget: Entity, + pub clip: Entity, + pub bookmark_before: Entity, + pub bookmark_after: Entity, +} + +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 { + parse_duration(text).filter(|time| (min..=max).contains(&time.as_secs())) +} + +impl SettingsFields { + fn new(settings: &Settings, window: &mut Window, cx: &mut Context) -> Self { + let field = |value: String, + accepts, + apply: fn(&mut Settings, &str) -> bool, + window: &mut Window, + cx: &mut Context| { + 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::() { + 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) { + 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 { 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) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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), diff --git a/crates/clipbin-desktop/src/main.rs b/crates/clipbin-desktop/src/main.rs index 208c06f..d59234f 100644 --- a/crates/clipbin-desktop/src/main.rs +++ b/crates/clipbin-desktop/src/main.rs @@ -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 { diff --git a/crates/clipbin-desktop/src/screens/editor.rs b/crates/clipbin-desktop/src/screens/editor.rs index 71c7a56..8749f59 100644 --- a/crates/clipbin-desktop/src/screens/editor.rs +++ b/crates/clipbin-desktop/src/screens/editor.rs @@ -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) -> 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, bounds: Bounds, length: Duration) -> Duration { - let x = (position.x - bounds.left()) / bounds.size.width; - length.mul_f32(x.clamp(0., 1.)) +fn position_in(position: Point, bounds: Bounds) -> 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) -> 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) - .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) - 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) - 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) - 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) - 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) - 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)) } diff --git a/crates/clipbin-desktop/src/screens/settings.rs b/crates/clipbin-desktop/src/screens/settings.rs index 9947bbb..f308231 100644 --- a/crates/clipbin-desktop/src/screens/settings.rs +++ b/crates/clipbin-desktop/src/screens/settings.rs @@ -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) -> 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) -> 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) -> 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) -> 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, 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) -> 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) } diff --git a/crates/clipbin-desktop/src/text_field.rs b/crates/clipbin-desktop/src/text_field.rs new file mode 100644 index 0000000..83d2418 --- /dev/null +++ b/crates/clipbin-desktop/src/text_field.rs @@ -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, + last_layout: Option, + last_bounds: Option>, +} + +impl EventEmitter for TextField {} + +impl TextField { + pub fn new( + value: impl Into, + accepts: fn(char) -> bool, + window: &mut Window, + cx: &mut Context, + ) -> 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) { + 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) { + 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.content = self.value.clone(); + self.selected_range = 0..0; + window.blur(); + cx.notify(); + } + + fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + 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) { + 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.move_to(0, cx); + } + + fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = 0..self.content.len(); + cx.notify(); + } + + fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + 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) { + 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) { + 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) { + 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.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, + ) { + 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.selected_range = offset..offset; + cx.notify(); + } + + fn index_for_position(&self, position: Point) -> 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, + actual_range: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + 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, + ) -> Option { + Some(UTF16Selection { + range: self.selected_range.clone(), + reversed: false, + }) + } + + fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { + None + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut Context) {} + + fn replace_text_in_range( + &mut self, + range: Option>, + new_text: &str, + _: &mut Window, + cx: &mut Context, + ) { + 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>, + new_text: &str, + _: Option>, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_text_in_range(range, new_text, window, cx); + } + + fn bounds_for_range( + &mut self, + range: Range, + bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + 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, + _: &mut Window, + _: &mut Context, + ) -> Option { + 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) -> 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, +} + +struct Painted { + line: ShapedLine, + cursor: Option, + selection: Option, +} + +impl IntoElement for TextLine { + type Element = Self; + + fn into_element(self) -> Self { + self + } +} + +impl Element for TextLine { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + 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, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) -> Option { + 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, + _: &mut (), + painted: &mut Option, + 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); + }); + } +} diff --git a/crates/clipbin-desktop/src/timeline_view.rs b/crates/clipbin-desktop/src/timeline_view.rs new file mode 100644 index 0000000..d3e9c77 --- /dev/null +++ b/crates/clipbin-desktop/src/timeline_view.rs @@ -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.); + } +} diff --git a/crates/rig-pointer/Cargo.toml b/crates/rig-pointer/Cargo.toml new file mode 100644 index 0000000..7909868 --- /dev/null +++ b/crates/rig-pointer/Cargo.toml @@ -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 diff --git a/crates/rig-pointer/src/main.rs b/crates/rig-pointer/src/main.rs new file mode 100644 index 0000000..b5b70ac --- /dev/null +++ b/crates/rig-pointer/src/main.rs @@ -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 move to output coordinates +//! click press and release the left button +//! down / up press or release the left button +//! scroll scroll vertically (negative scrolls up), in wheel steps +//! sleep +//! +//! echo -e "move 100 200\nclick" | rig-pointer + +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 for State { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } +} + +macro_rules! ignore_events { + ($($proxy:ty),*) => {$( + impl Dispatch<$proxy, ()> for State { + fn event(_: &mut Self, _: &$proxy, _: <$proxy as wayland_client::Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + )*}; +} +ignore_events!(WlSeat, ZwlrVirtualPointerManagerV1, ZwlrVirtualPointerV1); + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args() + .skip(1) + .map(|arg| arg.parse()) + .collect::>()?; + let [width, height] = args[..] else { + return Err("usage: rig-pointer < commands".into()); + }; + let connection = Connection::connect_to_env()?; + let (globals, mut queue) = registry_queue_init::(&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(()) +}