Skip to main content

neco_tui/
lib.rs

1//! 外部依存ゼロの最小 ANSI ターミナルヘルパー。
2//!
3//! SGR (Select Graphic Rendition) エスケープシーケンスによるテキスト装飾と、
4//! 複数スタイルの合成を提供する。
5//!
6//! # 使い方
7//!
8//! ```
9//! use neco_tui::{style, Color, Style};
10//!
11//! // 単一スタイル
12//! let text = style("hello").fg(Color::Cyan).bold().to_string();
13//! assert!(text.starts_with("\x1b["));
14//! assert!(text.ends_with("\x1b[0m"));
15//!
16//! // リセットのみ
17//! assert_eq!(Style::RESET, "\x1b[0m");
18//! ```
19
20use std::fmt;
21
22/// SGR 前景色。
23///
24/// 標準 8 色を提供する。拡張色(256 色・RGB)はスコープ外。
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Color {
27    Black,
28    Red,
29    Green,
30    Yellow,
31    Blue,
32    Magenta,
33    Cyan,
34    White,
35}
36
37impl Color {
38    /// SGR 前景色コードを返す。
39    fn fg_code(self) -> u8 {
40        match self {
41            Self::Black => 30,
42            Self::Red => 31,
43            Self::Green => 32,
44            Self::Yellow => 33,
45            Self::Blue => 34,
46            Self::Magenta => 35,
47            Self::Cyan => 36,
48            Self::White => 37,
49        }
50    }
51}
52
53/// テキスト装飾の定数。
54pub struct Style;
55
56impl Style {
57    /// 全属性リセット。
58    pub const RESET: &str = "\x1b[0m";
59}
60
61/// スタイル付きテキストのビルダー。
62///
63/// [`style`] 関数で生成し、メソッドチェーンで装飾を追加する。
64/// [`Display`](fmt::Display) で ANSI エスケープ付き文字列を出力する。
65///
66/// # 例
67///
68/// ```
69/// use neco_tui::{style, Color};
70///
71/// let s = style("error").fg(Color::Red).bold();
72/// // Display で "\x1b[31;1merror\x1b[0m" を出力
73/// let output = s.to_string();
74/// assert!(output.contains("error"));
75/// assert!(output.ends_with("\x1b[0m"));
76/// ```
77pub struct Styled<'a> {
78    text: &'a str,
79    codes: Vec<u8>,
80}
81
82impl<'a> Styled<'a> {
83    /// 前景色を設定する。
84    pub fn fg(mut self, color: Color) -> Self {
85        self.codes.push(color.fg_code());
86        self
87    }
88
89    /// 太字にする。
90    pub fn bold(mut self) -> Self {
91        self.codes.push(1);
92        self
93    }
94
95    /// 暗くする(dim)。
96    pub fn dim(mut self) -> Self {
97        self.codes.push(2);
98        self
99    }
100}
101
102impl fmt::Display for Styled<'_> {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        if self.codes.is_empty() {
105            return f.write_str(self.text);
106        }
107        f.write_str("\x1b[")?;
108        for (i, code) in self.codes.iter().enumerate() {
109            if i > 0 {
110                f.write_str(";")?;
111            }
112            write!(f, "{code}")?;
113        }
114        f.write_str("m")?;
115        f.write_str(self.text)?;
116        f.write_str(Style::RESET)
117    }
118}
119
120/// テキストにスタイルを適用するビルダーを返す。
121///
122/// # 例
123///
124/// ```
125/// use neco_tui::{style, Color};
126///
127/// println!("{}", style("ok").fg(Color::Green));
128/// println!("{}", style("warning").fg(Color::Yellow).bold());
129/// ```
130pub fn style(text: &str) -> Styled<'_> {
131    Styled {
132        text,
133        codes: Vec::new(),
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn style_without_codes_returns_plain_text() {
143        assert_eq!(style("hello").to_string(), "hello");
144    }
145
146    #[test]
147    fn single_fg_color_wraps_with_sgr() {
148        let output = style("ok").fg(Color::Green).to_string();
149        assert_eq!(output, "\x1b[32mok\x1b[0m");
150    }
151
152    #[test]
153    fn bold_produces_code_1() {
154        let output = style("title").bold().to_string();
155        assert_eq!(output, "\x1b[1mtitle\x1b[0m");
156    }
157
158    #[test]
159    fn combined_styles_join_with_semicolon() {
160        let output = style("err").fg(Color::Red).bold().to_string();
161        assert_eq!(output, "\x1b[31;1merr\x1b[0m");
162    }
163
164    #[test]
165    fn dim_produces_code_2() {
166        let output = style("faint").dim().to_string();
167        assert_eq!(output, "\x1b[2mfaint\x1b[0m");
168    }
169
170    #[test]
171    fn all_colors_produce_distinct_codes() {
172        let colors = [
173            (Color::Black, 30),
174            (Color::Red, 31),
175            (Color::Green, 32),
176            (Color::Yellow, 33),
177            (Color::Blue, 34),
178            (Color::Magenta, 35),
179            (Color::Cyan, 36),
180            (Color::White, 37),
181        ];
182        for (color, expected) in colors {
183            let output = style("x").fg(color).to_string();
184            assert_eq!(output, format!("\x1b[{expected}mx\x1b[0m"));
185        }
186    }
187
188    #[test]
189    fn reset_constant_is_sgr_0() {
190        assert_eq!(Style::RESET, "\x1b[0m");
191    }
192}