diff --git a/Cargo.lock b/Cargo.lock index 963cb32..9931d1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,8 @@ dependencies = [ "libc", "signal-hook", "termion", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -121,6 +123,18 @@ dependencies = [ "numtoa", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7b230e0..23d3bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ edition = "2024" crate-type = ["rlib", "staticlib"] [dependencies] +unicode-segmentation = "1.12" +unicode-width = "0.2" libc = "0.2.189" image = { version = "0.25.10", default-features = false, features = ["jpeg"] } signal-hook = "0.3" diff --git a/README.md b/README.md index 9be1cef..bb67709 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,13 @@ let border = Border::plain(Color::white()) .title("Video") .media_controls(video.controls()); ``` + +## Text + +`Text::new("Hello, world!").color(Color::CYAN).style(Style::new().height(1))` +adds plain text to a container or app. Text respects its layout rectangle, clips +at the right and bottom edges, and starts a row at each newline. Set its width +and height through `Style`, as with other nodes. Wide characters and combining +marks keep their terminal cell width. Control characters are ignored; ANSI +escape sequences are not supported. Wrap text in the app before passing it in +when scrolling or word wrapping is needed. diff --git a/src/lib.rs b/src/lib.rs index 430074a..3a9123e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,4 +14,6 @@ pub use style::{ AlignItems, AlignSelf, Border, BorderStyle, Color, FlexDirection, JustifyContent, Padding, Position, Style, TitleAlignment, }; -pub use tree::{App, Container, Image, ImageFit, ImageId, Node, Video, VideoControls, VideoId}; +pub use tree::{ + App, Container, Image, ImageFit, ImageId, Node, Text, Video, VideoControls, VideoId, +}; diff --git a/src/mpv.rs b/src/mpv.rs index d80fc9f..29b5964 100644 --- a/src/mpv.rs +++ b/src/mpv.rs @@ -281,17 +281,19 @@ impl Player { let encoded_capacity = encoded_capacity(frame)?; let handle = NonNull::new(unsafe { mpv_create() }) .ok_or_else(|| io::Error::other("mpv_create failed"))?; - if let Err(error) = mpv_result(unsafe { - mpv_set_option_string(handle.as_ptr(), c"vo".as_ptr(), c"libmpv".as_ptr()) - }) { - unsafe { mpv_terminate_destroy(handle.as_ptr()) }; - return Err(error); - } - if let Err(error) = mpv_result(unsafe { - mpv_set_option_string(handle.as_ptr(), c"profile".as_ptr(), c"sw-fast".as_ptr()) - }) { - unsafe { mpv_terminate_destroy(handle.as_ptr()) }; - return Err(error); + // Keep the file open at its end, so seeking back and replaying still work. + for (name, value) in [ + (c"vo", c"libmpv"), + (c"profile", c"sw-fast"), + (c"keep-open", c"yes"), + (c"keep-open-pause", c"no"), + ] { + if let Err(error) = mpv_result(unsafe { + mpv_set_option_string(handle.as_ptr(), name.as_ptr(), value.as_ptr()) + }) { + unsafe { mpv_terminate_destroy(handle.as_ptr()) }; + return Err(error); + } } if let Err(error) = mpv_result(unsafe { mpv_initialize(handle.as_ptr()) }) { unsafe { mpv_terminate_destroy(handle.as_ptr()) }; @@ -377,21 +379,18 @@ impl Player { Err(error) => thread_error.store(error, Ordering::Release), } } + // A seek can fail, such as before the file loads; playback goes on. let seek_steps = thread_control.seek_steps.swap(0, Ordering::AcqRel); - if seek_steps != 0 - && let Err(error) = thread_handle.seek(seek_steps) - { - thread_error.store(error, Ordering::Release); + if seek_steps != 0 { + let _ = thread_handle.seek(seek_steps); } let seek_target = f64::from_bits( thread_control .seek_target .swap((-1.0_f64).to_bits(), Ordering::AcqRel), ); - if seek_target >= 0.0 - && let Err(error) = thread_handle.set_position(seek_target) - { - thread_error.store(error, Ordering::Release); + if seek_target >= 0.0 { + let _ = thread_handle.set_position(seek_target); } if last_status.elapsed() >= Duration::from_millis(100) { if let Ok(position) = thread_handle.get_double(c"time-pos") { @@ -942,4 +941,46 @@ mod tests { } panic!("libmpv did not report the bad media"); } + #[test] + #[ignore = "requires FXCODE_TEST_VIDEO"] + fn seeking_past_the_end_keeps_the_player_usable() { + let path = std::env::var_os("FXCODE_TEST_VIDEO").expect("set FXCODE_TEST_VIDEO"); + let video = Video::from_path(path).unwrap(); + let frame = Frame::new( + Rect { + x: 0, + y: 0, + w: 8, + h: 4, + }, + TerminalSize::default(), + 0, + ) + .unwrap(); + let mut player = Player::new(&video, frame, 1).unwrap(); + let mut wait = |done: &dyn Fn() -> bool| { + for _ in 0..400 { + player.render().unwrap(); + if done() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("libmpv did not get there"); + }; + let controls = video.controls(); + wait(&|| controls.duration() > 0.0); + for _ in 0..40 { + video.seek_forward(); + } + wait(&|| controls.position() >= controls.duration() - 0.5); + video.seek_forward(); + controls.seek_to(0.0); + // Replace the optimistic position, so only a libmpv report can pass. + controls + .inner + .position + .store(f64::INFINITY.to_bits(), Ordering::Release); + wait(&|| controls.position() < 1.0); + } } diff --git a/src/renderer.rs b/src/renderer.rs index a812886..914615d 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -183,7 +183,7 @@ impl Renderer { .iter() .filter_map(|item| match item.node { Node::Image(image) => Some((image, item.rect)), - Node::Container(_) | Node::Video(_) => None, + Node::Container(_) | Node::Text(_) | Node::Video(_) => None, }) .collect(); self.kitty.draw(&mut self.output, &images, self.size)?; @@ -472,6 +472,10 @@ fn write_background(output: &mut W, value: Option) -> io::Resul fn paint_cells(nodes: &[PositionedNode<'_>], screen: &mut [Cell], cols: u16) { for item in nodes { + if let Node::Text(text) = item.node { + paint_text(text, item.rect, screen, cols); + continue; + } let Node::Container(container) = item.node else { continue; }; @@ -484,6 +488,37 @@ fn paint_cells(nodes: &[PositionedNode<'_>], screen: &mut [Cell], cols: u16) { } } +fn paint_text(text: &crate::Text, rect: Rect, screen: &mut [Cell], cols: u16) { + use unicode_segmentation::UnicodeSegmentation; + use unicode_width::UnicodeWidthStr; + if let Some(background) = text.style.background_color { + fill_background(screen, cols, rect, background); + } + for (row, line) in text.content.lines().take(usize::from(rect.h)).enumerate() { + let mut x = rect.x; + let end = rect.x.saturating_add(rect.w).min(cols); + for grapheme in line.graphemes(true) { + if grapheme.chars().any(char::is_control) { + continue; + } + let width = grapheme.width(); + if width == 0 { + continue; + } + if usize::from(x) + width > usize::from(end) { + break; + } + let y = rect.y.saturating_add(row as u16); + set(screen, cols, x, y, grapheme, text.color); + // Empty continuation cells must not overwrite the wide glyph on flush. + for offset in 1..width { + set(screen, cols, x + offset as u16, y, "", text.color); + } + x += width as u16; + } + } +} + fn set(screen: &mut [Cell], cols: u16, x: u16, y: u16, text: &str, foreground: Color) { if x >= cols { return; @@ -550,25 +585,39 @@ fn draw_frame(screen: &mut [Cell], cols: u16, rect: Rect, border: &Border) { } fn draw_title(screen: &mut [Cell], cols: u16, rect: Rect, border: &Border) { + use unicode_segmentation::UnicodeSegmentation; + use unicode_width::UnicodeWidthStr; let available = usize::from(rect.w.saturating_sub(2)); - let title: Vec = border.title.chars().take(available).collect(); - let free = available - title.len(); + let mut title = String::new(); + let mut width = 0; + for grapheme in border.title.graphemes(true) { + if grapheme.chars().any(char::is_control) { + continue; + } + let cells = grapheme.width(); + if width + cells > available { + break; + } + title.push_str(grapheme); + width += cells; + } + let free = available - width; let offset = match border.title_alignment { TitleAlignment::Left => 0, TitleAlignment::Center => free / 2, TitleAlignment::Right => free, }; - let start = rect.x + 1 + offset as u16; - for (index, character) in title.into_iter().enumerate() { - set( - screen, - cols, - start + index as u16, - rect.y, - &character.to_string(), - border.title_color, - ); - } + paint_text( + &crate::Text::new(title).color(border.title_color), + Rect { + x: rect.x.saturating_add(1).saturating_add(offset as u16), + y: rect.y, + w: width as u16, + h: u16::from(rect.h > 0), + }, + screen, + cols, + ); } #[cfg(test)] @@ -679,3 +728,46 @@ mod tests { ); } } + +#[cfg(test)] +mod text_tests { + use super::*; + #[test] + fn text_clips_graphemes_and_filters_terminal_controls() { + let mut cells = vec![Cell::default(); 12]; + let text = crate::Text::new("a界e\u{301}z\n\x1bX"); + paint_text( + &text, + Rect { + x: 1, + y: 0, + w: 4, + h: 2, + }, + &mut cells, + 6, + ); + assert_eq!(cells[1].grapheme, "a"); + assert_eq!(cells[2].grapheme, "界"); + assert_eq!(cells[3].grapheme, ""); + assert_eq!(cells[4].grapheme, "e\u{301}"); + assert_eq!(cells[5].grapheme, " "); + assert_eq!(cells[7].grapheme, "X"); + } + #[test] + fn wide_glyph_does_not_cross_right_edge() { + let mut cells = vec![Cell::default(); 2]; + paint_text( + &crate::Text::new("a界"), + Rect { + x: 0, + y: 0, + w: 2, + h: 1, + }, + &mut cells, + 2, + ); + assert_eq!(cells[1].grapheme, " "); + } +} diff --git a/src/tree.rs b/src/tree.rs index 7782d87..bfc186d 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -60,6 +60,7 @@ impl Container { #[derive(Debug, Clone)] pub enum Node { Container(Container), + Text(Text), Image(Image), Video(Video), } @@ -68,6 +69,7 @@ impl Node { pub fn style(&self) -> &Style { match self { Self::Container(container) => &container.style, + Self::Text(text) => &text.style, Self::Image(image) => &image.style, Self::Video(video) => &video.style, } @@ -366,3 +368,35 @@ impl Video { self.control.clone() } } + +/// Plain text, clipped to its layout rectangle. Newlines start a new row. +/// Control characters are ignored; input is never interpreted as terminal escapes. +#[derive(Debug, Clone)] +pub struct Text { + pub content: String, + pub style: Style, + pub color: crate::Color, +} + +impl Text { + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + style: Style::new(), + color: crate::Color::WHITE, + } + } + pub fn style(mut self, style: Style) -> Self { + self.style = style; + self + } + pub fn color(mut self, color: crate::Color) -> Self { + self.color = color; + self + } +} +impl From for Node { + fn from(value: Text) -> Self { + Self::Text(value) + } +}