feat: add support for kitty's font size spec

This commit is contained in:
Matias Fontanini 2025-02-04 16:44:56 -08:00
parent 33c7c9705c
commit 1235a26f75
22 changed files with 184 additions and 36 deletions

View File

@ -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;

View File

@ -11,7 +11,7 @@ use crate::{
builder::{BuildError, PresentationBuilder},
},
render::TerminalDrawer,
terminal::TerminalWrite,
terminal::{TerminalWrite, emulator::TerminalEmulator},
};
use std::{io, rc::Rc};
@ -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(

View File

@ -27,6 +27,7 @@ use std::{
rc::Rc,
sync::Arc,
};
use terminal::emulator::TerminalEmulator;
mod code;
mod commands;
@ -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,
}
}
@ -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()?;

View File

@ -12,6 +12,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) struct WeightedLine {
text: Vec<WeightedText>,
width: usize,
height: u8,
}
impl WeightedLine {
@ -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> {
@ -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(""));
@ -52,11 +59,13 @@ 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 }
}
}
@ -64,7 +73,7 @@ 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 }
}
}
@ -325,7 +334,8 @@ 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);
@ -333,7 +343,7 @@ mod test {
#[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);
@ -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"];
@ -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"];

View File

@ -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};
@ -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.
@ -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();

View File

@ -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 {
@ -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,
}
}
}
@ -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));
@ -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();
}
@ -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)?;
@ -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)]

View File

@ -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(())
}

View File

@ -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;
@ -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> {
@ -56,6 +57,7 @@ impl<'a> TextDrawer<'a> {
draw_block: false,
block_color: None,
repeat_prefix: false,
properties: TextProperties { height: line.height() },
})
}
}
@ -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() {
@ -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)?;
}
@ -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.
@ -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(())

View File

@ -1,6 +1,11 @@
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,
@ -8,12 +13,13 @@ use std::{
};
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 {
@ -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)
}
@ -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();
}
}

View File

@ -1,7 +1,10 @@
use super::{GraphicsMode, image::protocols::kitty::KittyMode, query::TerminalCapabilities};
use super::{GraphicsMode, capabilities::TerminalCapabilities, image::protocols::kitty::KittyMode};
use once_cell::sync::Lazy;
use std::env;
use strum::IntoEnumIterator;
static CAPABILITIES: Lazy<TerminalCapabilities> = Lazy::new(|| TerminalCapabilities::query().unwrap_or_default());
#[derive(Debug, strum::EnumIter)]
pub enum TerminalEmulator {
Iterm2,
@ -30,8 +33,12 @@ impl TerminalEmulator {
TerminalEmulator::Unknown
}
pub(crate) fn capabilities() -> TerminalCapabilities {
CAPABILITIES.clone()
}
pub fn preferred_protocol(&self) -> GraphicsMode {
let capabilities = TerminalCapabilities::query().unwrap_or_default();
let capabilities = Self::capabilities();
let modes = [
GraphicsMode::Iterm2,
GraphicsMode::Kitty { mode: KittyMode::Local, inside_tmux: capabilities.tmux },

View File

@ -1,8 +1,8 @@
pub(crate) mod ansi;
pub(crate) mod capabilities;
pub(crate) mod emulator;
pub(crate) mod image;
pub(crate) mod printer;
pub(crate) mod query;
pub(crate) use printer::{Terminal, TerminalWrite, should_hide_cursor};

View File

@ -23,12 +23,13 @@ where
writer: W,
image_printer: Arc<ImagePrinter>,
pub(crate) cursor_row: u16,
current_row_height: u16,
}
impl<W: TerminalWrite> Terminal<W> {
pub(crate) fn new(mut writer: W, image_printer: Arc<ImagePrinter>) -> io::Result<Self> {
writer.init()?;
Ok(Self { writer, image_printer, cursor_row: 0 })
Ok(Self { writer, image_printer, cursor_row: 0, current_row_height: 1 })
}
pub(crate) fn begin_update(&mut self) -> io::Result<()> {
@ -64,19 +65,27 @@ impl<W: TerminalWrite> Terminal<W> {
Ok(())
}
pub(crate) fn move_to_next_line(&mut self, amount: u16) -> io::Result<()> {
pub(crate) fn move_to_next_line(&mut self) -> io::Result<()> {
let amount = self.current_row_height;
self.writer.queue(cursor::MoveToNextLine(amount))?;
self.cursor_row += amount;
self.current_row_height = 1;
Ok(())
}
pub(crate) fn print_line(&mut self, text: &str) -> io::Result<()> {
pub(crate) fn print_line(&mut self, text: &str, properties: &TextProperties) -> io::Result<()> {
self.writer.queue(style::Print(text))?;
self.current_row_height = self.current_row_height.max(properties.height as u16);
Ok(())
}
pub(crate) fn print_styled_line(&mut self, content: StyledContent<String>) -> io::Result<()> {
self.writer.queue(style::PrintStyledContent(content))?;
pub(crate) fn print_styled_line(
&mut self,
string: StyledContent<String>,
properties: &TextProperties,
) -> io::Result<()> {
self.writer.queue(style::PrintStyledContent(string))?;
self.current_row_height = self.current_row_height.max(properties.height as u16);
Ok(())
}
@ -120,6 +129,17 @@ impl<W: TerminalWrite> Terminal<W> {
}
}
#[derive(Clone, Debug)]
pub(crate) struct TextProperties {
pub(crate) height: u8,
}
impl Default for TextProperties {
fn default() -> Self {
Self { height: 1 }
}
}
impl<W> Drop for Terminal<W>
where
W: TerminalWrite,

View File

@ -303,6 +303,10 @@ pub(crate) struct SlideTitleStyle {
/// Whether to use underlined font for slide titles.
#[serde(default)]
pub(crate) underlined: Option<bool>,
/// The font size to be used if the terminal supports it.
#[serde(default)]
pub(crate) font_size: Option<u8>,
}
impl SlideTitleStyle {
@ -366,11 +370,16 @@ pub(crate) struct HeadingStyle {
/// The colors to be used.
#[serde(default)]
pub(crate) colors: Colors,
/// The font size to be used if the terminal supports it.
#[serde(default)]
pub(crate) font_size: Option<u8>,
}
impl HeadingStyle {
fn resolve_palette_colors(&mut self, palette: &ColorPalette) -> Result<(), UndefinedPaletteColorError> {
self.colors = self.colors.resolve(palette)?;
let Self { colors, alignment: _, prefix: _, font_size: _ } = self;
*colors = colors.resolve(palette)?;
Ok(())
}
}
@ -643,7 +652,7 @@ impl AlertTypeProperties for CautionAlertType {
pub(crate) struct IntroSlideStyle {
/// The style of the title line.
#[serde(default)]
pub(crate) title: BasicStyle,
pub(crate) title: IntroSlideTitleStyle,
/// The style of the subtitle line.
#[serde(default)]
@ -673,9 +682,10 @@ pub(crate) struct IntroSlideStyle {
impl IntroSlideStyle {
fn resolve_palette_colors(&mut self, palette: &ColorPalette) -> Result<(), UndefinedPaletteColorError> {
let Self { title, subtitle, event, location, date, author, footer: _footer } = self;
for s in [title, subtitle, event, location, date] {
for s in [subtitle, event, location, date] {
s.resolve_palette_colors(palette)?;
}
title.resolve_palette_colors(palette)?;
author.resolve_palette_colors(palette)?;
Ok(())
}
@ -721,6 +731,30 @@ impl BasicStyle {
}
}
/// The intro slide title's style.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub(crate) struct IntroSlideTitleStyle {
/// The alignment.
#[serde(flatten, default)]
pub(crate) alignment: Option<Alignment>,
/// The colors to be used.
#[serde(default)]
pub(crate) colors: Colors,
/// The font size to be used if the terminal supports it.
#[serde(default)]
pub(crate) font_size: Option<u8>,
}
impl IntroSlideTitleStyle {
fn resolve_palette_colors(&mut self, palette: &ColorPalette) -> Result<(), UndefinedPaletteColorError> {
let Self { colors, alignment: _, font_size: _ } = self;
*colors = colors.resolve(palette)?;
Ok(())
}
}
/// Text alignment.
///
/// This allows anchoring presentation elements to the left, center, or right of the screen.

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: "e5c890"
bold: true
font_size: 2
code:
alignment: center
@ -47,6 +48,7 @@ intro_slide:
alignment: center
colors:
foreground: "a6d189"
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: "df8e1d"
bold: true
font_size: 2
code:
alignment: center
@ -47,6 +48,7 @@ intro_slide:
alignment: center
colors:
foreground: "40a02b"
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: "eed49f"
bold: true
font_size: 2
code:
alignment: center
@ -47,6 +48,7 @@ intro_slide:
alignment: center
colors:
foreground: "a6da95"
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: "f9e2af"
bold: true
font_size: 2
code:
alignment: center
@ -47,6 +48,7 @@ intro_slide:
alignment: center
colors:
foreground: "a6e3a1"
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: palette:orange
bold: true
font_size: 2
code:
alignment: center
@ -48,6 +49,7 @@ intro_slide:
alignment: center
colors:
foreground: palette:light_blue
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: "f77f00"
bold: true
font_size: 2
code:
alignment: center
@ -48,6 +49,7 @@ intro_slide:
alignment: center
colors:
foreground: "52b788"
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: yellow
bold: true
font_size: 2
code:
alignment: center
@ -46,6 +47,7 @@ intro_slide:
alignment: center
colors:
foreground: green
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: dark_yellow
bold: true
font_size: 2
code:
alignment: center
@ -46,6 +47,7 @@ intro_slide:
alignment: center
colors:
foreground: dark_green
font_size: 2
subtitle:
alignment: center
colors:

View File

@ -13,6 +13,7 @@ slide_title:
colors:
foreground: "e0af68"
bold: true
font_size: 2
code:
alignment: center
@ -48,6 +49,7 @@ intro_slide:
alignment: center
colors:
foreground: "7aa2f7"
font_size: 2
subtitle:
alignment: center
colors: