Skip to content

Commit

Permalink
feat: add support for kitty's font size protocol (#438)
Browse files Browse the repository at this point in the history
This adds support for kitty's font size protocol
(kovidgoyal/kitty#8226) which allows printing
characters that take up more than one cell. This feature will be
available in kitty >= 0.40.0 and is currently only available in nightly
builds.

This for now is only supported in a subset of the theme components,
namely:

* The introduction slide's presentation title
(`intro_slide.title.font_size`).
* The slide titles (`slide_title.font_size`).
* The headings (`headings.h*.font_size`).

Font sizes are only used if the terminal emulator supports it so this
doesn't change anything for emulators other than kitty (or other
implementors of the protocol). If you find this somehow breaks
something, please create an issue.

For now all built in themes set `intro_slide.title.font_size=2` and
`slide_title.font_size=2`. I think this looks a lot better this way but
please do comment here if you don't think built in themes should come
with these values set.

These are now the first 2 slides in the `demo.md` example:


https://github.com/user-attachments/assets/8d761d86-8855-498a-9766-5294cdae3b57
  • Loading branch information
mfontanini authored Feb 6, 2025
2 parents 8093875 + 1235a26 commit fb0223b
Show file tree
Hide file tree
Showing 22 changed files with 184 additions and 36 deletions.
3 changes: 2 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use crate::{
code::snippet::SnippetLanguage,
commands::keyboard::KeyBinding,
terminal::{
GraphicsMode, emulator::TerminalEmulator, image::protocols::kitty::KittyMode, query::TerminalCapabilities,
GraphicsMode, capabilities::TerminalCapabilities, emulator::TerminalEmulator,
image::protocols::kitty::KittyMode,
},
};
use clap::ValueEnum;
Expand Down
7 changes: 5 additions & 2 deletions src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
builder::{BuildError, PresentationBuilder},
},
render::TerminalDrawer,
terminal::TerminalWrite,
terminal::{TerminalWrite, emulator::TerminalEmulator},
};
use std::{io, rc::Rc};

Expand Down Expand Up @@ -104,7 +104,10 @@ impl<W: TerminalWrite> ThemesDemo<W> {
let image_registry = ImageRegistry::default();
let mut resources = Resources::new("non_existent", image_registry.clone());
let mut third_party = ThirdPartyRender::default();
let options = PresentationBuilderOptions::default();
let options = PresentationBuilderOptions {
font_size_supported: TerminalEmulator::capabilities().font_size,
..Default::default()
};
let executer = Rc::new(SnippetExecutor::default());
let bindings_config = Default::default();
let builder = PresentationBuilder::new(
Expand Down
4 changes: 4 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use std::{
rc::Rc,
sync::Arc,
};
use terminal::emulator::TerminalEmulator;

mod code;
mod commands;
Expand Down Expand Up @@ -258,6 +259,7 @@ impl CoreComponents {
enable_snippet_execution_replace: config.snippet.exec_replace.enable,
render_speaker_notes_only,
auto_render_languages: config.options.auto_render_languages.clone(),
font_size_supported: TerminalEmulator::capabilities().font_size,
}
}

Expand Down Expand Up @@ -349,6 +351,8 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", String::from_utf8_lossy(acknowledgements));
return Ok(());
} else if cli.list_themes {
// Load this ahead of time so we don't do it when we're already in raw mode.
TerminalEmulator::capabilities();
let Customizations { config, themes, .. } =
Customizations::load(cli.config_file.clone().map(PathBuf::from), &current_dir()?)?;
let bindings = config.bindings.try_into()?;
Expand Down
22 changes: 17 additions & 5 deletions src/markdown/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) struct WeightedLine {
text: Vec<WeightedText>,
width: usize,
height: u8,
}

impl WeightedLine {
Expand All @@ -25,6 +26,11 @@ impl WeightedLine {
self.width
}

/// The height of this line.
pub(crate) fn height(&self) -> u8 {
self.height
}

/// Get an iterator to the underlying text chunks.
#[cfg(test)]
pub(crate) fn iter_texts(&self) -> impl Iterator<Item = &WeightedText> {
Expand All @@ -43,6 +49,7 @@ impl From<Vec<Text>> for WeightedLine {
let mut output = Vec::new();
let mut index = 0;
let mut width = 0;
let mut height = 1;
// Compact chunks so any consecutive chunk with the same style is merged into the same block.
while index < texts.len() {
let mut target = mem::replace(&mut texts[index], Text::from(""));
Expand All @@ -52,19 +59,21 @@ impl From<Vec<Text>> for WeightedLine {
target.content.push_str(&current_content);
current += 1;
}
width += target.content.width();
let size = target.style.size.max(1);
width += target.content.width() * size as usize;
output.push(target.into());
index = current;
height = height.max(size);
}
Self { text: output, width }
Self { text: output, width, height }
}
}

impl From<String> for WeightedLine {
fn from(text: String) -> Self {
let width = text.width();
let text = vec![WeightedText::from(text)];
Self { text, width }
Self { text, width, height: 1 }
}
}

Expand Down Expand Up @@ -325,15 +334,16 @@ mod test {

#[test]
fn no_split_necessary() {
let text = WeightedLine { text: vec![WeightedText::from("short"), WeightedText::from("text")], width: 0 };
let text =
WeightedLine { text: vec![WeightedText::from("short"), WeightedText::from("text")], width: 0, height: 1 };
let lines = join_lines(text.split(50));
let expected = vec!["short text"];
assert_eq!(lines, expected);
}

#[test]
fn split_lines_single() {
let text = WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0 };
let text = WeightedLine { text: vec![WeightedText::from("this is a slightly long line")], width: 0, height: 1 };
let lines = join_lines(text.split(6));
let expected = vec!["this", "is a", "slight", "ly", "long", "line"];
assert_eq!(lines, expected);
Expand All @@ -348,6 +358,7 @@ mod test {
WeightedText::from("yet some other piece"),
],
width: 0,
height: 1,
};
let lines = join_lines(text.split(10));
let expected = vec!["this is a", "slightly", "long line", "another", "chunk yet", "some other", "piece"];
Expand All @@ -363,6 +374,7 @@ mod test {
WeightedText::from("yet some other piece"),
],
width: 0,
height: 1,
};
let lines = join_lines(text.split(50));
let expected = vec!["this is a slightly long line another chunk yet some", "other piece"];
Expand Down
27 changes: 22 additions & 5 deletions src/markdown/text_style.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::theme::ColorPalette;
use crossterm::style::Stylize;
use crossterm::style::{StyledContent, Stylize};
use hex::{FromHex, FromHexError};
use serde::{Deserialize, Serialize};
use serde_with::{DeserializeFromStr, SerializeDisplay};
Expand All @@ -10,15 +10,27 @@ use std::{
};

/// The style of a piece of text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TextStyle {
flags: u8,
pub(crate) colors: Colors,
pub(crate) size: u8,
}

impl Default for TextStyle {
fn default() -> Self {
Self { flags: Default::default(), colors: Default::default(), size: 1 }
}
}

impl TextStyle {
pub(crate) fn colored(colors: Colors) -> Self {
Self { flags: Default::default(), colors }
Self { colors, ..Default::default() }
}

pub(crate) fn size(mut self, size: u8) -> Self {
self.size = size.min(16);
self
}

/// Add bold to this style.
Expand Down Expand Up @@ -107,13 +119,18 @@ impl TextStyle {
/// Merge this style with another one.
pub(crate) fn merge(&mut self, other: &TextStyle) {
self.flags |= other.flags;
self.size = self.size.max(other.size);
self.colors.background = self.colors.background.or(other.colors.background);
self.colors.foreground = self.colors.foreground.or(other.colors.foreground);
}

/// Apply this style to a piece of text.
pub(crate) fn apply<T: Into<String>>(&self, text: T) -> Result<<String as Stylize>::Styled, PaletteColorError> {
let text: String = text.into();
pub(crate) fn apply<T: Into<String>>(&self, text: T) -> Result<StyledContent<String>, PaletteColorError> {
let text = text.into();
let text = match self.size {
0 | 1 => text,
size => format!("\x1b]66;s={size};{text}\x1b\\"),
};
let mut styled = text.stylize();
if self.is_bold() {
styled = styled.bold();
Expand Down
15 changes: 13 additions & 2 deletions src/presentation/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub struct PresentationBuilderOptions {
pub enable_snippet_execution_replace: bool,
pub render_speaker_notes_only: bool,
pub auto_render_languages: Vec<SnippetLanguage>,
pub font_size_supported: bool,
}

impl PresentationBuilderOptions {
Expand Down Expand Up @@ -114,6 +115,7 @@ impl Default for PresentationBuilderOptions {
enable_snippet_execution_replace: false,
render_speaker_notes_only: false,
auto_render_languages: Default::default(),
font_size_supported: false,
}
}
}
Expand Down Expand Up @@ -395,7 +397,10 @@ impl<'a> PresentationBuilder<'a> {
let styles = self.theme.intro_slide.clone();
let create_text =
|text: Option<String>, style: TextStyle| -> Option<Text> { text.map(|text| Text::new(text, style)) };
let title = create_text(metadata.title, TextStyle::default().bold().colors(styles.title.colors));
let title = create_text(
metadata.title,
TextStyle::default().bold().colors(styles.title.colors).size(self.font_size(styles.title.font_size)),
);
let sub_title = create_text(metadata.sub_title, TextStyle::default().colors(styles.subtitle.colors));
let event = create_text(metadata.event, TextStyle::default().colors(styles.event.colors));
let location = create_text(metadata.location, TextStyle::default().colors(styles.location.colors));
Expand Down Expand Up @@ -572,6 +577,7 @@ impl<'a> PresentationBuilder<'a> {

let style = self.theme.slide_title.clone();
let mut text_style = TextStyle::default().colors(style.colors);
text_style = text_style.size(self.font_size(style.font_size));
if style.bold.unwrap_or_default() {
text_style = text_style.bold();
}
Expand Down Expand Up @@ -613,7 +619,7 @@ impl<'a> PresentationBuilder<'a> {
prefix.push(' ');
text.0.insert(0, Text::from(prefix));
}
let text_style = TextStyle::default().bold().colors(style.colors);
let text_style = TextStyle::default().bold().colors(style.colors).size(self.font_size(style.font_size));
text.apply_style(&text_style);

self.push_text(text, element_type)?;
Expand Down Expand Up @@ -1155,6 +1161,11 @@ impl<'a> PresentationBuilder<'a> {
_ => Err(ImageAttributeError::UnknownAttribute(key.to_string())),
}
}

fn font_size(&self, font_size: Option<u8>) -> u8 {
let Some(font_size) = font_size else { return 1 };
if self.options.font_size_supported { font_size.clamp(1, 7) } else { 1 }
}
}

#[derive(Debug, Default)]
Expand Down
2 changes: 1 addition & 1 deletion src/render/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ where
}

fn render_line_break(&mut self) -> RenderResult {
self.terminal.move_to_next_line(1)?;
self.terminal.move_to_next_line()?;
Ok(())
}

Expand Down
12 changes: 7 additions & 5 deletions src/render/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::{
text_style::{Color, Colors},
},
render::{RenderError, RenderResult, layout::Positioning},
terminal::{Terminal, TerminalWrite},
terminal::{Terminal, TerminalWrite, printer::TextProperties},
};

const MINIMUM_LINE_LENGTH: u16 = 10;
Expand All @@ -23,6 +23,7 @@ pub(crate) struct TextDrawer<'a> {
draw_block: bool,
block_color: Option<Color>,
repeat_prefix: bool,
properties: TextProperties,
}

impl<'a> TextDrawer<'a> {
Expand Down Expand Up @@ -56,6 +57,7 @@ impl<'a> TextDrawer<'a> {
draw_block: false,
block_color: None,
repeat_prefix: false,
properties: TextProperties { height: line.height() },
})
}
}
Expand Down Expand Up @@ -86,7 +88,7 @@ impl<'a> TextDrawer<'a> {
style.apply(content)?
};
terminal.move_to_column(self.positioning.start_column)?;
terminal.print_styled_line(styled_prefix.clone())?;
terminal.print_styled_line(styled_prefix.clone(), &self.properties)?;

let start_column = self.positioning.start_column + self.prefix_length;
for (line_index, line) in self.line.split(self.positioning.max_line_length as usize).enumerate() {
Expand All @@ -100,7 +102,7 @@ impl<'a> TextDrawer<'a> {
if self.prefix_length > 0 {
terminal.move_to_column(self.positioning.start_column)?;
if self.repeat_prefix {
terminal.print_styled_line(styled_prefix.clone())?;
terminal.print_styled_line(styled_prefix.clone(), &self.properties)?;
} else {
self.print_block_background(self.prefix_length, terminal)?;
}
Expand All @@ -112,7 +114,7 @@ impl<'a> TextDrawer<'a> {

let (text, style) = chunk.into_parts();
let text = style.apply(text)?;
terminal.print_styled_line(text)?;
terminal.print_styled_line(text, &self.properties)?;

// Crossterm resets colors if any attributes are set so let's just re-apply colors
// if the format has anything on it at all.
Expand All @@ -137,7 +139,7 @@ impl<'a> TextDrawer<'a> {
terminal.set_background_color(color)?;
}
let text = " ".repeat(remaining as usize);
terminal.print_line(&text)?;
terminal.print_line(&text, &self.properties)?;
}
}
Ok(())
Expand Down
27 changes: 23 additions & 4 deletions src/terminal/query.rs → src/terminal/capabilities.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
use super::image::protocols::kitty::{Action, ControlCommand, ControlOption, ImageFormat, TransmissionMedium};
use base64::{Engine, engine::general_purpose::STANDARD};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crossterm::{
QueueableCommand,
cursor::{self},
style::Print,
terminal,
};
use image::{DynamicImage, EncodableLayout};
use std::{
env,
io::{self, Write},
};
use tempfile::NamedTempFile;

#[derive(Default, Debug)]
#[derive(Default, Debug, Clone)]
pub(crate) struct TerminalCapabilities {
pub(crate) kitty_local: bool,
pub(crate) kitty_remote: bool,
pub(crate) sixel: bool,
pub(crate) tmux: bool,
pub(crate) font_size: bool,
}

impl TerminalCapabilities {
Expand Down Expand Up @@ -48,6 +54,19 @@ impl TerminalCapabilities {

let mut response = Self::parse_response(io::stdin(), ids)?;
response.tmux = tmux;

// Use kitty's font size protocol to write 1 character using size 2. If after writing the
// cursor has moves 2 columns, the protocol is supported.
stdout.queue(terminal::EnterAlternateScreen)?;
stdout.queue(cursor::MoveTo(0, 0))?;
stdout.queue(Print("\x1b]66;s=2; \x1b\\"))?;
stdout.flush()?;
let position = cursor::position()?;
if position.0 == 2 {
response.font_size = true;
}
stdout.queue(terminal::LeaveAlternateScreen)?;

Ok(response)
}

Expand Down Expand Up @@ -113,14 +132,14 @@ struct RawModeGuard;

impl RawModeGuard {
fn new() -> io::Result<Self> {
enable_raw_mode()?;
terminal::enable_raw_mode()?;
Ok(Self)
}
}

impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = terminal::disable_raw_mode();
}
}

Expand Down
Loading

0 comments on commit fb0223b

Please sign in to comment.