diff --git a/Cargo.lock b/Cargo.lock index 0b62a58..402f746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "android_system_properties" @@ -29,15 +29,20 @@ version = "0.0.3" dependencies = [ "chrono", "libc", + "sixel-bytes", "unicode-segmentation", "unicode-width", ] [[package]] name = "cc" -version = "1.0.79" +version = "1.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" @@ -100,7 +105,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn", + "syn 1.0.107", ] [[package]] @@ -117,9 +122,15 @@ checksum = "357f40d1f06a24b60ae1fe122542c1fb05d28d32acb2aed064e84bc2ad1e252e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.107", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "iana-time-zone" version = "0.1.53" @@ -155,9 +166,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.139" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "link-cplusplus" @@ -177,6 +188,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "make-cmd" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ca8afbe8af1785e09636acb5a41e08a765f5f0340568716c18a8700ba3c0d3" + [[package]] name = "num-integer" version = "0.1.45" @@ -189,43 +206,80 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" -version = "1.17.0" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pkg-config" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "proc-macro2" -version = "1.0.50" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.23" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scratch" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sixel-bytes" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cad296a72571e80953823496e9a55caf893e264de9a7c5cfd29427fca720fc" +dependencies = [ + "sixel-sys-static", +] + +[[package]] +name = "sixel-sys-static" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2988846c5099382a880a7dd385d38b203a60430710a9c22e538d500e6908f4f9" +dependencies = [ + "make-cmd", + "pkg-config", +] + [[package]] name = "syn" version = "1.0.107" @@ -237,6 +291,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "termcolor" version = "1.2.0" @@ -283,34 +348,36 @@ checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "wasm-bindgen" -version = "0.2.83" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.83" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.83" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -318,22 +385,25 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.83" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.83" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] [[package]] name = "winapi" diff --git a/Cargo.toml b/Cargo.toml index 295337c..56ae4e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ libc = "0.2" unicode-width = "0.1.10" unicode-segmentation = "1.10.0" chrono = "0.4.23" +sixel-bytes = "0.2" [lib] name = "carbonyl" diff --git a/build.rs b/build.rs index 2ff9a33..09f46fb 100644 --- a/build.rs +++ b/build.rs @@ -12,8 +12,14 @@ fn link_sysroot() { "cargo:rustc-link-arg=--sysroot=./chromium/src/build/linux/debian_bullseye_amd64-sysroot" ); } else { - println!("cargo:warning={}", "x86_64 debian sysroot provided by chromium was not found!"); - println!("cargo:warning={}", "carbonyl may fail to link against a proper libc!"); + println!( + "cargo:warning={}", + "x86_64 debian sysroot provided by chromium was not found!" + ); + println!( + "cargo:warning={}", + "carbonyl may fail to link against a proper libc!" + ); } } @@ -29,8 +35,14 @@ fn link_sysroot() { "cargo:rustc-link-arg=--sysroot=./chromium/src/build/linux/debian_bullseye_i386-sysroot" ); } else { - println!("cargo:warning={}", "x86 debian sysroot provided by chromium was not found!"); - println!("cargo:warning={}", "carbonyl may fail to link against a proper libc!"); + println!( + "cargo:warning={}", + "x86 debian sysroot provided by chromium was not found!" + ); + println!( + "cargo:warning={}", + "carbonyl may fail to link against a proper libc!" + ); } } diff --git a/readme.md b/readme.md index 5d79824..6b86b66 100644 --- a/readme.md +++ b/readme.md @@ -67,6 +67,10 @@ $ carbonyl https://github.com +## Rendering + +Carbonyl now prefers Sixel graphics for page rendering whenever the terminal reports support. Legacy character-based rendering is automatically used as a fallback if Sixel frames cannot be sent. Sixel scrolling is enabled by default so web content can be browsed normally; set `CARBONYL_SIXEL_SCROLL=off` (or `0`) to opt back into the legacy non-scrolling behaviour. + ## Known issues - Fullscreen mode not supported yet diff --git a/scripts/python3 b/scripts/python3 new file mode 100755 index 0000000..6ba1ecc --- /dev/null +++ b/scripts/python3 @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exec "$HOME/carbonyl/chromium/depot_tools/vpython3" "$@" diff --git a/src/browser/bridge.rs b/src/browser/bridge.rs index 7b3d629..2e7e030 100644 --- a/src/browser/bridge.rs +++ b/src/browser/bridge.rs @@ -187,12 +187,15 @@ pub extern "C" fn carbonyl_renderer_resize(bridge: RendererPtr) { let mut bridge = bridge.unwrap().lock().unwrap(); let window = bridge.window.update(); let cells = window.cells.clone(); + // Use the full terminal pixel geometry for SIXEL frames. + let geometry = window.graphics_px; log::debug!("resizing renderer, terminal window: {:?}", window); - bridge - .renderer - .render(move |renderer| renderer.set_size(cells)); + bridge.renderer.render(move |renderer| { + renderer.set_size(cells); + renderer.update_sixel_geometry(geometry); + }); } #[no_mangle] @@ -411,6 +414,11 @@ pub extern "C" fn carbonyl_renderer_listen(bridge: RendererPtr, delegate: *mut B Terminal(terminal) => match terminal { TerminalEvent::Name(name) => log::debug!("terminal name: {name}"), TerminalEvent::TrueColorSupported => renderer.enable_true_color(), + TerminalEvent::SixelSupported { .. } => { + let geometry = bridge.lock().unwrap().window.graphics_px; + + renderer.enable_sixel(geometry) + } }, } } diff --git a/src/cli/cli.rs b/src/cli/cli.rs index fc977b7..997d377 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -9,6 +9,7 @@ pub struct CommandLine { pub zoom: f32, pub debug: bool, pub bitmap: bool, + pub sixel_only: bool, pub program: CommandLineProgram, pub shell_mode: bool, } @@ -16,6 +17,7 @@ pub struct CommandLine { pub enum EnvVar { Debug, Bitmap, + SixelOnly, ShellMode, } @@ -24,6 +26,7 @@ impl EnvVar { match self { EnvVar::Debug => "CARBONYL_ENV_DEBUG", EnvVar::Bitmap => "CARBONYL_ENV_BITMAP", + EnvVar::SixelOnly => "CARBONYL_ENV_SIXEL_ONLY", EnvVar::ShellMode => "CARBONYL_ENV_SHELL_MODE", } } @@ -41,6 +44,7 @@ impl CommandLine { let mut zoom = 1.0; let mut debug = false; let mut bitmap = false; + let mut sixel_only = true; let mut shell_mode = false; let mut program = CommandLineProgram::Main; let args = env::args().skip(1).collect::>(); @@ -77,6 +81,12 @@ impl CommandLine { "-z" | "--zoom" => set_f32!(zoom = zoom / 100.0), "-d" | "--debug" => set!(debug, Debug), "-b" | "--bitmap" => set!(bitmap, Bitmap), + "--sixel-only" => set!(sixel_only, SixelOnly), + "--legacy-text" => { + sixel_only = false; + + env::set_var(EnvVar::SixelOnly, "0"); + } "-h" | "--help" => program = CommandLineProgram::Help, "-v" | "--version" => program = CommandLineProgram::Version, @@ -92,6 +102,14 @@ impl CommandLine { bitmap = true; } + if let Ok(value) = env::var(EnvVar::SixelOnly) { + let normalized = value.trim().to_ascii_lowercase(); + + sixel_only = !matches!(normalized.as_str(), "0" | "false" | "off" | "no"); + } + + env::set_var(EnvVar::SixelOnly, if sixel_only { "1" } else { "0" }); + if env::var(EnvVar::ShellMode).is_ok() { shell_mode = true; } @@ -102,6 +120,7 @@ impl CommandLine { zoom, debug, bitmap, + sixel_only, program, shell_mode, } diff --git a/src/cli/usage.txt b/src/cli/usage.txt index 5a2083d..e6c355c 100644 --- a/src/cli/usage.txt +++ b/src/cli/usage.txt @@ -9,6 +9,7 @@ Usage: carbonyl [options] [url] Options: -f, --fps= set the maximum number of frames per second (default: 60) -z, --zoom= set the zoom level in percent (default: 100) + --legacy-text re-enable the legacy ANSI text renderer -b, --bitmap render text as bitmaps -d, --debug enable debug logs -h, --help display this help message diff --git a/src/input.rs b/src/input.rs index f78e753..f71313e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,4 +1,5 @@ mod dcs; +mod graphics; mod keyboard; mod listen; mod mouse; diff --git a/src/input/graphics.rs b/src/input/graphics.rs new file mode 100644 index 0000000..12066ac --- /dev/null +++ b/src/input/graphics.rs @@ -0,0 +1,72 @@ +use crate::control_flow; + +use super::{Event, ParseControlFlow, TerminalEvent}; + +#[derive(Default, Clone, Debug)] +pub struct Graphics { + params: Vec, + buffer: Vec, +} + +impl Graphics { + pub fn new() -> Self { + Self::default() + } + + pub fn parse(&mut self, key: u8) -> ParseControlFlow { + match key { + b'0'..=b'9' => { + self.buffer.push(key); + + control_flow!(continue) + } + b';' => { + self.push_param(); + + control_flow!(continue) + } + b'S' => { + self.push_param(); + + control_flow!(break self.event())? + } + _ => control_flow!(break)?, + } + } + + fn push_param(&mut self) { + if self.buffer.is_empty() { + self.params.push(0); + return; + } + + if let Ok(text) = std::str::from_utf8(&self.buffer) { + if let Ok(value) = text.parse::() { + self.params.push(value); + } + } + + self.buffer.clear(); + } + + fn event(&mut self) -> Option { + let params = std::mem::take(&mut self.params); + + if params.len() >= 2 { + let item = params[0]; + let status = params[1]; + + if item == 2 && status == 0 { + let width = params.get(2).copied().unwrap_or_default(); + let height = params.get(3).copied().unwrap_or_default(); + + return Some(Event::Terminal(TerminalEvent::SixelSupported { + width, + height, + })); + } + } + + None + } +} diff --git a/src/input/parser.rs b/src/input/parser.rs index f27b630..f9f6b5c 100644 --- a/src/input/parser.rs +++ b/src/input/parser.rs @@ -1,5 +1,6 @@ use std::ops::ControlFlow; +use super::graphics::Graphics; use crate::input::*; #[derive(Default)] @@ -17,12 +18,14 @@ enum Sequence { Mouse(Mouse), Keyboard(Keyboard), DeviceControl(DeviceControl), + Graphics(Graphics), } #[derive(Clone, Debug)] pub enum TerminalEvent { Name(String), TrueColorSupported, + SixelSupported { width: u32, height: u32 }, } #[derive(Clone, Debug)] @@ -91,12 +94,14 @@ impl Parser { }, Sequence::Control => match key { b'<' => Sequence::Mouse(Mouse::new()), + b'?' => Sequence::Graphics(Graphics::new()), b'1' => Sequence::Keyboard(Keyboard::new()), key => emit!(Keyboard::key(key, 0)), }, Sequence::Mouse(ref mut mouse) => parse!(mouse, key), Sequence::Keyboard(ref mut keyboard) => parse!(keyboard, key), Sequence::DeviceControl(ref mut dcs) => parse!(dcs, key), + Sequence::Graphics(ref mut graphics) => parse!(graphics, key), } } diff --git a/src/input/tty.rs b/src/input/tty.rs index 5cf491b..e4203f9 100644 --- a/src/input/tty.rs +++ b/src/input/tty.rs @@ -87,12 +87,16 @@ impl TTY { write!(out, "\x1b[?{}{}", sequence, if enable { "h" } else { "l" })?; } - // Set the current foreground color to black + // Set the current background color to black write!(out, "\x1b[48;2;0;0;0m")?; // Query current foreground color to for true-color support detection write!(out, "\x1bP$qm\x1b\\")?; // Query current terminal name write!(out, "\x1bP+q544e\x1b\\")?; + // Query graphics capability (XTSMGRAPHICS). Some terminals expect DCS form; + // use it first and fall back to CSI if ignored. + write!(out, "\x1bP?2;1;0S\x1b\\")?; + write!(out, "\x1b[?2;1;0S")?; out.flush() } diff --git a/src/output.rs b/src/output.rs index 9d16b5f..bba4e4a 100644 --- a/src/output.rs +++ b/src/output.rs @@ -6,6 +6,7 @@ mod painter; mod quad; mod render_thread; mod renderer; +mod sixel; mod window; mod xterm; diff --git a/src/output/cell.rs b/src/output/cell.rs index 6c2193d..0e66c4e 100644 --- a/src/output/cell.rs +++ b/src/output/cell.rs @@ -18,6 +18,7 @@ pub struct Cell { /// Text grapheme if any pub grapheme: Option>, pub quadrant: (Color, Color, Color, Color), + pub image: bool, } impl Cell { @@ -31,6 +32,7 @@ impl Cell { Color::black(), Color::black(), ), + image: false, } } } diff --git a/src/output/painter.rs b/src/output/painter.rs index a28862d..e48642c 100644 --- a/src/output/painter.rs +++ b/src/output/painter.rs @@ -1,8 +1,17 @@ -use std::io::{self, Stdout, Write}; +use std::{ + env, + io::{self, Stdout, Write}, +}; -use crate::gfx::{Color, Point}; +use crate::gfx::{Color, Point, Size}; +use crate::utils::log; +use sixel_bytes::DiffusionMethod; -use super::{binarize_quandrant, Cell}; +use super::{ + binarize_quandrant, + sixel::{Error as SixelError, Frame}, + Cell, +}; pub struct Painter { output: Stdout, @@ -13,6 +22,16 @@ pub struct Painter { foreground: Option, background_code: Option, foreground_code: Option, + sixel: Option, + sixel_only: bool, +} + +struct SixelState { + configured: bool, + geometry: Size, + pending: Option, + scrolling: bool, + dither: DiffusionMethod, } impl Painter { @@ -25,10 +44,12 @@ impl Painter { foreground: None, background_code: None, foreground_code: None, + sixel: None, true_color: match std::env::var("COLORTERM").unwrap_or_default().as_str() { "truecolor" | "24bit" => true, _ => false, }, + sixel_only: false, } } @@ -40,8 +61,144 @@ impl Painter { self.true_color = true_color } + pub fn set_sixel_only(&mut self, sixel_only: bool) { + self.sixel_only = sixel_only; + } + + pub fn enable_sixel(&mut self, geometry: Size) { + let state = self.sixel.get_or_insert_with(|| { + let scrolling = env::var("CARBONYL_SIXEL_SCROLL") + .ok() + .and_then(|value| { + let normalized = value.trim().to_ascii_lowercase(); + + match normalized.as_str() { + "1" | "true" | "on" | "yes" => Some(true), + "0" | "false" | "off" | "no" => Some(false), + _ => None, + } + }) + .unwrap_or(false); + + let dither = match env::var("CARBONYL_SIXEL_DITHER") + .unwrap_or_else(|_| "none".into()) + .to_ascii_lowercase() + .as_str() + { + "auto" => DiffusionMethod::Auto, + "fs" | "floyd-steinberg" => DiffusionMethod::FS, + "atkinson" => DiffusionMethod::Atkinson, + "stucki" => DiffusionMethod::Stucki, + "burkes" => DiffusionMethod::Burkes, + "jajuni" | "jarvis" => DiffusionMethod::JaJuNi, + _ => DiffusionMethod::None, + }; + + SixelState { + configured: false, + geometry, + pending: None, + scrolling, + dither, + } + }); + + state.geometry = geometry; + } + + pub fn update_sixel_geometry(&mut self, geometry: Size) { + if let Some(state) = self.sixel.as_mut() { + state.geometry = geometry; + } + } + + pub fn queue_sixel_background(&mut self, pixels: &[u8], size: Size) -> bool { + let Some(state) = self.sixel.as_mut() else { + return false; + }; + + let expected = size.width as usize * size.height as usize * 4; + + if pixels.len() < expected { + log::error!( + "failed to encode sixel frame: unexpected buffer size (expected {expected}, actual {})", + pixels.len() + ); + state.pending = None; + + return false; + } + + let expected = (state.geometry.width > 0 && state.geometry.height > 0) + .then_some(state.geometry); + + if let Some(target) = expected { + if size != target { + log::error!( + "failed to encode sixel frame: geometry mismatch (expected {:?}, actual {:?})", + target, + size + ); + state.pending = None; + + return false; + } + } + + log::debug!( + "sixel encode: size={}x{} dither={:?}", + size.width, + size.height, + state.dither + ); + + match Frame::from_viewport(pixels, size, state.dither) { + Ok(frame) => { + state.pending = Some(frame); + + true + } + Err(SixelError::InvalidSize(invalid)) => { + log::error!("failed to encode sixel frame: viewport {invalid:?} is invalid"); + state.pending = None; + + false + } + Err(SixelError::Encode(error)) => { + log::error!("failed to encode sixel frame: {error}"); + state.pending = None; + + false + } + } + } + + fn sixel_enabled(&self) -> bool { + self.sixel.is_some() + } + pub fn begin(&mut self) -> io::Result<()> { - write!(self.buffer, "\x1b[?25l\x1b[?12l") + write!(self.buffer, "\x1b[?25l\x1b[?12l")?; + + if let Some(state) = self.sixel.as_mut() { + if !state.configured { + if state.scrolling { + write!(self.buffer, "\x1b[?80h")?; + } else { + write!(self.buffer, "\x1b[?80l")?; + } + state.configured = true; + } + + if let Some(frame) = state.pending.take() { + // Reposition cursor without clearing the entire screen. + write!(self.buffer, "\x1b[H")?; + self.buffer.extend_from_slice(&frame.bytes); + write!(self.buffer, "\x1b[H")?; + } + } + + Ok(()) } pub fn end(&mut self, cursor: Option) -> io::Result<()> { @@ -52,6 +209,8 @@ impl Painter { cursor.y + 1, cursor.x + 1 )?; + } else { + write!(self.buffer, "\x1b[?25h\x1b[?12h")?; } self.output.write(self.buffer.as_slice())?; @@ -67,8 +226,17 @@ impl Painter { cursor, quadrant, ref grapheme, + image, } = cell; + if self.sixel_only && self.sixel_enabled() { + return Ok(()); + } + + if self.sixel_enabled() && grapheme.is_none() && image { + return Ok(()); + } + let (char, background, foreground, width) = if let Some(grapheme) = grapheme { if grapheme.index > 0 { return Ok(()); diff --git a/src/output/render_thread.rs b/src/output/render_thread.rs index 43f648c..408d97d 100644 --- a/src/output/render_thread.rs +++ b/src/output/render_thread.rs @@ -59,7 +59,7 @@ impl RenderThread { fn boot(rx: Receiver) { let cmd = CommandLine::parse(); let mut sync = FrameSync::new(cmd.fps); - let mut renderer = Renderer::new(); + let mut renderer = Renderer::new(cmd.sixel_only); let mut needs_render = false; loop { diff --git a/src/output/renderer.rs b/src/output/renderer.rs index f515cad..d5bbb1a 100644 --- a/src/output/renderer.rs +++ b/src/output/renderer.rs @@ -23,11 +23,14 @@ pub struct Renderer { } impl Renderer { - pub fn new() -> Renderer { + pub fn new(sixel_only: bool) -> Renderer { + let mut painter = Painter::new(); + painter.set_sixel_only(sixel_only); + Renderer { nav: Navigation::new(), cells: Vec::with_capacity(0), - painter: Painter::new(), + painter, size: Size::new(0, 0), } } @@ -36,6 +39,14 @@ impl Renderer { self.painter.set_true_color(true) } + pub fn enable_sixel(&mut self, geometry: Size) { + self.painter.enable_sixel(geometry); + } + + pub fn update_sixel_geometry(&mut self, geometry: Size) { + self.painter.update_sixel_geometry(geometry); + } + pub fn keypress(&mut self, key: &Key) -> io::Result { let action = self.nav.keypress(key); @@ -114,6 +125,7 @@ impl Renderer { previous.quadrant = current.quadrant; previous.grapheme = current.grapheme.clone(); + previous.image = current.image; self.painter.paint(current)?; } @@ -125,6 +137,8 @@ impl Renderer { /// Draw the background from a pixel array encoded in RGBA8888 pub fn draw_background(&mut self, pixels: &[u8], pixels_size: Size, rect: Rect) { + let uses_sixel = self.painter.queue_sixel_background(pixels, pixels_size); + let viewport = self.size.cast::(); if pixels.len() < viewport.width * viewport.height * 8 * 4 { @@ -163,12 +177,23 @@ impl Renderer { let (mut x, y) = (left * 2, y * 4); for (_, cell) in &mut self.cells[start..end] { - cell.quadrant = ( - pair(x + 0, y + 0), - pair(x + 1, y + 0), - pair(x + 1, y + 2), - pair(x + 0, y + 2), - ); + if uses_sixel { + cell.quadrant = ( + Color::black(), + Color::black(), + Color::black(), + Color::black(), + ); + cell.grapheme = None; + } else { + cell.quadrant = ( + pair(x + 0, y + 0), + pair(x + 1, y + 0), + pair(x + 1, y + 2), + pair(x + 0, y + 2), + ); + } + cell.image = true; x += 2; } @@ -195,6 +220,7 @@ impl Renderer { self.draw(rect, |cell| { cell.grapheme = None; cell.quadrant = (color, color, color, color); + cell.image = false; }) } @@ -214,6 +240,7 @@ impl Renderer { let right = left + size.width; for (_, current) in self.cells[left..right].iter_mut() { + current.image = false; draw(current) } } @@ -273,6 +300,7 @@ impl Renderer { previous.color != next.color || previous.char != next.char } } { + cell.image = false; cell.grapheme = Some(Rc::new(next)) } } diff --git a/src/output/sixel.rs b/src/output/sixel.rs new file mode 100644 index 0000000..8fbf3f5 --- /dev/null +++ b/src/output/sixel.rs @@ -0,0 +1,51 @@ +use crate::gfx::Size; +use sixel_bytes::{self, DiffusionMethod, PixelFormat}; + +#[derive(Clone, Debug)] +pub struct Frame { + pub bytes: Vec, +} + +#[derive(Debug)] +pub enum Error { + Encode(sixel_bytes::SixelError), + InvalidSize(Size), +} + +impl From for Error { + fn from(value: sixel_bytes::SixelError) -> Self { + Self::Encode(value) + } +} + +impl Frame { + fn encode_rgba(pixels: &[u8], size: Size, method: DiffusionMethod) -> Result { + if size.width == 0 || size.height == 0 { + return Err(Error::InvalidSize(size)); + } + + let bytes = sixel_bytes::sixel_string( + pixels, + size.width as i32, + size.height as i32, + PixelFormat::BGRA8888, + method, + ) + .map_err(Error::from)? + .into_bytes(); + + Ok(Self { bytes }) + } + + pub fn from_viewport( + pixels: &[u8], + size: Size, + method: DiffusionMethod, + ) -> Result { + if size.width == 0 || size.height == 0 { + return Err(Error::InvalidSize(size)); + } + + Self::encode_rgba(pixels, size, method) + } +} diff --git a/src/output/window.rs b/src/output/window.rs index a53c507..d7e7697 100644 --- a/src/output/window.rs +++ b/src/output/window.rs @@ -1,5 +1,11 @@ use core::mem::MaybeUninit; -use std::str::FromStr; +use std::{ + fs::OpenOptions, + io::{Read, Write}, + os::fd::AsRawFd, + str::FromStr, + time::{Duration, Instant}, +}; use crate::{cli::CommandLine, gfx::Size, utils::log}; @@ -14,6 +20,10 @@ pub struct Window { pub cells: Size, /// Size of the browser window in pixels pub browser: Size, + /// Full terminal pixel geometry for graphics output + pub graphics_px: Size, + /// Device scale factor used by Chromium (integer preferred) + pub dsf: f32, /// Command line arguments pub cmd: CommandLine, } @@ -26,6 +36,8 @@ impl Window { scale: (0.0, 0.0).into(), cells: (0, 0).into(), browser: (0, 0).into(), + graphics_px: (0, 0).into(), + dsf: 1.0, cmd: CommandLine::parse(), }; @@ -35,7 +47,7 @@ impl Window { } pub fn update(&mut self) -> &Self { - let (mut term, mut cell) = unsafe { + let (mut term, cell) = unsafe { let mut ptr = MaybeUninit::::uninit(); if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ptr.as_mut_ptr()) == 0 { @@ -50,11 +62,6 @@ impl Window { } }; - if cell.width == 0 || cell.height == 0 { - cell.width = 8; - cell.height = 16; - } - if term.width == 0 || term.height == 0 { let cols = match parse_var("COLUMNS").unwrap_or(0) { 0 => 80, @@ -77,24 +84,54 @@ impl Window { term.height = rows; } - let zoom = 1.5 * self.cmd.zoom; - let cells = Size::new(term.width.max(1), term.height.max(2) - 1); - let auto_scale = false; - let cell_pixels = if auto_scale { - Size::new(cell.width as f32, cell.height as f32) / cells.cast() - } else { - Size::new(8.0, 16.0) - }; - // Normalize the cells dimensions for an aspect ratio of 1:2 - let cell_width = (cell_pixels.width + cell_pixels.height / 2.0) / 2.0; + let mut cell_pixels = + if term.width > 0 && term.height > 0 && cell.width > 0 && cell.height > 0 { + Size::new( + cell.width as f32 / term.width.max(1) as f32, + cell.height as f32 / term.height.max(1) as f32, + ) + } else { + Size::new(0.0, 0.0) + }; + + if cell_pixels.width <= 0.0 || cell_pixels.height <= 0.0 { + if let Some(win_px) = query_window_pixels() { + cell_pixels = Size::new( + win_px.width / term.width.max(1) as f32, + win_px.height / term.height.max(1) as f32, + ); + } - // Round DPI to 2 decimals for proper viewport computations - self.dpi = (2.0 / cell_width * zoom * 100.0).ceil() / 100.0; - // A virtual cell should contain a 2x4 pixel quadrant - self.scale = Size::new(2.0, 4.0) / self.dpi; + if cell_pixels.width <= 0.0 || cell_pixels.height <= 0.0 { + cell_pixels = query_cell_geometry().unwrap_or(Size::new(8.0, 16.0)); + } + } + // Normalize the cells dimensions for an aspect ratio of 1:2 + self.scale = cell_pixels; // Keep some space for the UI self.cells = Size::new(term.width.max(1), term.height.max(2) - 1).cast(); - self.browser = self.cells.cast::().mul(self.scale).ceil().cast(); + self.graphics_px = Size::new( + (self.cells.width as f32 * cell_pixels.width).round() as u32, + (self.cells.height as f32 * cell_pixels.height).round() as u32, + ); + + // Choose an integer device scale factor (DSF) to avoid fractional raster scaling. + let mut dsf = if cell_pixels.height >= 18.0 { 2.0 } else { 1.0 }; + if let Ok(value) = std::env::var("CARBONYL_DSF") { + match value.trim() { + "1" => dsf = 1.0, + "2" => dsf = 2.0, + "3" => dsf = 3.0, + _ => {} + } + } + self.dsf = dsf; + self.dpi = self.dsf; + + self.browser = Size::new( + (self.graphics_px.width as f32 / self.dsf).round() as u32, + (self.graphics_px.height as f32 / self.dsf).round() as u32, + ); self } @@ -103,3 +140,131 @@ impl Window { fn parse_var(var: &str) -> Option { std::env::var(var).ok()?.parse().ok() } + +fn query_cell_geometry() -> Option> { + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + let fd = tty.as_raw_fd(); + let mut term = MaybeUninit::::uninit(); + + unsafe { + if libc::tcgetattr(fd, term.as_mut_ptr()) != 0 { + return None; + } + } + + let original = unsafe { term.assume_init() }; + let mut raw = original; + let c_oflag = raw.c_oflag; + + unsafe { + libc::cfmakeraw(&mut raw); + } + + raw.c_oflag = c_oflag; + + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return None; + } + + struct Restore(libc::c_int, libc::termios); + + impl Drop for Restore { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.0, libc::TCSANOW, &self.1); + } + } + } + + let _restore = Restore(fd, original); + + if tty.write_all(b"\x1b[16t").is_err() || tty.flush().is_err() { + return None; + } + + let mut buffer = [0u8; 128]; + let mut length = 0usize; + let deadline = Instant::now() + Duration::from_millis(100); + + while length < buffer.len() && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let timeout = remaining.as_millis().min(i32::MAX as u128) as libc::c_int; + let mut fds = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + + let result = unsafe { libc::poll(&mut fds, 1, timeout) }; + + if result <= 0 { + break; + } + + match tty.read(&mut buffer[length..]) { + Ok(0) => break, + Ok(read) => { + length += read; + + if buffer[..length].contains(&b't') { + break; + } + } + Err(_) => break, + } + } + + if length == 0 { + return None; + } + + let response = std::str::from_utf8(&buffer[..length]).ok()?; + let start = response.rfind("\u{1b}[6;")?; + let rest = &response[start + 3..]; + let end = rest.find('t')?; + let mut parts = rest[..end].split(';'); + let height = parts.next()?.parse::().ok()?; + let width = parts.next()?.parse::().ok()?; + + if width <= 0.0 || height <= 0.0 { + return None; + } + + Some(Size::new(width, height)) +} + +fn query_window_pixels() -> Option> { + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + + tty.write_all(b"\x1b[14t").ok()?; + tty.flush().ok()?; + + let mut buf = [0u8; 128]; + let n = tty.read(&mut buf).ok()?; + + if n == 0 { + return None; + } + + let response = std::str::from_utf8(&buf[..n]).ok()?; + let start = response.rfind("\u{1b}[4;")?; + let rest = &response[start + 3..]; + let end = rest.find('t')?; + let mut parts = rest[..end].split(';'); + let height = parts.next()?.parse::().ok()?; + let width = parts.next()?.parse::().ok()?; + + if width <= 0.0 || height <= 0.0 { + return None; + } + + Some(Size::new(width, height)) +}