Skip to main content

beamterm_core/
lib.rs

1pub(crate) mod error;
2pub mod gl;
3mod mat4;
4mod position;
5mod url;
6
7pub use ::beamterm_data::{
8    CellSize, DebugSpacePattern, FontAtlasData, GlyphEffect, SerializationError, TerminalSize,
9};
10pub use beamterm_data::FontStyle;
11pub use error::Error;
12pub use gl::{
13    Atlas, CellData, CellDynamic, CellIterator, CellQuery, Drawable, FontAtlas, GlState, GlyphSlot,
14    GlyphTracker, RenderContext, SelectionMode, SelectionTracker, StaticFontAtlas, TerminalGrid,
15    select,
16};
17#[cfg(feature = "native-dynamic-atlas")]
18pub use gl::{NativeDynamicAtlas, NativeGlyphRasterizer};
19pub use position::CursorPosition;
20use unicode_width::UnicodeWidthStr;
21pub use url::{UrlMatch, find_url_at_cursor};
22
23/// GL shader language target for version injection.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum GlslVersion {
27    /// WebGL2 / OpenGL ES 3.0: `#version 300 es`
28    Es300,
29    /// OpenGL 3.3 Core: `#version 330 core`
30    Gl330,
31}
32
33impl GlslVersion {
34    pub fn vertex_preamble(&self) -> &'static str {
35        match self {
36            Self::Es300 => "#version 300 es\nprecision highp float;\n",
37            Self::Gl330 => "#version 330 core\n",
38        }
39    }
40
41    pub fn fragment_preamble(&self) -> &'static str {
42        match self {
43            Self::Es300 => "#version 300 es\nprecision mediump float;\nprecision highp int;\n",
44            Self::Gl330 => "#version 330 core\n",
45        }
46    }
47}
48
49/// Checks if a grapheme is an emoji that should use color font rendering.
50///
51/// Uses UTF-8 byte-level checks and a codepoint table to avoid calling
52/// `unicode-width` for single-codepoint strings (the common case). Only
53/// multi-codepoint sequences (ZWJ, flags, keycaps, text + FE0F) fall
54/// through to a `width()` check.
55pub fn is_emoji(s: &str) -> bool {
56    let bytes = s.as_bytes();
57    let first_byte = match bytes.first() {
58        Some(&b) => b,
59        None => return false,
60    };
61
62    // ASCII (1 byte, U+0000–U+007F): single ASCII is never emoji, but
63    // multi-codepoint sequences starting with ASCII can be (e.g. keycap "1️⃣").
64    if first_byte < 0x80 {
65        return s.len() > 1 && s.width() >= 2;
66    }
67
68    // 2-byte UTF-8 (U+0080–U+07FF): no emoji exist in this range.
69    if first_byte < 0xE0 {
70        return s.len() > 2 && s.width() >= 2;
71    }
72
73    // 3+ byte UTF-8: decode the first codepoint.
74    // SAFETY: we verified the string is non-empty and starts with a 3+ byte sequence.
75    let first = unsafe { s.chars().next().unwrap_unchecked() };
76    let first_len = first.len_utf8();
77
78    // Single codepoint
79    if s.len() == first_len {
80        // 3-byte (BMP, U+0800–U+FFFF): emoji table is exact — skip width().
81        // 4-byte (SMP, U+10000+): range check is broad, verify with width().
82        return if first_len == 3 {
83            is_emoji_presentation(first)
84        } else {
85            s.width() >= 2 && is_emoji_presentation(first)
86        };
87    }
88
89    // Multi-codepoint: emoji if wide (ZWJ, flags, skin tones, text + FE0F).
90    s.width() >= 2
91}
92
93/// Checks if a grapheme is double-width (emoji or fullwidth character).
94pub fn is_double_width(grapheme: &str) -> bool {
95    grapheme.width() >= 2
96}
97
98/// Returns `true` for characters with emoji-presentation-by-default that
99/// `unicode-width` reports as width 2. This covers BMP emoji (60 code
100/// points) and SMP emoji (U+1F000–U+1FFFF), excluding CJK Enclosed
101/// Ideographic Supplement characters that are wide but not emoji.
102///
103/// Derived from cross-referencing every entry in the `emojis` 0.8 crate
104/// against `unicode-width` 0.2 — see `tests/enumerate_emojis_crate.rs`.
105fn is_emoji_presentation(c: char) -> bool {
106    let cp = c as u32;
107
108    match cp {
109        // BMP emoji with default emoji presentation (60 code points, U+231A–U+2B55).
110        0x231A..=0x2B55 => matches!(
111            cp,
112            0x231A..=0x231B   // ⌚⌛
113            | 0x23E9..=0x23EC // ⏩⏪⏫⏬
114            | 0x23F0           // ⏰
115            | 0x23F3           // ⏳
116            | 0x25FD..=0x25FE // ◽◾
117            | 0x2614..=0x2615 // ☔☕
118            | 0x2648..=0x2653 // ♈..♓
119            | 0x267F           // ♿
120            | 0x2693           // ⚓
121            | 0x26A1           // ⚡
122            | 0x26AA..=0x26AB // ⚪⚫
123            | 0x26BD..=0x26BE // ⚽⚾
124            | 0x26C4..=0x26C5 // ⛄⛅
125            | 0x26CE           // ⛎
126            | 0x26D4           // ⛔
127            | 0x26EA           // ⛪
128            | 0x26F2..=0x26F3 // ⛲⛳
129            | 0x26F5           // ⛵
130            | 0x26FA           // ⛺
131            | 0x26FD           // ⛽
132            | 0x2705           // ✅
133            | 0x270A..=0x270B // ✊✋
134            | 0x2728           // ✨
135            | 0x274C           // ❌
136            | 0x274E           // ❎
137            | 0x2753..=0x2755 // ❓❔❕
138            | 0x2757           // ❗
139            | 0x2795..=0x2797 // ➕➖➗
140            | 0x27B0           // ➰
141            | 0x27BF           // ➿
142            | 0x2B1B..=0x2B1C // ⬛⬜
143            | 0x2B50           // ⭐
144            | 0x2B55           // ⭕
145        ),
146        // SMP emoji: nearly all characters in U+1F000–U+1FFFF are emoji.
147        // Exclude CJK Enclosed Ideographic Supplement (EAW=W text symbols).
148        0x1F000..=0x1FFFF => !matches!(
149            cp,
150            0x1F200
151                | 0x1F202..=0x1F219
152                | 0x1F21B..=0x1F22E
153                | 0x1F230..=0x1F231
154                | 0x1F237
155                | 0x1F23B..=0x1F24F
156                | 0x1F260..=0x1F265
157        ),
158        _ => false,
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_is_emoji() {
168        // Emoji-presentation-by-default: always emoji
169        assert!(is_emoji("\u{1F680}"));
170        assert!(is_emoji("\u{1F600}"));
171        assert!(is_emoji("\u{23E9}"));
172        assert!(is_emoji("\u{23EA}"));
173
174        // Text-presentation-by-default with FE0F: emoji
175        assert!(is_emoji("\u{25B6}\u{FE0F}"));
176
177        // Text-presentation-by-default without FE0F: NOT emoji
178        assert!(!is_emoji("\u{25B6}"));
179        assert!(!is_emoji("\u{25C0}"));
180        assert!(!is_emoji("\u{23ED}"));
181        assert!(!is_emoji("\u{23F9}"));
182        assert!(!is_emoji("\u{23EE}"));
183        assert!(!is_emoji("\u{25AA}"));
184        assert!(!is_emoji("\u{25AB}"));
185        assert!(!is_emoji("\u{25FC}"));
186
187        // Not emoji
188        assert!(!is_emoji("A"));
189        assert!(!is_emoji("\u{2588}"));
190    }
191
192    #[test]
193    fn test_is_double_width() {
194        // emoji-presentation-by-default
195        assert!(is_double_width("\u{1F600}"));
196        assert!(is_double_width(
197            "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"
198        )); // ZWJ sequence
199
200        [
201            "\u{231A}", "\u{231B}", "\u{23E9}", "\u{23F3}", "\u{2614}", "\u{2615}", "\u{2648}",
202            "\u{2653}", "\u{267F}", "\u{2693}", "\u{26A1}", "\u{26AA}", "\u{26AB}", "\u{26BD}",
203            "\u{26BE}", "\u{26C4}", "\u{26C5}", "\u{26CE}", "\u{26D4}", "\u{26EA}", "\u{26F2}",
204            "\u{26F3}", "\u{26F5}", "\u{26FA}", "\u{26FD}", "\u{25FE}", "\u{2B1B}", "\u{2B1C}",
205            "\u{2B50}", "\u{2B55}", "\u{3030}", "\u{303D}", "\u{3297}", "\u{3299}",
206        ]
207        .iter()
208        .for_each(|s| {
209            assert!(is_double_width(s), "Failed for emoji: {s}");
210        });
211
212        // text-presentation-by-default with FE0F: double-width
213        assert!(is_double_width("\u{25B6}\u{FE0F}"));
214        assert!(is_double_width("\u{25C0}\u{FE0F}"));
215
216        // text-presentation-by-default without FE0F: single-width
217        assert!(!is_double_width("\u{23F8}"));
218        assert!(!is_double_width("\u{23FA}"));
219        assert!(!is_double_width("\u{25AA}"));
220        assert!(!is_double_width("\u{25AB}"));
221        assert!(!is_double_width("\u{25B6}"));
222        assert!(!is_double_width("\u{25C0}"));
223        assert!(!is_double_width("\u{25FB}"));
224        assert!(!is_double_width("\u{2934}"));
225        assert!(!is_double_width("\u{2935}"));
226        assert!(!is_double_width("\u{2B05}"));
227        assert!(!is_double_width("\u{2B07}"));
228        assert!(!is_double_width("\u{26C8}"));
229
230        // CJK
231        assert!(is_double_width("\u{4E2D}"));
232        assert!(is_double_width("\u{65E5}"));
233
234        // single-width
235        assert!(!is_double_width("A"));
236        assert!(!is_double_width("\u{2192}"));
237    }
238
239    #[test]
240    fn test_font_atlas_config_deserialization() {
241        let _ = FontAtlasData::default();
242    }
243}