feat: typed settings fields, timeline zoom, faster dev builds
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:
Generated
+21
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,64 +392,72 @@ 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()
|
||||
let spans = session.spans.iter().filter_map(|span| {
|
||||
let (left, width) = placed(&view, span.start, span.end)?;
|
||||
let piece = 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 {
|
||||
.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);
|
||||
let (left, width) = placed(&view, from, to)?;
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(26.))
|
||||
.h_4()
|
||||
.left(relative(fraction(from, length)))
|
||||
.w(relative(fraction(to, length) - fraction(from, length)))
|
||||
.left(relative(left))
|
||||
.w(relative(width))
|
||||
.rounded_sm()
|
||||
.bg(Hsla::from(theme::warning()).opacity(0.12))
|
||||
.bg(Hsla::from(theme::warning()).opacity(0.12)),
|
||||
)
|
||||
});
|
||||
|
||||
let markers = session.bookmarks.iter().map(|bookmark| {
|
||||
let markers = session
|
||||
.bookmarks
|
||||
.iter()
|
||||
.filter(|bookmark| (0. ..=1.).contains(&view.position(bookmark.at)))
|
||||
.map(|bookmark| {
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(4.))
|
||||
.left(relative(fraction(bookmark.at, length)))
|
||||
.left(at(bookmark.at))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
@@ -463,8 +476,8 @@ fn timeline(app: &ClipbinApp, session: &Session, cx: &mut Context<ClipbinApp>) -
|
||||
.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();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(div().flex().justify_between().px_3().children(ticks))
|
||||
})
|
||||
.when(!view.is_zoomed(), |row| {
|
||||
row.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme::faint())
|
||||
.child("Scroll to zoom"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user