use bevy::{
ecs::schedule::SystemSet,
prelude::*,
text::{
ComputedTextBlock, FontCx, FontHinting, FontSource, FontStyle, FontWeight, LayoutCx,
LetterSpacing, LineHeight, TextPipeline,
},
};
use crate::scene::{StyleFlags, TerminalCell, TerminalSnapshot};
mod batch;
mod color;
mod fonts;
#[cfg(feature = "3d")]
mod world_quad;
pub use batch::{
Terminal, TerminalPlugin, TerminalReady, TerminalRemeasured, TerminalStats, TerminalTexture,
grid_for, grid_for_window, raster_scale_for_window,
};
pub use color::TerminalTheme;
use color::dim;
pub use fonts::{SmolStr, TerminalFonts, font_family};
#[cfg(feature = "3d")]
pub use world_quad::TerminalWorldQuad;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum CursorStyle {
#[default]
Block,
Bar,
Underline,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CursorConfig {
pub style: CursorStyle,
pub color: Color,
pub blink_hz: Option<f32>,
}
impl Default for CursorConfig {
fn default() -> Self {
Self {
style: CursorStyle::Block,
color: Color::srgba(0.82, 0.88, 1.0, 0.48),
blink_hz: Some(1.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlinkConfig {
pub slow_hz: Option<f32>,
pub rapid_hz: Option<f32>,
}
impl Default for BlinkConfig {
fn default() -> Self {
Self {
slow_hz: Some(1.0),
rapid_hz: Some(3.0),
}
}
}
impl BlinkConfig {
pub const NONE: Self = Self {
slow_hz: None,
rapid_hz: None,
};
}
#[derive(Clone, Debug, PartialEq)]
pub struct FontFaces {
pub regular: FontSource,
pub bold: Option<FontSource>,
pub italic: Option<FontSource>,
pub bold_italic: Option<FontSource>,
pub synthesize: bool,
}
impl FontFaces {
#[must_use]
pub fn regular(regular: impl Into<FontSource>) -> Self {
Self {
regular: regular.into(),
bold: None,
italic: None,
bold_italic: None,
synthesize: true,
}
}
#[must_use]
pub const fn with_synthesis(mut self, synthesize: bool) -> Self {
self.synthesize = synthesize;
self
}
fn resolve(&self, bold: bool, italic: bool) -> (&FontSource, FontWeight, FontStyle) {
let (face, exact) = match (bold, italic) {
(true, true) => self
.bold_italic
.as_ref()
.map(|face| (face, true))
.or_else(|| self.bold.as_ref().map(|face| (face, false)))
.or_else(|| self.italic.as_ref().map(|face| (face, false))),
(true, false) => self.bold.as_ref().map(|face| (face, true)),
(false, true) => self.italic.as_ref().map(|face| (face, true)),
(false, false) => Some((&self.regular, true)),
}
.unwrap_or((&self.regular, false));
let request = exact || self.synthesize;
let weight = if bold && request {
FontWeight::BOLD
} else {
FontWeight::NORMAL
};
let style = if italic && request {
FontStyle::Italic
} else {
FontStyle::Normal
};
(face, weight, style)
}
#[must_use]
pub fn select(&self, bold: bool, italic: bool) -> &FontSource {
match (bold, italic) {
(true, true) => self
.bold_italic
.as_ref()
.or(self.bold.as_ref())
.or(self.italic.as_ref()),
(true, false) => self.bold.as_ref(),
(false, true) => self.italic.as_ref(),
(false, false) => None,
}
.unwrap_or(&self.regular)
}
}
impl Default for FontFaces {
fn default() -> Self {
Self::regular(FontSource::Monospace)
}
}
impl<T: Into<FontSource>> From<T> for FontFaces {
fn from(regular: T) -> Self {
Self::regular(regular)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum FontSizing {
#[default]
FitCellWidth,
Px(f32),
}
const PROBE_GLYPHS: usize = 100;
pub(crate) const PROBE_FONT_SIZE: f32 = 64.0;
const UNMEASURED_FONT_SIZE: f32 = 16.0;
fn measure_advance(
faces: &FontFaces,
fonts: &Assets<Font>,
text_pipeline: &mut TextPipeline,
font_cx: &mut FontCx,
layout_cx: &mut LayoutCx,
) -> Option<f32> {
if let FontSource::Handle(handle) = &faces.regular
&& fonts
.get(handle.id())
.is_none_or(|font| font.alias.is_empty())
{
return None;
}
let font = TextFont {
font: faces.regular.clone(),
font_size: PROBE_FONT_SIZE.into(),
..default()
};
let probe = "0".repeat(PROBE_GLYPHS);
let mut computed = ComputedTextBlock::default();
let measure = text_pipeline
.create_text_measure(
Entity::PLACEHOLDER,
fonts,
std::iter::once((
Entity::PLACEHOLDER,
0,
probe.as_str(),
&font,
Color::WHITE,
LineHeight::Px(PROBE_FONT_SIZE),
LetterSpacing::default(),
)),
1.0,
&TextLayout::new(Justify::Left, LineBreak::NoWrap),
&mut computed,
font_cx,
layout_cx,
Vec2::new(f32::MAX, f32::MAX),
20.0,
)
.ok()?;
let advance = measure.max.x / PROBE_GLYPHS as f32;
(advance.is_finite() && advance > 0.0).then_some(advance)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct LogicalMetrics {
pub(crate) font_size: f32,
pub(crate) cell_size: Vec2,
}
fn resolve_metrics(config: &TerminalRenderConfig, measured_advance: Option<f32>) -> LogicalMetrics {
let advance_per_px = measured_advance.map(|advance| advance / PROBE_FONT_SIZE);
match (config.cell_size, config.font_size) {
(CellSizing::Logical(cell), FontSizing::Px(size)) => LogicalMetrics {
font_size: size.max(1.0),
cell_size: cell,
},
(CellSizing::Logical(cell), FontSizing::FitCellWidth) => LogicalMetrics {
font_size: advance_per_px
.map_or(UNMEASURED_FONT_SIZE, |ratio| (cell.x / ratio).max(1.0)),
cell_size: cell,
},
(CellSizing::FromFont { line_height }, FontSizing::Px(size)) => {
let font_size = size.max(1.0);
let width = advance_per_px.map_or(font_size * 0.6, |ratio| ratio * font_size);
LogicalMetrics {
font_size,
cell_size: Vec2::new(width.max(1.0), (font_size * line_height).max(1.0)),
}
}
(CellSizing::FromFont { .. }, FontSizing::FitCellWidth) => {
warn_once!(
"bevy_terminal: CellSizing::FromFont requires FontSizing::Px; using an 11×20 cell"
);
resolve_metrics(
&TerminalRenderConfig {
cell_size: CellSizing::default(),
..config.clone()
},
measured_advance,
)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct GlyphBox {
pub(crate) top: f32,
pub(crate) bottom: f32,
}
impl GlyphBox {
pub(crate) fn height(self) -> f32 {
self.bottom - self.top
}
pub(crate) fn union(self, other: GlyphBox) -> GlyphBox {
GlyphBox {
top: self.top.min(other.top),
bottom: self.bottom.max(other.bottom),
}
}
}
pub(crate) fn fitted_cell_height(cell_height: f32, block: Option<GlyphBox>) -> f32 {
block
.map(|block| block.height().ceil())
.filter(|height| *height > cell_height)
.unwrap_or(cell_height)
}
pub(crate) fn vertical_offset(
cell_height: f32,
block: Option<GlyphBox>,
core: Option<GlyphBox>,
accents: Option<GlyphBox>,
) -> f32 {
let mut low = f32::NEG_INFINITY;
let mut high = f32::INFINITY;
let mut narrow = |range_low: f32, range_high: f32| {
if range_low <= high && range_high >= low {
low = low.max(range_low);
high = high.min(range_high);
}
};
if let Some(block) = block
&& block.height() >= cell_height
{
narrow(cell_height - block.bottom, -block.top);
}
for ink in [core, accents].into_iter().flatten() {
if ink.height() <= cell_height {
narrow(-ink.top, cell_height - ink.bottom);
}
}
let target = core
.or(accents)
.map_or(0.0, |ink| (cell_height - ink.height()) / 2.0 - ink.top);
if low.is_finite() && high.is_finite() {
snap(target.clamp(low, high))
} else if low.is_finite() {
snap(target.max(low))
} else if high.is_finite() {
snap(target.min(high))
} else {
snap(target)
}
}
pub(crate) fn snap(value: f32) -> f32 {
(value + 0.5).floor()
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum TerminalRenderScale {
#[default]
Automatic,
Fixed(f32),
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CellSizing {
Logical(Vec2),
FromFont {
line_height: f32,
},
}
impl CellSizing {
pub const FROM_FONT: Self = Self::FromFont { line_height: 1.2 };
}
impl Default for CellSizing {
fn default() -> Self {
Self::Logical(Vec2::new(11.0, 20.0))
}
}
impl From<Vec2> for CellSizing {
fn from(cell: Vec2) -> Self {
Self::Logical(cell)
}
}
#[derive(Clone, Component, Debug, PartialEq)]
pub struct TerminalRenderConfig {
pub cell_size: CellSizing,
pub font: FontFaces,
pub font_size: FontSizing,
pub theme: TerminalTheme,
pub cursor: CursorConfig,
pub blink: BlinkConfig,
pub raster: RasterConfig,
}
impl Default for TerminalRenderConfig {
fn default() -> Self {
Self {
cell_size: CellSizing::default(),
font: FontFaces::default(),
font_size: FontSizing::FitCellWidth,
theme: TerminalTheme::default(),
cursor: CursorConfig::default(),
blink: BlinkConfig::default(),
raster: RasterConfig::default(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RasterConfig {
pub scale: TerminalRenderScale,
pub hinting: FontHinting,
}
impl Default for RasterConfig {
fn default() -> Self {
Self {
scale: TerminalRenderScale::Automatic,
hinting: FontHinting::Disabled,
}
}
}
#[derive(Clone, Debug, Hash, Eq, PartialEq, SystemSet)]
pub enum TerminalSystems {
Sync,
}
fn text_font(faces: &FontFaces, font_size: f32, style: &ResolvedStyle) -> TextFont {
let (face, weight, font_style) = faces.resolve(style.bold, style.italic);
TextFont {
font: face.clone(),
font_size: font_size.into(),
weight,
style: font_style,
..default()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct PixelGeometry {
x: f32,
y: f32,
width: f32,
height: f32,
}
fn cell_span(cells: &[TerminalCell], column: usize) -> usize {
let declared = usize::from(cells[column].columns()).min(cells.len() - column);
let mut span = 1;
while span < declared && cells[column + span].is_continuation() {
span += 1;
}
span
}
fn cursor_should_be_visible(snapshot: &TerminalSnapshot) -> bool {
let size = snapshot.size();
let position = snapshot.cursor_position();
snapshot.cursor_visible() && position.x < size.width && position.y < size.height
}
fn blink_hidden(elapsed: f32, frequency_hz: Option<f32>) -> bool {
frequency_hz.is_some_and(|frequency_hz| {
frequency_hz.is_finite()
&& frequency_hz > 0.0
&& (elapsed * frequency_hz * 2.0).floor() as u64 % 2 == 1
})
}
#[derive(Clone, Debug, PartialEq)]
struct ResolvedStyle {
foreground: Color,
background: Color,
underline: Color,
bold: bool,
italic: bool,
underlined: bool,
crossed_out: bool,
slow_blink: bool,
rapid_blink: bool,
hidden: bool,
}
impl ResolvedStyle {
pub(crate) fn plain() -> Self {
Self {
foreground: Color::WHITE,
background: Color::BLACK,
underline: Color::WHITE,
bold: false,
italic: false,
underlined: false,
crossed_out: false,
slow_blink: false,
rapid_blink: false,
hidden: false,
}
}
}
impl ResolvedStyle {
fn new(cell: &TerminalCell, theme: &TerminalTheme) -> Self {
let mut foreground = theme.foreground(cell.style.foreground);
let mut background = theme.background(cell.style.background);
if cell.style.has(StyleFlags::REVERSED) {
std::mem::swap(&mut foreground, &mut background);
}
let mut underline = theme.resolve(cell.style.underline, foreground);
if cell.style.has(StyleFlags::DIM) {
foreground = dim(foreground, background);
underline = dim(underline, background);
}
if cell.style.has(StyleFlags::HIDDEN) {
foreground = background;
underline = background;
}
Self {
foreground,
background,
underline,
bold: cell.style.has(StyleFlags::BOLD),
italic: cell.style.has(StyleFlags::ITALIC),
underlined: cell.style.has(StyleFlags::UNDERLINED),
crossed_out: cell.style.has(StyleFlags::CROSSED_OUT),
slow_blink: cell.style.has(StyleFlags::SLOW_BLINK),
rapid_blink: cell.style.has(StyleFlags::RAPID_BLINK),
hidden: cell.style.has(StyleFlags::HIDDEN),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{TerminalColor, TerminalStyle};
fn glyph_box(top: f32, bottom: f32) -> GlyphBox {
GlyphBox { top, bottom }
}
#[test]
fn snap_rounds_halves_consistently() {
assert_eq!(snap(0.5), 1.0);
assert_eq!(snap(-0.5), 0.0);
assert_eq!(snap(4.5) - snap(-0.5), 5.0);
assert_eq!(snap(2.49), 2.0);
assert_eq!(snap(-2.51), -3.0);
}
#[test]
fn cell_height_grows_to_the_block_box_only() {
assert_eq!(fitted_cell_height(24.0, Some(glyph_box(1.0, 28.0))), 27.0);
assert_eq!(fitted_cell_height(30.0, Some(glyph_box(1.0, 28.0))), 30.0);
assert_eq!(fitted_cell_height(20.0, None), 20.0);
assert_eq!(fitted_cell_height(20.0, Some(glyph_box(0.0, 22.4))), 23.0);
}
#[test]
fn vertical_offset_keeps_blocks_covering_then_centers_ink() {
let block = Some(glyph_box(1.0, 28.0));
let core = Some(glyph_box(3.0, 27.0));
let accents = Some(glyph_box(-4.0, 22.0));
assert_eq!(vertical_offset(27.0, block, core, accents), -1.0);
let block = Some(glyph_box(-3.0, 22.0));
let core = Some(glyph_box(0.0, 16.0));
assert_eq!(vertical_offset(20.0, block, core, None), 2.0);
let block = Some(glyph_box(2.0, 12.0));
let core = Some(glyph_box(4.0, 12.0));
let accents = Some(glyph_box(1.0, 12.0));
assert_eq!(vertical_offset(20.0, block, core, accents), 2.0);
assert_eq!(
vertical_offset(10.0, None, Some(glyph_box(-2.0, 12.0)), None),
0.0
);
assert_eq!(vertical_offset(20.0, None, None, None), 0.0);
}
#[test]
fn vertical_offset_prefers_core_ink_over_accents() {
let core = Some(glyph_box(2.0, 22.0));
let accents = Some(glyph_box(-2.0, 18.0));
assert_eq!(vertical_offset(20.0, None, core, accents), -2.0);
}
#[test]
fn styles_resolve_reverse_hidden_dim_and_decorations() {
let theme = TerminalTheme::default();
let mut cell = TerminalCell::new("X").with_style(
TerminalStyle::new()
.fg(TerminalColor::RED)
.bg(TerminalColor::BLUE)
.with(
StyleFlags::REVERSED
| StyleFlags::DIM
| StyleFlags::UNDERLINED
| StyleFlags::CROSSED_OUT
| StyleFlags::BOLD
| StyleFlags::ITALIC,
),
);
let style = ResolvedStyle::new(&cell, &theme);
assert_eq!(style.background, theme.ansi[1]);
assert_ne!(style.foreground, theme.ansi[4]);
assert!(style.bold && style.italic && style.underlined && style.crossed_out);
cell.style.flags.insert(StyleFlags::HIDDEN);
let hidden = ResolvedStyle::new(&cell, &theme);
assert_eq!(hidden.foreground, hidden.background);
assert!(hidden.hidden);
let reversed = TerminalCell::new("X").with_style(
TerminalStyle::new()
.fg(TerminalColor::RED)
.bg(TerminalColor::BLUE)
.with(StyleFlags::REVERSED | StyleFlags::UNDERLINED),
);
let reversed = ResolvedStyle::new(&reversed, &theme);
assert_eq!(reversed.foreground, theme.ansi[4]);
assert_eq!(reversed.underline, reversed.foreground);
}
#[test]
fn font_faces_fall_back_in_order() {
let regular = FontSource::from("regular");
let bold = FontSource::from("bold");
let italic = FontSource::from("italic");
let bold_italic = FontSource::from("bold italic");
let only_regular = FontFaces::regular(regular.clone());
assert_eq!(only_regular.select(true, true), ®ular);
assert_eq!(FontFaces::from(regular.clone()), only_regular);
let with_bold = FontFaces {
bold: Some(bold.clone()),
..only_regular.clone()
};
assert_eq!(with_bold.select(true, false), &bold);
assert_eq!(with_bold.select(true, true), &bold);
assert_eq!(with_bold.select(false, true), ®ular);
let with_italic = FontFaces {
italic: Some(italic.clone()),
..only_regular.clone()
};
assert_eq!(with_italic.select(true, true), &italic);
let complete = FontFaces {
regular: regular.clone(),
bold: Some(bold.clone()),
italic: Some(italic.clone()),
bold_italic: Some(bold_italic.clone()),
synthesize: true,
};
assert_eq!(complete.select(false, false), ®ular);
assert_eq!(complete.select(true, false), &bold);
assert_eq!(complete.select(false, true), &italic);
assert_eq!(complete.select(true, true), &bold_italic);
let theme = TerminalTheme::default();
let cell = TerminalCell::new("X")
.with_style(TerminalStyle::new().with(StyleFlags::BOLD | StyleFlags::ITALIC));
let font = text_font(&complete, 18.0, &ResolvedStyle::new(&cell, &theme));
assert_eq!(font.font, bold_italic);
assert_eq!(font.weight, FontWeight::BOLD);
assert_eq!(font.style, FontStyle::Italic);
let bold_only = FontFaces {
bold: Some(bold.clone()),
..FontFaces::regular(regular.clone())
};
let italic_cell =
TerminalCell::new("X").with_style(TerminalStyle::new().with(StyleFlags::ITALIC));
let synthesized = text_font(&bold_only, 18.0, &ResolvedStyle::new(&italic_cell, &theme));
assert_eq!(synthesized.font, regular);
assert_eq!(synthesized.style, FontStyle::Italic);
let plain = text_font(
&bold_only.clone().with_synthesis(false),
18.0,
&ResolvedStyle::new(&italic_cell, &theme),
);
assert_eq!(plain.font, regular);
assert_eq!(plain.style, FontStyle::Normal);
let bold_cell =
TerminalCell::new("X").with_style(TerminalStyle::new().with(StyleFlags::BOLD));
let exact = text_font(
&bold_only.with_synthesis(false),
18.0,
&ResolvedStyle::new(&bold_cell, &theme),
);
assert_eq!(exact.font, bold);
assert_eq!(exact.weight, FontWeight::BOLD);
}
#[test]
fn font_size_selection_uses_measured_advance_or_explicit_pixels() {
let config = TerminalRenderConfig {
cell_size: Vec2::new(11.0, 20.0).into(),
..default()
};
let fitted = resolve_metrics(&config, Some(38.4));
assert!((fitted.font_size - 11.0 / 0.6).abs() < 1e-3);
assert_eq!(fitted.cell_size, Vec2::new(11.0, 20.0));
assert_eq!(
resolve_metrics(&config, None).font_size,
UNMEASURED_FONT_SIZE
);
let explicit = TerminalRenderConfig {
font_size: FontSizing::Px(18.0),
..config.clone()
};
assert_eq!(resolve_metrics(&explicit, Some(38.4)).font_size, 18.0);
assert_eq!(resolve_metrics(&explicit, None).font_size, 18.0);
let from_font = TerminalRenderConfig {
cell_size: CellSizing::FROM_FONT,
font_size: FontSizing::Px(20.0),
..config
};
let metrics = resolve_metrics(&from_font, Some(38.4));
assert_eq!(metrics.font_size, 20.0);
assert!((metrics.cell_size.x - 12.0).abs() < 1e-4);
assert!((metrics.cell_size.y - 24.0).abs() < 1e-4);
let zoomed = TerminalRenderConfig {
font_size: FontSizing::Px(30.0),
..from_font.clone()
};
assert!((resolve_metrics(&zoomed, Some(38.4)).cell_size.x - 18.0).abs() < 1e-4);
let invalid = TerminalRenderConfig {
font_size: FontSizing::FitCellWidth,
..from_font
};
assert_eq!(
resolve_metrics(&invalid, Some(38.4)).cell_size,
Vec2::new(11.0, 20.0)
);
}
#[test]
fn wide_cells_span_only_their_continuations() {
let wide = TerminalCell::wide("界", 2);
let cells = [
wide.clone(),
TerminalCell::continuation_of(&wide),
TerminalCell::new("A"),
];
assert_eq!(cell_span(&cells, 0), 2);
assert_eq!(cell_span(&cells, 2), 1);
let overwritten = [wide.clone(), TerminalCell::new("B")];
assert_eq!(cell_span(&overwritten, 0), 1);
let clipped = [wide];
assert_eq!(cell_span(&clipped, 0), 1);
}
#[test]
fn blink_phase_alternates_at_twice_the_frequency() {
assert!(!blink_hidden(0.1, Some(1.0)));
assert!(blink_hidden(0.6, Some(1.0)));
assert!(!blink_hidden(0.6, None));
assert!(!blink_hidden(0.6, Some(0.0)));
}
}