T26c Media: video

Scrubber shows what has loaded, what has played and its chapters, names
the time under the pointer, seeks by press and gpui's drag, and steps by
the slider's keys; over_media lights it for a dark layer. VideoPlayer
shows the host's frame in its shape with a caption and a control bar over
it: the scrubber, play, the time, speed, captions, picture in picture and
full screen, each only with its handler; the bar rests after a still
pointer while it plays, on one wake from the executor's timer. Keys:
Space and K, J and L, the arrows, C and F. VideoThumbnailStrip lays frames
along the length under a playhead with a trim whose handles drag and whose
ends I and O set. SubtitleEditor edits cues in place: times as mm:ss.mmm,
kept in order, a time that does not read kept out with the reason shown.

InlineEdit is a Tab stop with the ring; it hands focus back after Enter or
Escape and keeps its text on Enter's release. overlays::media_button is
shared by the lightbox and the player's bar.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 17:07:26 -04:00
1 parent 99b6ccd798
commit d125314d39
22 files changed
+2232 -25

No files matched your search

+48 -12
View File
@@ -1,24 +1,25 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
prelude::*,
App, Context, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
Window, div, prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
primitives::{Icon, IconName},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
pub(crate) type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A text being edited in place: its field while open, and who hears the kept text.
/// A text being edited in place: its field while open, who hears the kept text, and where focus goes back after Enter or Escape. An edit that hands focus back keeps its text on Enter's release, so the release does not open it again.
#[derive(Default)]
pub(crate) struct Editing {
field: Option<(Entity<TextInput>, Subscription)>,
pub on_commit: Option<OnCommit>,
back: Option<FocusHandle>,
}
impl Editing {
@@ -33,10 +34,17 @@ impl Editing {
field.update(cx, |input, cx| input.select(0..all, cx));
window.focus(&field.read(cx).focus().clone());
let owner = state.clone();
let events = window.subscribe(&field, cx, move |_, event, window, cx| {
if matches!(event, InputEvent::Submit | InputEvent::Blur) {
owner.update(cx, |editing, cx| editing.finish(false, window, cx));
}
let events = window.subscribe(&field, cx, move |_, event, window, cx| match event {
InputEvent::Submit => owner.update(cx, |editing, cx| {
if editing.back.is_none() {
editing.finish(false, window, cx);
}
}),
InputEvent::Blur => owner.update(cx, |editing, cx| {
editing.back = None;
editing.finish(false, window, cx);
}),
_ => {}
});
state.update(cx, |editing, cx| {
editing.field = Some((field, events));
@@ -44,6 +52,18 @@ impl Editing {
});
}
/// Focus goes back here after Enter or Escape.
fn returning(&mut self, back: FocusHandle) {
self.back = Some(back);
}
fn give_back(&mut self, window: &mut Window) {
if let Some(back) = self.back.take() {
log::info!("inline edit: focus handed back");
window.focus(&back);
}
}
pub(crate) fn field(&self) -> Option<Entity<TextInput>> {
self.field.as_ref().map(|(field, _)| field.clone())
}
@@ -102,16 +122,29 @@ impl InlineEdit {
impl RenderOnce for InlineEdit {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default());
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone());
if let Some(field) = state.read(cx).field() {
let escape = state.clone();
let (escape, enter) = (state.clone(), state.clone());
return div()
.id(self.id)
.w_full()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |editing, cx| editing.finish(true, window, cx));
escape.update(cx, |editing, cx| {
editing.finish(true, window, cx);
editing.give_back(window);
});
}
})
.on_key_up(move |event, window, cx| {
if event.keystroke.key == "enter" {
cx.stop_propagation();
enter.update(cx, |editing, cx| {
editing.finish(false, window, cx);
editing.give_back(window);
});
}
})
.child(Input::new(&field).size(ControlSize::Sm))
@@ -120,7 +153,7 @@ impl RenderOnce for InlineEdit {
let theme = cx.theme();
let colors = &theme.colors;
let empty = self.value.is_empty();
let (value, start) = (self.value.clone(), state);
let (value, start, back) = (self.value.clone(), state, focus.clone());
let group = SharedString::from(format!("inline-edit-{:?}", self.id));
div()
.id(self.id)
@@ -133,12 +166,15 @@ impl RenderOnce for InlineEdit {
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.track_focus(&focus)
.focus_ring(cx)
.cursor_text()
.hover(|style| style.bg(colors.hover))
.text_color(if empty { colors.fg_subtle } else { colors.fg })
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("inline edit: editing");
start.update(cx, |editing, _| editing.returning(back.clone()));
Editing::begin(&start, value.to_string(), window, cx);
})
.child(if empty { self.placeholder } else { self.value })
+1
View File
@@ -97,6 +97,7 @@ pub use select::Select;
pub(crate) use select::{Listing, field_button, field_text, listing};
pub(crate) use signature::{Drawing, ink, pen};
pub use signature::{SignaturePad, Stroke};
pub(crate) use slider::keyed;
pub use slider::{RangeSlider, Slider};
pub use stepper::Stepper;
pub use structure::{
+55 -3
View File
@@ -1,11 +1,12 @@
use gpui::{
Context, IntoElement, Modifiers, MouseButton, Render, TestAppContext, Window, point, px,
Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, Render,
SharedString, TestAppContext, VisualTestContext, Window, point, px,
};
use super::setup;
use crate::{
forms::{IconPicker, SignaturePad, Stroke},
primitives::IconName,
forms::{IconPicker, InlineEdit, SignaturePad, Stroke},
primitives::{FocusNext, IconName},
};
struct Icons {
@@ -92,3 +93,54 @@ fn one_move_keeps_both_ends_of_the_stroke(cx: &mut TestAppContext) {
assert_eq!(stroke.len(), 2, "{stroke:?}");
assert_eq!(stroke[1] - stroke[0], point(px(60.0), px(-20.0)));
}
/// An inline edit and the texts it kept.
struct Titled(Vec<SharedString>);
impl Render for Titled {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let value = self.0.last().cloned().unwrap_or_else(|| "Draft".into());
InlineEdit::new("title", value).on_commit(move |text, _, cx| {
view.update(cx, |view, cx| {
view.0.push(text.clone());
cx.notify();
})
})
}
}
/// Presses and releases `key`, a drawn frame between.
fn release(key: &str, cx: &mut VisualTestContext) {
let redraw = |cx: &mut VisualTestContext| {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
};
cx.simulate_keystrokes(key);
redraw(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
redraw(cx);
}
#[gpui::test]
fn an_inline_edit_takes_tab_opens_on_enter_and_takes_focus_back(cx: &mut TestAppContext) {
setup(cx);
cx.update(|cx| cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]));
let (view, cx) = cx.add_window_view(|_, _| Titled(Vec::new()));
cx.update(|window, _| window.activate_window());
cx.update(|window, _| window.focus_next());
release("enter", cx);
cx.simulate_input("Stairs");
release("enter", cx);
release("enter", cx);
cx.simulate_input("Light");
release("enter", cx);
assert_eq!(
view.read_with(cx, |view, _| view.0.clone()),
["Stairs", "Light"],
"focus came back after the first"
);
}