Skip to content

Commit 89e5f52

Browse files
committed
feat: parse palettes with ANSI OSC 4 codes
Screenshot parsing is still supported but is only attempted if the OSC method fails. Thanks to shiomiru on Hacker News for suggesting this: https://news.ycombinator.com/item?id=44270561 Relates to: #99, #107
1 parent c540f74 commit 89e5f52

File tree

13 files changed

+305
-86
lines changed

13 files changed

+305
-86
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tattoy/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "tattoy"
33
description = "Eye-candy for your terminal"
4-
version = "0.1.2"
4+
version = "0.1.3"
55
edition = "2021"
66
readme = "README.md"
77
repository = "https://github.com/tombh/tattoy"

crates/tattoy/src/cli_args.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ pub(crate) struct CliArgs {
2323
#[arg(long)]
2424
pub command: Option<String>,
2525

26-
/// Use image capture to detect the true colour values of the terminal's palette.
26+
/// Capture the true color values of the terminal's palette. First tries using ANSI CSI queries
27+
/// and if that fails resorts to parsing a screenshot of the palette (with user's consent).
2728
#[arg(long)]
2829
pub capture_palette: bool,
2930

crates/tattoy/src/config/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ impl Config {
405405
pub async fn load_palette(
406406
state: std::sync::Arc<crate::shared_state::SharedState>,
407407
) -> Result<crate::palette::converter::Palette> {
408-
let path = crate::palette::parser::Parser::palette_config_path(&state).await;
408+
let path = crate::palette::main::palette_config_path(&state).await;
409409
if !path.exists() {
410410
color_eyre::eyre::bail!(
411411
"Terminal palette colours config file not found at: {}",
@@ -415,7 +415,7 @@ impl Config {
415415

416416
tracing::info!("Loading the terminal palette's true colours from config");
417417
let data = tokio::fs::read_to_string(path).await?;
418-
let map = toml::from_str::<crate::palette::converter::PaletteHashMap>(&data)?;
418+
let map = toml::from_str::<crate::palette::main::PaletteHashMap>(&data)?;
419419
let palette = crate::palette::converter::Palette { map };
420420
Ok(palette)
421421
}

crates/tattoy/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ pub mod raw_input;
1717
/// The palette code is for helping convert a terminal's palette to true colour.
1818
pub mod palette {
1919
pub mod converter;
20+
pub mod main;
21+
pub mod osc;
2022
pub mod parser;
2123
pub mod state_machine;
2224
}

crates/tattoy/src/palette/converter.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,11 @@ use color_eyre::{eyre::ContextCompat as _, Result};
66
/// the palette when no other index or true colour is specified.
77
const DEFAULT_TEXT_PALETTE_INDEX: u8 = 15;
88

9-
/// A single palette colour.
10-
type PaletteColour = (u8, u8, u8);
11-
12-
/// A hash of palette indexes to true colour values.
13-
pub type PaletteHashMap = std::collections::HashMap<String, PaletteColour>;
14-
159
/// Convenience type for the palette hash.
1610
#[derive(Clone)]
1711
pub(crate) struct Palette {
1812
/// The palette hash.
19-
pub map: PaletteHashMap,
13+
pub map: super::main::PaletteHashMap,
2014
}
2115

2216
impl Palette {

crates/tattoy/src/palette/main.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//! Get the true colour values for the terminal's colour palette.
2+
3+
#![expect(clippy::print_stdout, reason = "We need to give user feedback")]
4+
5+
use color_eyre::Result;
6+
/// A default palette for users that can't parse their own palette.
7+
const DEFAULT_PALETTE: &str = include_str!("../../default_palette.toml");
8+
9+
/// A single palette colour.
10+
pub type PaletteColour = (u8, u8, u8);
11+
12+
/// A hash of palette indexes to true colour values.
13+
pub type PaletteHashMap = std::collections::HashMap<String, PaletteColour>;
14+
15+
/// Get the terminal's colour palette.
16+
pub(crate) async fn get_palette(
17+
state: &std::sync::Arc<crate::shared_state::SharedState>,
18+
) -> Result<()> {
19+
match super::osc::OSC::run(state).await {
20+
Ok(()) => return Ok(()),
21+
Err(error) => tracing::warn!("Failed getting palette with OSC query: {error:?}"),
22+
}
23+
24+
super::parser::Parser::run(state, None).await?;
25+
26+
Ok(())
27+
}
28+
29+
/// Canonical path to the palette config file.
30+
pub(crate) async fn palette_config_path(
31+
state: &std::sync::Arc<crate::shared_state::SharedState>,
32+
) -> std::path::PathBuf {
33+
crate::config::main::Config::directory(state)
34+
.await
35+
.join("palette.toml")
36+
}
37+
38+
/// Does a palette config file exist?
39+
pub(crate) async fn palette_config_exists(
40+
state: &std::sync::Arc<crate::shared_state::SharedState>,
41+
) -> bool {
42+
palette_config_path(state).await.exists()
43+
}
44+
45+
/// Save the default palette config to the user's Tattoy config path.
46+
pub(crate) async fn set_default_palette(
47+
state: &std::sync::Arc<crate::shared_state::SharedState>,
48+
) -> Result<()> {
49+
let path = palette_config_path(state).await;
50+
std::fs::write(path.clone(), DEFAULT_PALETTE)?;
51+
52+
println!("Default palette saved to: {}", path.display());
53+
Ok(())
54+
}
55+
56+
/// Save the parsed palette true colours as TOML in the Tattoy config directory.
57+
pub(crate) async fn save(
58+
state: &std::sync::Arc<crate::shared_state::SharedState>,
59+
palette: &crate::palette::converter::Palette,
60+
) -> Result<()> {
61+
let path = palette_config_path(state).await;
62+
let data = toml::to_string(&palette.map)?;
63+
std::fs::write(path.clone(), data)?;
64+
65+
println!("Palette saved to: {}", path.display());
66+
Ok(())
67+
}

crates/tattoy/src/palette/osc.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
//! Use OSC codes to query the terminal emulator about what RGB values it uses for each palette
2+
//! index.
3+
4+
use color_eyre::eyre::{ContextCompat as _, Result};
5+
use termwiz::terminal::Terminal as _;
6+
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7+
8+
/// The amount of time in seconds to wait for a response from the host terminal emulator.
9+
const WAIT_FOR_RESPONSE_TIMEOUT: u64 = 1;
10+
11+
/// `OSC`
12+
pub(crate) struct OSC;
13+
14+
impl OSC {
15+
/// Main entry point.
16+
pub async fn run(state: &std::sync::Arc<crate::shared_state::SharedState>) -> Result<()> {
17+
let mut termwiz_terminal = crate::renderer::Renderer::get_termwiz_terminal()?;
18+
19+
termwiz_terminal.set_raw_mode()?;
20+
let result = Self::query_terminal().await;
21+
termwiz_terminal.set_cooked_mode()?;
22+
23+
match result {
24+
Ok(hashmap) => {
25+
let palette = super::converter::Palette { map: hashmap };
26+
super::main::save(state, &palette).await?;
27+
}
28+
Err(error) => color_eyre::eyre::bail!(error),
29+
}
30+
31+
Ok(())
32+
}
33+
34+
/// Send OSC codes to the user's terminal emulator to query the terminal's palette.
35+
async fn query_terminal() -> Result<super::main::PaletteHashMap> {
36+
let mut tty = tokio::fs::OpenOptions::new()
37+
.read(true)
38+
.write(true)
39+
.open("/dev/tty")
40+
.await?;
41+
42+
let mut command = String::new();
43+
for index in 0..255u8 {
44+
command.extend(
45+
format!("{}]4;{index};?{}", crate::utils::ESCAPE, crate::utils::BELL).chars(),
46+
);
47+
}
48+
tty.write_all(command.as_bytes()).await?;
49+
tty.flush().await?;
50+
51+
let palette = Self::read_response(tty).await?;
52+
tracing::debug!("OSC response to palette RGB query: {palette:?}");
53+
Ok(palette)
54+
}
55+
56+
/// Read the response from the controlling terminal after sending an OSC code.
57+
async fn read_response(tty: tokio::fs::File) -> Result<super::main::PaletteHashMap> {
58+
let buffer_size = 1024;
59+
let mut reader = tokio::io::BufReader::new(tty);
60+
let mut all = Vec::new();
61+
let mut attempts = 0u16;
62+
63+
loop {
64+
let mut buffer = vec![0; buffer_size];
65+
let result = tokio::time::timeout(
66+
tokio::time::Duration::from_secs(WAIT_FOR_RESPONSE_TIMEOUT),
67+
reader.read(&mut buffer),
68+
)
69+
.await;
70+
attempts += 1;
71+
if result.is_err() || attempts > 300 {
72+
let message = "Timed out waiting for response from controlling terminal \
73+
when querying for palette colour values";
74+
tracing::warn!(message);
75+
color_eyre::eyre::bail!(message);
76+
}
77+
78+
buffer.retain(|&x| x != 0);
79+
all.extend(buffer.clone());
80+
81+
let response = &String::from_utf8_lossy(&all)
82+
.replace(crate::utils::ESCAPE, "ESC")
83+
.replace(crate::utils::STRING_TERMINATOR, "ST")
84+
.replace(crate::utils::BELL, "BELL");
85+
86+
match Self::parse_colours(response) {
87+
Ok(colours) => {
88+
if colours.len() == 255 {
89+
return Ok(colours);
90+
}
91+
}
92+
Err(error) => tracing::debug!("Potential error parsing OSC codes: {error:?}"),
93+
}
94+
}
95+
}
96+
97+
/// Parse the OSC response for palette colours.
98+
fn parse_colours(response: &str) -> Result<super::main::PaletteHashMap> {
99+
let mut palette = super::main::PaletteHashMap::new();
100+
for sequence in response.split("ESC]4;") {
101+
if sequence.is_empty() {
102+
continue;
103+
}
104+
tracing::trace!("Parsing OSC sequence: {sequence}");
105+
106+
let mut index_and_colour = sequence.split(';');
107+
let index = index_and_colour
108+
.next()
109+
.context(format!("OSC sequence not delimited by colon: {sequence}"))?
110+
.to_owned();
111+
let colourish = index_and_colour
112+
.next()
113+
.context(format!("Colour not found in OSC sequence: {sequence}"))?;
114+
115+
let mut channels = colourish.split('/');
116+
let red = Self::get_last_chars(
117+
channels
118+
.next()
119+
.context(format!("Couldn't get red from OSC response: {colourish:?}"))?,
120+
2,
121+
);
122+
let green = Self::get_last_chars(
123+
channels.next().context(format!(
124+
"Couldn't get green from OSC response: {colourish:?}"
125+
))?,
126+
2,
127+
);
128+
let blue = Self::get_first_chars(
129+
channels.next().context(format!(
130+
"Couldn't get blue from OSC response: {colourish:?}"
131+
))?,
132+
2,
133+
);
134+
135+
let colour: super::main::PaletteColour = (
136+
u8::from_str_radix(&red, 16)?,
137+
u8::from_str_radix(&green, 16)?,
138+
u8::from_str_radix(&blue, 16)?,
139+
);
140+
palette.insert(index, colour);
141+
}
142+
143+
Ok(palette)
144+
}
145+
146+
/// Get the first x characters from a string.
147+
fn get_first_chars(string: &str, count: usize) -> String {
148+
string.chars().take(count).collect::<String>()
149+
}
150+
151+
/// Get the last x characters from a string.
152+
fn get_last_chars(string: &str, count: usize) -> String {
153+
string
154+
.chars()
155+
.rev()
156+
.take(count)
157+
.collect::<String>()
158+
.chars()
159+
.rev()
160+
.collect::<String>()
161+
}
162+
}
163+
164+
#[cfg(test)]
165+
#[expect(clippy::indexing_slicing, reason = "It's just a test")]
166+
mod test {
167+
use super::*;
168+
169+
#[test]
170+
fn parsing_osc_colours() {
171+
let response = "ESC]4;1;rgb:c0c0/2222/eaeaBELLESC]4;229;rgb:aaaa/ffff/afafBELL";
172+
let palette = OSC::parse_colours(response).unwrap();
173+
assert_eq!(palette["1"], (192, 34, 234));
174+
assert_eq!(palette["229"], (170, 255, 175));
175+
}
176+
}

0 commit comments

Comments
 (0)