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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7f20b5146
commit
d0175edb7b
22 files changed
+2232
-25
No files matched your search
+48
-12
@@ -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 })
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user