Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
79 changes: 60 additions & 19 deletions src/mpv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()) };
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
}
}
120 changes: 106 additions & 14 deletions src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ impl<W: Write> Renderer<W> {
.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)?;
Expand Down Expand Up @@ -472,6 +472,10 @@ fn write_background<W: Write>(output: &mut W, value: Option<Color>) -> 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;
};
Expand All @@ -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;
Expand Down Expand Up @@ -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<char> = 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)]
Expand Down Expand Up @@ -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, " ");
}
}
34 changes: 34 additions & 0 deletions src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ impl Container {
#[derive(Debug, Clone)]
pub enum Node {
Container(Container),
Text(Text),
Image(Image),
Video(Video),
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -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<String>) -> 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<Text> for Node {
fn from(value: Text) -> Self {
Self::Text(value)
}
}