diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541a78..58ed3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add flag to hide input bar. - Adaptive height config option. - Quit on outer region click. +- Marquee-like animation for the itmes that do not fit the window. ## Changes diff --git a/src/config.rs b/src/config.rs index 3ca7460..5abb8c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,6 +105,8 @@ struct ListItems { margin: Margin, #[def = "false"] hide_actions: bool, + #[def = "false"] + hide_marquee: bool, #[def = "60.0"] action_left_margin: f32, #[def = "2.0"] diff --git a/src/config/params.rs b/src/config/params.rs index 5e2ad3c..0153fa8 100644 --- a/src/config/params.rs +++ b/src/config/params.rs @@ -78,6 +78,7 @@ impl<'a> From<&'a Config> for ListParams { .map(Icon::new), margin: config.list_items.margin.clone(), hide_actions: config.list_items.hide_actions, + hide_marquee: config.list_items.hide_marquee, action_left_margin: config.list_items.action_left_margin, item_spacing: config.list_items.item_spacing, icon_spacing: config.list_items.icon_spacing, diff --git a/src/draw.rs b/src/draw.rs index 2d1b871..e4b2d94 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -1,6 +1,5 @@ use std::f32::consts; -use oneshot::Sender; pub use raqote::Point; use raqote::{DrawOptions, Path, PathBuilder, Source, StrokeStyle}; @@ -8,6 +7,7 @@ pub use background::Params as BgParams; pub use input_text::Params as InputTextParams; pub use list_view::{ListItem, Params as ListParams}; +use crate::feedback::{self, Feedback, FeedbackListener}; use crate::{style::Radius, Color}; pub type DrawTarget<'a> = raqote::DrawTarget<&'a mut [u32]>; @@ -36,8 +36,8 @@ pub enum Widget<'a, It = std::iter::Empty>> { pub struct Drawables<'a> { counter: u32, - tx: Option>, - rx: Option>, + feedback: Option, + listener: Option, state: &'a mut crate::state::State, background_config: BgParams, input_config: InputTextParams<'a>, @@ -55,12 +55,12 @@ impl<'a> Drawables<'a> { self.state.skip_offset(), self.state.selected_item(), self.state.selected_has_subitems(), - self.tx.take().unwrap(), + self.feedback.take().unwrap(), + self.state.marquee_scroll_offset(), &self.list_config, ), 4 => { - self.state - .update_skip_offset(self.rx.take().unwrap().recv().unwrap()); + self.listener.take().unwrap().apply(self.state); return None; } _ => return None, @@ -94,12 +94,12 @@ pub fn make_drawables<'c: 'it, 's: 'it, 'it>( None }; - let (tx, rx) = oneshot::channel(); + let (feedback, listener) = feedback::channel(); ( Drawables { counter: 0, - tx: Some(tx), - rx: Some(rx), + feedback: Some(feedback), + listener: Some(listener), state, background_config, @@ -120,7 +120,8 @@ impl<'a, It> Widget<'a, It> { skip_offset: usize, selected_item: usize, has_subitems: bool, - tx: Sender, + feedback: Feedback, + marquee_scroll_offset: f32, params: &'a ListParams, ) -> Self { Self::ListView(list_view::ListView::new( @@ -128,7 +129,8 @@ impl<'a, It> Widget<'a, It> { skip_offset, selected_item, has_subitems, - tx, + feedback, + marquee_scroll_offset, params, )) } diff --git a/src/draw/list_view.rs b/src/draw/list_view.rs index 9bc5dfb..39a4f23 100644 --- a/src/draw/list_view.rs +++ b/src/draw/list_view.rs @@ -1,15 +1,18 @@ use std::marker::PhantomData; -use oneshot::Sender; -use raqote::{AntialiasMode, DrawOptions, Image, Point}; +use raqote::{AntialiasMode, DrawOptions, Image, IntPoint, IntRect, Point}; use super::{DrawTarget, Drawable, Space}; +use crate::feedback::Feedback; use crate::font::{Font, FontBackend, FontColor}; use crate::state::ContinuousMatch; use crate::style::Margin; use crate::Color; use unicode_segmentation::UnicodeSegmentation; +// Padding past the measured text width so the shifted end-point stays past the last glyph. +const MARQUEE_DRAW_PADDING: f32 = 100.0; + pub struct Params { pub font: Font, pub font_size: u16, @@ -20,6 +23,7 @@ pub struct Params { pub fallback_icon: Option, pub margin: Margin, pub hide_actions: bool, + pub hide_marquee: bool, pub action_left_margin: f32, pub item_spacing: f32, pub icon_spacing: f32, @@ -37,7 +41,8 @@ pub struct ListView<'a, It> { skip_offset: usize, selected_item: usize, has_subitems: bool, - new_skip: Sender, + feedback: Feedback, + marquee_scroll_offset: f32, params: &'a Params, _tparam: PhantomData<&'a ()>, } @@ -79,7 +84,8 @@ impl<'a, It> ListView<'a, It> { skip_offset: usize, selected_item: usize, has_subitems: bool, - new_skip: Sender, + feedback: Feedback, + marquee_scroll_offset: f32, params: &'a Params, ) -> Self { Self { @@ -87,7 +93,8 @@ impl<'a, It> ListView<'a, It> { skip_offset, selected_item, has_subitems, - new_skip, + feedback, + marquee_scroll_offset, params, _tparam: PhantomData, } @@ -98,7 +105,7 @@ impl<'a, It> Drawable for ListView<'a, It> where It: Iterator>, { - fn draw(self, dt: &mut DrawTarget<'_>, scale: u16, space: Space, point: Point) -> Space { + fn draw(mut self, dt: &mut DrawTarget<'_>, scale: u16, space: Space, point: Point) -> Space { let (margin, item_spacing, entry_height) = self.params.common_bounds(scale); let icon_size = self.params.icon_size.unwrap_or(0) * scale; let icon_spacing = self.params.icon_spacing * f32::from(scale); @@ -124,7 +131,7 @@ where (self.selected_item - self.skip_offset, self.skip_offset) }; - self.new_skip.send(skip_offset).unwrap(); + self.feedback.report_skip_offset(skip_offset); for (i, item) in self .items @@ -207,7 +214,40 @@ where }; let font = &self.params.font; - font.draw(dt, item.name, font_size, pos, end_pos, color, &draw_opts); + + if i == selected_item && !self.params.hide_marquee { + let available_width = end_pos.x - pos.x; + let text_width = font.measure_text_width(item.name, font_size); + + if text_width > available_width { + self.feedback + .report_marquee_overflow(text_width - available_width); + + let clip = IntRect::new( + IntPoint::new(pos.x as i32, 0), + IntPoint::new(end_pos.x as i32, dt.height()), + ); + dt.push_clip_rect(clip); + + let shifted_pos = Point::new(pos.x - self.marquee_scroll_offset, pos.y); + let wide_end = Point::new(pos.x + text_width + MARQUEE_DRAW_PADDING, end_pos.y); + font.draw( + dt, + item.name, + font_size, + shifted_pos, + wide_end, + color, + &draw_opts, + ); + + dt.pop_clip(); + } else { + font.draw(dt, item.name, font_size, pos, end_pos, color, &draw_opts); + } + } else { + font.draw(dt, item.name, font_size, pos, end_pos, color, &draw_opts); + } if i == selected_item && has_subname { if let Some(subname) = item.subname { @@ -258,6 +298,7 @@ mod tests { right: 3., }, hide_actions: false, + hide_marquee: false, action_left_margin: 0., item_spacing: 1.5, icon_spacing: 0.5, diff --git a/src/feedback.rs b/src/feedback.rs new file mode 100644 index 0000000..b7e77b3 --- /dev/null +++ b/src/feedback.rs @@ -0,0 +1,57 @@ +use crate::state::State; + +struct FeedbackData { + skip_offset: usize, + marquee_overflow: f32, +} + +pub struct Feedback { + skip_offset: usize, + marquee_overflow: f32, + tx: Option>, +} + +impl Feedback { + pub fn report_skip_offset(&mut self, skip_offset: usize) { + self.skip_offset = skip_offset; + } + + pub fn report_marquee_overflow(&mut self, overflow: f32) { + self.marquee_overflow = overflow; + } +} + +impl Drop for Feedback { + fn drop(&mut self) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(FeedbackData { + skip_offset: self.skip_offset, + marquee_overflow: self.marquee_overflow, + }); + } + } +} + +pub struct FeedbackListener { + rx: oneshot::Receiver, +} + +impl FeedbackListener { + pub fn apply(self, state: &mut State) { + let data = self.rx.recv().unwrap(); + state.update_skip_offset(data.skip_offset); + state.set_marquee_overflow(data.marquee_overflow); + } +} + +pub fn channel() -> (Feedback, FeedbackListener) { + let (tx, rx) = oneshot::channel(); + ( + Feedback { + skip_offset: 0, + marquee_overflow: 0.0, + tx: Some(tx), + }, + FeedbackListener { rx }, + ) +} diff --git a/src/font.rs b/src/font.rs index 68322d8..346acc5 100644 --- a/src/font.rs +++ b/src/font.rs @@ -25,6 +25,8 @@ pub trait FontBackend: Sized { fn font_by_path(path: &Path) -> Result; + fn measure_text_width(&self, text: &str, font_size: f32) -> f32; + #[allow(clippy::too_many_arguments)] fn draw( &self, diff --git a/src/font/fdue.rs b/src/font/fdue.rs index 9ece82c..a1f684b 100644 --- a/src/font/fdue.rs +++ b/src/font/fdue.rs @@ -182,6 +182,24 @@ impl FontBackend for Font { Font::from_path(path, None) } + fn measure_text_width(&self, text: &str, font_size: f32) -> f32 { + let mut layout = self.layout.borrow_mut(); + layout.reset(&LayoutSettings { + x: 0.0, + y: 0.0, + max_height: Some(font_size), + vertical_align: VerticalAlign::Middle, + wrap_style: WrapStyle::Letter, + ..LayoutSettings::default() + }); + layout.append(&[&self.inner], &TextStyle::new(text, font_size, 0)); + layout + .glyphs() + .last() + .map(|g| g.x + g.width as f32) + .unwrap_or(0.0) + } + fn draw( &self, dt: &mut DrawTarget, diff --git a/src/lib.rs b/src/lib.rs index 2d89134..eb2ba6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub(crate) use draw::DrawTarget; mod color; mod draw; mod exec; +mod feedback; mod font; mod icon; mod input_parser; @@ -24,6 +25,7 @@ macro_rules! prog_name { }; } +#[derive(Clone, Copy)] pub struct Viewport { buf_size: (i32, i32), content_offset: (f32, f32), diff --git a/src/state.rs b/src/state.rs index db40a88..2af970e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -6,6 +6,38 @@ use filtered_lines::FilteredLines; mod filtered_lines; +struct MarqueeState { + scroll_offset: f32, + last_frame_time: Option, + tracked_item: usize, + overflow_width: f32, + pause_remaining: f32, +} + +const SCROLL_SPEED: f32 = 30.0; // px/s +const INITIAL_PAUSE: f32 = 1000.0; // ms +const LOOP_PAUSE: f32 = 500.0; // ms +const END_PAUSE: f32 = 1000.0; // ms +const MIN_TICK_MS: u32 = 50; // ~20 fps cap + +impl MarqueeState { + fn new() -> Self { + Self { + scroll_offset: 0.0, + last_frame_time: None, + tracked_item: 0, + overflow_width: 0.0, + pause_remaining: INITIAL_PAUSE, + } + } + + fn reset(&mut self) { + self.scroll_offset = 0.0; + self.last_frame_time = None; + self.pause_remaining = INITIAL_PAUSE; + } +} + struct InputBuffer { raw_input: String, parsed_input: InputValue<'static>, @@ -53,6 +85,8 @@ pub struct State { selected_subitem: usize, filtered_lines: FilteredLines, inner: Mode, + marquee: MarqueeState, + dirty: bool, } impl State { @@ -64,13 +98,29 @@ impl State { selected_subitem: 0, filtered_lines: FilteredLines::unfiltred(inner.entries_len()), inner, + marquee: MarqueeState::new(), + dirty: true, } } + pub fn mark_dirty(&mut self) { + self.dirty = true; + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + pub fn clear_dirty(&mut self) { + self.dirty = false; + } + pub fn remove_input_char(&mut self) { self.input_buffer.update_input(|input| { input.pop(); - }) + }); + self.marquee.reset(); + self.mark_dirty(); } pub fn remove_input_word(&mut self) { @@ -80,15 +130,21 @@ impl State { } else { input.clear(); } - }) + }); + self.marquee.reset(); + self.mark_dirty(); } pub fn append_to_input(&mut self, s: &str) { - self.input_buffer.update_input(|input| input.push_str(s)) + self.input_buffer.update_input(|input| input.push_str(s)); + self.marquee.reset(); + self.mark_dirty(); } pub fn clear_input(&mut self) { - self.input_buffer.update_input(|input| input.clear()) + self.input_buffer.update_input(|input| input.clear()); + self.marquee.reset(); + self.mark_dirty(); } pub fn eval_input(&mut self, with_fork: bool) -> anyhow::Result<()> { @@ -113,22 +169,88 @@ impl State { .entries_len() .saturating_sub(1) .min(self.selected_item + 1); + self.marquee.reset(); + self.mark_dirty(); } pub fn prev_item(&mut self) { self.selected_subitem = 0; self.selected_item = self.selected_item.saturating_sub(1); + self.marquee.reset(); + self.mark_dirty(); + } + + pub fn update_marquee(&mut self, time_ms: u32) { + if self.selected_item != self.marquee.tracked_item { + self.marquee.reset(); + self.marquee.tracked_item = self.selected_item; + } + + if self.marquee.overflow_width <= 0.0 { + return; + } + + let delta = match self.marquee.last_frame_time { + Some(last) => { + let dt = time_ms.wrapping_sub(last); + if dt < MIN_TICK_MS { + return; + } + dt as f32 + } + None => { + self.marquee.last_frame_time = Some(time_ms); + self.mark_dirty(); + return; + } + }; + self.marquee.last_frame_time = Some(time_ms); + self.mark_dirty(); + + if self.marquee.pause_remaining > 0.0 { + self.marquee.pause_remaining -= delta; + let at_end = self.marquee.scroll_offset >= self.marquee.overflow_width; + if self.marquee.pause_remaining <= 0.0 && at_end { + self.marquee.scroll_offset = 0.0; + self.marquee.pause_remaining = LOOP_PAUSE; + } + return; + } + + self.marquee.scroll_offset += SCROLL_SPEED * delta / 1000.0; + + if self.marquee.scroll_offset >= self.marquee.overflow_width { + self.marquee.scroll_offset = self.marquee.overflow_width; + self.marquee.pause_remaining = END_PAUSE; + } + } + + pub fn marquee_scroll_offset(&self) -> f32 { + self.marquee.scroll_offset + } + + pub fn is_marquee_animating(&self) -> bool { + self.marquee.overflow_width > 0.0 + } + + pub fn set_marquee_overflow(&mut self, overflow: f32) { + self.marquee.overflow_width = overflow; + if overflow <= 0.0 { + self.marquee.reset(); + } } pub fn next_subitem(&mut self) { self.selected_subitem = self .inner .subentries_len(self.filtered_lines.index(self.selected_item).unwrap_or(0)) - .min(self.selected_subitem + 1) + .min(self.selected_subitem + 1); + self.mark_dirty(); } pub fn prev_subitem(&mut self) { - self.selected_subitem = self.selected_subitem.saturating_sub(1) + self.selected_subitem = self.selected_subitem.saturating_sub(1); + self.mark_dirty(); } pub fn raw_input(&self) -> &str { diff --git a/src/window.rs b/src/window.rs index 65a8196..7647516 100644 --- a/src/window.rs +++ b/src/window.rs @@ -66,8 +66,8 @@ pub struct Window { key_modifiers: sctk::seat::keyboard::Modifiers, wheel_scroll_pending: f64, + qh: QueueHandle, loop_handle: LoopHandle<'static, Window>, - dirty: bool, exit: bool, error: Option, @@ -187,8 +187,8 @@ impl Window { }, key_modifiers: Default::default(), wheel_scroll_pending: 0.0, + qh, loop_handle: event_loop.handle(), - dirty: true, exit: false, error: None, }, @@ -213,7 +213,7 @@ impl Window { if new_w != self.width || new_h != self.height { self.width = new_w; self.height = new_h; - self.dirty = true; + self.state.mark_dirty(); } } @@ -235,7 +235,7 @@ impl Window { } pub fn redraw_if_dirty(&mut self) { - if !self.dirty { + if !self.state.is_dirty() { return; } self.draw(); @@ -318,10 +318,13 @@ impl Window { crate::render_to_buffer(&self.config, &mut self.state, self.scale, canvas, viewport); self.surface.damage_buffer(0, 0, width, height); + if self.state.is_marquee_animating() { + self.surface.frame(&self.qh, (*self.surface).clone()); + } buffer.attach_to(&self.surface).expect("buffer attach"); self.buffer = Some(buffer); self.surface.commit(); - self.dirty = false; + self.state.clear_dirty(); } pub fn asked_exit(&self) -> bool { diff --git a/src/window/compositor.rs b/src/window/compositor.rs index 2e2ff68..0c9dd73 100644 --- a/src/window/compositor.rs +++ b/src/window/compositor.rs @@ -30,7 +30,7 @@ impl CompositorHandler for Window { { self.error = Some(err); } - self.dirty = true; + self.state.mark_dirty(); } } @@ -49,7 +49,14 @@ impl CompositorHandler for Window { _conn: &Connection, _qh: &QueueHandle, _surface: &WlSurface, - _time: u32, + time: u32, ) { + self.state.update_marquee(time); + // On throttle-skipped ticks keep the frame-callback chain alive with + // a bare re-request + commit; Wayland ties callbacks to commits. + if self.state.is_marquee_animating() && !self.state.is_dirty() { + self.surface.frame(&self.qh, (*self.surface).clone()); + self.surface.commit(); + } } } diff --git a/src/window/keyboard.rs b/src/window/keyboard.rs index 524294b..bba1d80 100644 --- a/src/window/keyboard.rs +++ b/src/window/keyboard.rs @@ -47,10 +47,8 @@ impl Window { m.ctrl, m.shift ); - return; } } - self.dirty = true; } } diff --git a/src/window/layer_shell.rs b/src/window/layer_shell.rs index 8213177..69a24a9 100644 --- a/src/window/layer_shell.rs +++ b/src/window/layer_shell.rs @@ -28,7 +28,6 @@ impl LayerShellHandler for Window { if !self.configured_surface { self.configured_surface = true; - self.dirty = true; self.draw(); } } diff --git a/src/window/pointer.rs b/src/window/pointer.rs index 9993177..6b87c06 100644 --- a/src/window/pointer.rs +++ b/src/window/pointer.rs @@ -24,7 +24,6 @@ impl PointerHandler for Window { _pointer: &protocol::wl_pointer::WlPointer, events: &[PointerEvent], ) { - let mut changed = false; let config = self.config.param::(); for event in events { @@ -94,7 +93,6 @@ impl PointerHandler for Window { | PointerEventKind::Release { .. } | PointerEventKind::Axis { .. } => continue, } - changed = true; } let scroll_per_step = SCROLL_PER_STEP @@ -105,8 +103,6 @@ impl PointerHandler for Window { }; let wheel_steps = (self.wheel_scroll_pending / scroll_per_step) as i32; if wheel_steps != 0 { - changed = true; - self.wheel_scroll_pending -= f64::from(wheel_steps) * scroll_per_step; let is_wheel_down = wheel_steps > 0; for _ in 0..wheel_steps.abs() { @@ -117,9 +113,5 @@ impl PointerHandler for Window { } } } - - if changed { - self.dirty = true; - } } } diff --git a/src/window/xdg_window.rs b/src/window/xdg_window.rs index e49f6e2..8742cc7 100644 --- a/src/window/xdg_window.rs +++ b/src/window/xdg_window.rs @@ -32,7 +32,6 @@ impl WindowHandler for Window { window.set_title(crate::prog_name!().to_owned()); window.unset_fullscreen(); self.configured_surface = true; - self.dirty = true; self.draw(); } } diff --git a/tests/fixtures/long_name.png b/tests/fixtures/long_name.png index e5ef49d..eac8fc1 100644 Binary files a/tests/fixtures/long_name.png and b/tests/fixtures/long_name.png differ diff --git a/tests/fixtures/marquee_long_selected.png b/tests/fixtures/marquee_long_selected.png new file mode 100644 index 0000000..928028a Binary files /dev/null and b/tests/fixtures/marquee_long_selected.png differ diff --git a/tests/regression/main.rs b/tests/regression/main.rs index 39c70e4..14a0e92 100644 --- a/tests/regression/main.rs +++ b/tests/regression/main.rs @@ -56,3 +56,16 @@ fn long_input() { (20, 10), ); } + +#[test] +fn marquee_long_selected() { + // Past the 1s initial pause, scroll should be mid-animation. + run_regression( + "marquee_long_selected", + test_entries_with_long_name(), + &[Action::SimulateFrames { + count: 120, + fps: 60, + }], + ); +} diff --git a/tests/regression/snap.rs b/tests/regression/snap.rs index ead2090..9cc253e 100644 --- a/tests/regression/snap.rs +++ b/tests/regression/snap.rs @@ -6,6 +6,11 @@ use yofi::window::Params; pub enum Action { Type(&'static str), NextItem, + /// Simulate `count` frame callbacks at `fps` to advance marquee animation. + SimulateFrames { + count: u32, + fps: u32, + }, } fn to_entries(items: &[&str]) -> Vec { @@ -109,13 +114,6 @@ pub fn run_regression_with_padding( let mode = Mode::dialog_from_lines(entries); let mut state = State::new(mode); - for action in actions { - match action { - Action::Type(s) => state.append_to_input(s), - Action::NextItem => state.next_item(), - } - } - let params: Params = config.param(); let scale = params.scale.unwrap_or(1); let content_w = params.width * u32::from(scale); @@ -128,6 +126,22 @@ pub fn run_regression_with_padding( (padding.0 as f32, padding.1 as f32), (content_w as f32, content_h as f32), ); + + for action in actions { + match action { + Action::Type(s) => state.append_to_input(s), + Action::NextItem => state.next_item(), + Action::SimulateFrames { count, fps } => { + // First render establishes overflow_width via the feedback channel. + yofi::render_to_buffer(&config, &mut state, scale, &mut buffer, viewport); + let frame_ms = 1000 / fps; + for i in 0..*count { + state.update_marquee(i * frame_ms); + } + } + } + } + yofi::render_to_buffer(&config, &mut state, scale, &mut buffer, viewport); let actual_rgba = unpremultiply_to_rgba(&buffer);