6 Commits
42 changed files with 8165 additions and 3166 deletions
-7
View File
@@ -9,10 +9,3 @@ target/
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
Generated
+1129 -1433
View File
File diff suppressed because it is too large Load Diff
+9 -47
View File
@@ -1,55 +1,17 @@
[package]
name = "rs-pictures"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rs-pictures"
path = "src/main.rs"
[dependencies]
# Wayland-native screen capture
libwayshot = "0.7"
# GUI framework (Wayland only)
eframe = { version = "0.29", default-features = false, features = ["wayland", "wgpu"] }
egui = "0.29"
# Image handling
image = { version = "0.25", features = ["png", "jpeg"] }
# 2D rendering for effects (rounded corners, drop shadow)
tiny-skia = "0.11"
# Clipboard (Wayland)
arboard = { version = "3.6", features = ["wayland-data-control"] }
# Config serialization
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# Platform config/data directories
directories = "5"
# Error handling
anyhow = "1"
# Timestamp-based filenames
chrono = { version = "0.4", features = ["clock"] }
# ── Build profiles ────────────────────────────────────────────────────────────
[workspace]
members = ["winiterm"]
resolver = "2"
[profile.release]
opt-level = 3
lto = "thin" # link-time optimisation across crates
codegen-units = 1 # better inlining at the cost of compile time
strip = true # strip debug symbols → smaller binary
lto = "thin"
codegen-units = 1
strip = true
# Dev builds are slow for pixel-processing code. This gives opt-level 2
# to our own crate only while keeping dependencies at their default (opt=3
# they already compiled with), so incremental rebuilds stay fast.
[profile.dev]
opt-level = 0
# Compile dependencies in release mode even in dev builds (much faster rendering)
[profile.dev.package."*"]
opt-level = 3 # all deps at full optimisation even in dev mode
opt-level = 3
-53
View File
@@ -1,53 +0,0 @@
use anyhow::{Context, Result};
use image::RgbaImage;
use libwayshot::WayshotConnection;
/// A rectangular region on screen in physical pixels.
#[derive(Debug, Clone, Copy)]
pub struct Region {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
/// Captures the specified region in physical pixels.
pub fn capture_region(region: Region) -> Result<RgbaImage> {
let full = capture_all_outputs()?;
let px = (region.x.max(0) as u32).min(full.width().saturating_sub(1));
let py = (region.y.max(0) as u32).min(full.height().saturating_sub(1));
let pw = region.width.min(full.width() - px);
let ph = region.height.min(full.height() - py);
eprintln!(
"[capture] crop ({px},{py}) {pw}x{ph} from {}x{}",
full.width(), full.height(),
);
Ok(image::imageops::crop_imm(&full, px, py, pw, ph).to_image())
}
/// Captures all connected outputs stitched together into one image.
pub fn capture_all_outputs() -> Result<RgbaImage> {
let conn = WayshotConnection::new()
.context("Failed to connect to Wayland display")?;
let all = conn.get_all_outputs();
eprintln!("[capture] outputs: {:?}", all.iter().map(|o| &o.name).collect::<Vec<_>>());
let active_name = crate::hyprland::active_monitor_name();
let target_output = if let Some(ref name) = active_name {
all.iter().find(|o| &o.name == name).unwrap_or(&all[0])
} else {
&all[0]
};
let rgba = conn
.screenshot_single_output(target_output, false)
.context("libwayshot failed to capture output")?
.into_rgba8();
eprintln!("[capture] capture_all_outputs → {}x{}", rgba.width(), rgba.height());
Ok(rgba)
}
-163
View File
@@ -1,163 +0,0 @@
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Top-level application configuration.
/// Serialized to/from ~/.config/rs-pictures/config.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Directory where screenshots are saved.
pub save_directory: PathBuf,
/// File format for saved screenshots: "png" or "jpeg".
pub save_format: String,
/// Filename template. Supports strftime-style tokens via chrono.
/// Example: "screenshot_%Y-%m-%d_%H-%M-%S"
pub filename_template: String,
/// When true, automatically save to disk after capture without opening
/// the review window.
#[serde(default)]
pub auto_save: bool,
/// When true, automatically copy to the clipboard after capture without
/// opening the review window.
#[serde(default)]
pub auto_copy: bool,
/// Milliseconds to wait after launch before capturing the desktop snapshot.
/// Increase this if the overlay background still shows the terminal/launcher
/// that started rs-pictures. Default: 200.
#[serde(default = "default_capture_delay_ms")]
pub capture_delay_ms: u64,
/// If true, the selection overlay will be transparent (live preview) instead of
/// a frozen screenshot. The final capture happens after selection.
#[serde(default)]
pub live_mode: bool,
/// Visual effects applied after capture.
pub effects: EffectsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EffectsConfig {
/// Apply rounded corners to the screenshot.
pub rounded_corners: bool,
/// Radius in pixels for rounded corners.
pub corner_radius: f32,
/// Apply a drop shadow beneath the screenshot.
pub drop_shadow: bool,
/// Blur radius for the drop shadow (higher = softer shadow).
pub shadow_blur_radius: f32,
/// Horizontal offset of the shadow in pixels.
pub shadow_offset_x: f32,
/// Vertical offset of the shadow in pixels.
pub shadow_offset_y: f32,
/// Shadow color as [R, G, B, A] in 0..=255.
pub shadow_color: [u8; 4],
}
impl Default for EffectsConfig {
fn default() -> Self {
Self {
rounded_corners: false,
corner_radius: 12.0,
drop_shadow: false,
shadow_blur_radius: 20.0,
shadow_offset_x: 5.0,
shadow_offset_y: 8.0,
shadow_color: [0, 0, 0, 160],
}
}
}
impl Default for Config {
fn default() -> Self {
let save_directory = dirs_default_pictures().unwrap_or_else(|| PathBuf::from("."));
Self {
save_directory,
save_format: "png".into(),
filename_template: "screenshot_%Y-%m-%d_%H-%M-%S".into(),
auto_save: false,
auto_copy: false,
capture_delay_ms: default_capture_delay_ms(),
live_mode: false,
effects: EffectsConfig::default(),
}
}
}
impl Config {
/// Returns the path to the config file, creating parent directories if needed.
pub fn config_path() -> Option<PathBuf> {
ProjectDirs::from("", "", "rs-pictures")
.map(|pd| pd.config_dir().join("config.toml"))
}
/// Load config from disk, or return the default config if the file doesn't exist.
pub fn load() -> Result<Self> {
let path = match Self::config_path() {
Some(p) => p,
None => return Ok(Self::default()),
};
if !path.exists() {
let config = Self::default();
config.save()?;
return Ok(config);
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config at {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("Failed to parse config at {}", path.display()))
}
/// Persist the current config to disk.
pub fn save(&self) -> Result<()> {
let path = Self::config_path()
.context("Could not determine config directory")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config dir {}", parent.display()))?;
}
let serialized = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
std::fs::write(&path, serialized)
.with_context(|| format!("Failed to write config to {}", path.display()))?;
Ok(())
}
/// Build the full output path for a new screenshot using chrono formatting.
pub fn output_path(&self) -> PathBuf {
let now = chrono::Local::now();
let filename = now.format(&self.filename_template).to_string();
let ext = &self.save_format;
self.save_directory.join(format!("{filename}.{ext}"))
}
}
fn default_capture_delay_ms() -> u64 { 200 }
fn dirs_default_pictures() -> Option<PathBuf> {
// Use XDG_PICTURES_DIR if available, otherwise ~/Pictures
if let Ok(val) = std::env::var("XDG_PICTURES_DIR") {
return Some(PathBuf::from(val));
}
directories::UserDirs::new()
.and_then(|ud| ud.picture_dir().map(|p| p.to_path_buf()))
}
-293
View File
@@ -1,293 +0,0 @@
//! Post-capture image effects: rounded corners and drop shadow.
//!
//! Pipeline:
//! RgbaImage (captured)
//! → apply_rounded_corners() clips corners to transparent via tiny-skia mask
//! → apply_drop_shadow() composites a blurred shadow beneath the image
//! → final RgbaImage (may be larger when shadow is added)
//!
//! Performance notes:
//! - Box blur uses a sliding-window algorithm: O(W*H) regardless of radius.
//! Three passes of the box filter approximate a Gaussian.
//! - Pixel format conversions between RgbaImage and tiny-skia Pixmap are done
//! with a single pass each way.
//! - This module is called from a background thread in review.rs so the UI
//! never blocks.
use crate::config::EffectsConfig;
use image::RgbaImage;
use tiny_skia::{
BlendMode, Color, FillRule, Paint, Path, PathBuilder, Pixmap, PixmapPaint, Transform,
};
/// Apply all configured effects in order. Returns a new image.
pub fn apply_effects(img: RgbaImage, cfg: &EffectsConfig) -> RgbaImage {
let img = if cfg.rounded_corners {
apply_rounded_corners(img, cfg.corner_radius)
} else {
img
};
if cfg.drop_shadow {
apply_drop_shadow(
img,
cfg.shadow_blur_radius,
cfg.shadow_offset_x,
cfg.shadow_offset_y,
cfg.shadow_color,
)
} else {
img
}
}
// ─── Rounded corners ─────────────────────────────────────────────────────────
pub fn apply_rounded_corners(img: RgbaImage, radius: f32) -> RgbaImage {
let (w, h) = img.dimensions();
let mut mask = Pixmap::new(w, h).expect("mask pixmap");
let path = rounded_rect_path(0.0, 0.0, w as f32, h as f32, radius);
let mut paint = Paint::default();
paint.set_color(Color::WHITE);
paint.anti_alias = true;
mask.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
let mut pixmap = rgba_image_to_pixmap(&img);
let mut dst_paint = PixmapPaint::default();
dst_paint.blend_mode = BlendMode::DestinationIn;
pixmap.draw_pixmap(0, 0, mask.as_ref(), &dst_paint, Transform::identity(), None);
pixmap_to_rgba_image(pixmap)
}
// ─── Drop shadow ─────────────────────────────────────────────────────────────
pub fn apply_drop_shadow(
img: RgbaImage,
blur_radius: f32,
offset_x: f32,
offset_y: f32,
shadow_color: [u8; 4],
) -> RgbaImage {
let (iw, ih) = img.dimensions();
let br = blur_radius.ceil() as u32;
let extra_left = br.saturating_sub((-offset_x).max(0.0) as u32);
let extra_top = br.saturating_sub((-offset_y).max(0.0) as u32);
let extra_right = br + offset_x.max(0.0) as u32;
let extra_bottom = br + offset_y.max(0.0) as u32;
let canvas_w = iw + extra_left + extra_right;
let canvas_h = ih + extra_top + extra_bottom;
// 1. Place the image silhouette at the shadow position.
let mut shadow_pixmap = Pixmap::new(canvas_w, canvas_h).expect("shadow pixmap");
let img_pixmap = rgba_image_to_pixmap(&img);
let shadow_x = (extra_left as f32 + offset_x) as i32;
let shadow_y = (extra_top as f32 + offset_y) as i32;
let mut sp = PixmapPaint::default();
sp.blend_mode = BlendMode::Source;
shadow_pixmap.draw_pixmap(shadow_x, shadow_y, img_pixmap.as_ref(), &sp, Transform::identity(), None);
// 2. Tint the silhouette with the shadow colour.
tint_pixmap_as_shadow(&mut shadow_pixmap, shadow_color);
// 3. Blur the shadow (sliding-window box blur, 3 passes).
let shadow_img = pixmap_to_rgba_image(shadow_pixmap);
let blurred = box_blur_rgba(&shadow_img, br);
let blurred_pixmap = rgba_image_to_pixmap(&blurred);
// 4. Composite: shadow first, image on top.
let mut canvas = Pixmap::new(canvas_w, canvas_h).expect("canvas pixmap");
let mut p = PixmapPaint::default();
p.blend_mode = BlendMode::Source;
canvas.draw_pixmap(0, 0, blurred_pixmap.as_ref(), &p, Transform::identity(), None);
let mut p2 = PixmapPaint::default();
p2.blend_mode = BlendMode::SourceOver;
canvas.draw_pixmap(extra_left as i32, extra_top as i32, img_pixmap.as_ref(), &p2, Transform::identity(), None);
pixmap_to_rgba_image(canvas)
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Path {
let r = r.min(w / 2.0).min(h / 2.0);
let mut pb = PathBuilder::new();
pb.move_to(x + r, y);
pb.line_to(x + w - r, y);
pb.quad_to(x + w, y, x + w, y + r);
pb.line_to(x + w, y + h - r);
pb.quad_to(x + w, y + h, x + w - r, y + h);
pb.line_to(x + r, y + h);
pb.quad_to(x, y + h, x, y + h - r);
pb.line_to(x, y + r);
pb.quad_to(x, y, x + r, y);
pb.close();
pb.finish().expect("rounded rect path")
}
fn rgba_image_to_pixmap(img: &RgbaImage) -> Pixmap {
let (w, h) = img.dimensions();
let mut pixmap = Pixmap::new(w, h).expect("pixmap alloc");
let pixels = pixmap.pixels_mut();
for (i, px) in img.pixels().enumerate() {
let [r, g, b, a] = px.0;
let af = a as f32 / 255.0;
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(
(r as f32 * af) as u8,
(g as f32 * af) as u8,
(b as f32 * af) as u8,
a,
)
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
}
pixmap
}
fn pixmap_to_rgba_image(pixmap: Pixmap) -> RgbaImage {
let (w, h) = (pixmap.width(), pixmap.height());
let mut out = RgbaImage::new(w, h);
for (i, px) in pixmap.pixels().iter().enumerate() {
let x = (i as u32) % w;
let y = (i as u32) / w;
let a = px.alpha();
let (r, g, b) = if a == 0 {
(0, 0, 0)
} else {
let af = a as f32 / 255.0;
(
(px.red() as f32 / af).round().min(255.0) as u8,
(px.green() as f32 / af).round().min(255.0) as u8,
(px.blue() as f32 / af).round().min(255.0) as u8,
)
};
out.put_pixel(x, y, image::Rgba([r, g, b, a]));
}
out
}
fn tint_pixmap_as_shadow(pixmap: &mut Pixmap, color: [u8; 4]) {
let [sr, sg, sb, _] = color;
for px in pixmap.pixels_mut() {
let a = px.alpha();
if a > 0 {
let af = a as f32 / 255.0;
*px = tiny_skia::PremultipliedColorU8::from_rgba(
(sr as f32 * af) as u8,
(sg as f32 * af) as u8,
(sb as f32 * af) as u8,
a,
)
.unwrap_or(tiny_skia::PremultipliedColorU8::TRANSPARENT);
}
}
}
// ─── Sliding-window box blur (O(W*H) regardless of radius) ───────────────────
//
// Classic algorithm: maintain a running sum over a window of (2r+1) pixels.
// When the window slides by one pixel, subtract the pixel leaving the window
// and add the pixel entering it. Three passes (H→V→H or H→V→H) approximate
// a Gaussian kernel.
fn box_blur_rgba(img: &RgbaImage, radius: u32) -> RgbaImage {
if radius == 0 {
return img.clone();
}
// Three passes of H+V to approximate a Gaussian.
let mut buf = sliding_horizontal(img, radius);
buf = sliding_vertical(&buf, radius);
buf = sliding_horizontal(&buf, radius);
buf = sliding_vertical(&buf, radius);
buf
}
/// Horizontal sliding-window box blur, single pass.
fn sliding_horizontal(img: &RgbaImage, radius: u32) -> RgbaImage {
let (w, h) = img.dimensions();
let r = radius as i32;
let diam = (2 * r + 1) as u32;
let mut out = RgbaImage::new(w, h);
for y in 0..h {
// Accumulator for the current window.
let mut sr = 0u32;
let mut sg = 0u32;
let mut sb = 0u32;
let mut sa = 0u32;
// Seed the window around x=0.
for dx in -r..=r {
let sx = dx.clamp(0, w as i32 - 1) as u32;
let p = img.get_pixel(sx, y).0;
sr += p[0] as u32;
sg += p[1] as u32;
sb += p[2] as u32;
sa += p[3] as u32;
}
for x in 0..w {
out.put_pixel(x, y, image::Rgba([
(sr / diam) as u8,
(sg / diam) as u8,
(sb / diam) as u8,
(sa / diam) as u8,
]));
// Slide: remove left edge, add right edge.
let remove_x = (x as i32 - r).clamp(0, w as i32 - 1) as u32;
let add_x = (x as i32 + r + 1).clamp(0, w as i32 - 1) as u32;
let rp = img.get_pixel(remove_x, y).0;
let ap = img.get_pixel(add_x, y).0;
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
}
}
out
}
/// Vertical sliding-window box blur, single pass.
fn sliding_vertical(img: &RgbaImage, radius: u32) -> RgbaImage {
let (w, h) = img.dimensions();
let r = radius as i32;
let diam = (2 * r + 1) as u32;
let mut out = RgbaImage::new(w, h);
for x in 0..w {
let mut sr = 0u32;
let mut sg = 0u32;
let mut sb = 0u32;
let mut sa = 0u32;
for dy in -r..=r {
let sy = dy.clamp(0, h as i32 - 1) as u32;
let p = img.get_pixel(x, sy).0;
sr += p[0] as u32;
sg += p[1] as u32;
sb += p[2] as u32;
sa += p[3] as u32;
}
for y in 0..h {
out.put_pixel(x, y, image::Rgba([
(sr / diam) as u8,
(sg / diam) as u8,
(sb / diam) as u8,
(sa / diam) as u8,
]));
let remove_y = (y as i32 - r).clamp(0, h as i32 - 1) as u32;
let add_y = (y as i32 + r + 1).clamp(0, h as i32 - 1) as u32;
let rp = img.get_pixel(x, remove_y).0;
let ap = img.get_pixel(x, add_y ).0;
sr = sr.saturating_sub(rp[0] as u32) + ap[0] as u32;
sg = sg.saturating_sub(rp[1] as u32) + ap[1] as u32;
sb = sb.saturating_sub(rp[2] as u32) + ap[2] as u32;
sa = sa.saturating_sub(rp[3] as u32) + ap[3] as u32;
}
}
out
}
-270
View File
@@ -1,270 +0,0 @@
//! Hyprland window geometry queries.
//!
//! Uses `hyprctl clients -j` and `hyprctl activeworkspace -j` to enumerate
//! windows on the active workspace. Returns logical pixel coordinates that
//! match the coordinate space used by libwayshot LogicalRegion.
//!
//! If `hyprctl` is not available (non-Hyprland compositor) the functions
//! return an empty list so the overlay degrades gracefully to manual
//! selection only.
/// A window's position and size in Wayland logical pixels.
#[derive(Debug, Clone)]
pub struct WindowRect {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub title: String,
}
/// Returns the logical size (width, height) of the primary/active monitor.
/// Falls back to None if hyprctl is unavailable.
pub fn active_monitor_logical_size() -> Option<(u32, u32)> {
let info = active_monitor_info()?;
Some((info.0, info.1))
}
/// Returns the scale factor of the active monitor (e.g. 1.33 on HiDPI).
/// Falls back to 1.0 if hyprctl is unavailable.
pub fn active_monitor_scale() -> f32 {
active_monitor_info().map(|i| i.2).unwrap_or(1.0)
}
/// Returns the name of the active/focused monitor (e.g. "DP-1").
pub fn active_monitor_name() -> Option<String> {
let output = std::process::Command::new("hyprctl")
.args(["monitors", "-j"])
.output()
.ok()?;
if !output.status.success() { return None; }
let text = std::str::from_utf8(&output.stdout).ok()?;
for obj in split_objects(text) {
if json_bool(obj, "focused") == Some(true) {
return json_string(obj, "name");
}
}
None
}
/// Returns (logical_width, logical_height, scale) for the focused monitor.
fn active_monitor_info() -> Option<(u32, u32, f32)> {
let output = std::process::Command::new("hyprctl")
.args(["monitors", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = std::str::from_utf8(&output.stdout).ok()?;
// Find the focused monitor (focused: true) or fall back to the first.
for obj in split_objects(text) {
let focused = json_bool(obj, "focused");
if focused != Some(true) {
continue;
}
let w = json_i64(obj, "width")? as f32;
let h = json_i64(obj, "height")? as f32;
let scale = json_f32(obj, "scale").unwrap_or(1.0);
return Some(((w / scale).round() as u32, (h / scale).round() as u32, scale));
}
// No focused monitor found — take the first one.
let obj = split_objects(text).into_iter().next()?;
let w = json_i64(obj, "width")? as f32;
let h = json_i64(obj, "height")? as f32;
let scale = json_f32(obj, "scale").unwrap_or(1.0);
Some(((w / scale).round() as u32, (h / scale).round() as u32, scale))
}
/// Returns an empty Vec if hyprctl is unavailable or returns bad data.
pub fn active_workspace_windows() -> Vec<WindowRect> {
let workspace_id = match active_workspace_id() {
Some(id) => id,
None => return vec![],
};
let output = match std::process::Command::new("hyprctl")
.args(["clients", "-j"])
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return vec![],
};
parse_clients(&output, workspace_id)
}
// ─── Private helpers ──────────────────────────────────────────────────────────
fn active_workspace_id() -> Option<i64> {
let output = std::process::Command::new("hyprctl")
.args(["activeworkspace", "-j"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
// Extract "id": <number> with a tiny hand-rolled parse — no serde dep.
let text = std::str::from_utf8(&output.stdout).ok()?;
json_i64(text, "id")
}
/// Parse the `hyprctl clients -j` JSON output without pulling in serde_json.
///
/// We only need four fields per client: `at`, `size`, `workspace.id`, `title`,
/// `mapped`, `hidden`. A minimal hand-rolled extractor is sufficient.
fn parse_clients(data: &[u8], workspace_id: i64) -> Vec<WindowRect> {
let text = match std::str::from_utf8(data) {
Ok(s) => s,
Err(_) => return vec![],
};
let mut result = Vec::new();
// Split on top-level `{` … `}` objects.
// The JSON is a flat array of objects with no nested arrays of objects,
// so a simple brace-depth scan is safe here.
for obj in split_objects(text) {
// Skip unmapped or hidden windows.
if json_bool(obj, "mapped") != Some(true) { continue; }
if json_bool(obj, "hidden") == Some(true) { continue; }
// Only windows on the active workspace.
if json_i64_nested(obj, "workspace", "id") != Some(workspace_id) { continue; }
let at = json_pair_i64(obj, "at");
let size = json_pair_i64(obj, "size");
let (Some((x, y)), Some((w, h))) = (at, size) else { continue };
if w <= 0 || h <= 0 { continue; }
let title = json_string(obj, "title").unwrap_or_default();
result.push(WindowRect { x, y, width: w, height: h, title });
}
result
}
// ─── Tiny JSON field extractors ───────────────────────────────────────────────
/// Split a JSON array text into individual object strings.
fn split_objects(text: &str) -> Vec<&str> {
let mut objects = Vec::new();
let bytes = text.as_bytes();
let mut depth = 0i32;
let mut start = None;
let mut in_string = false;
let mut escape = false;
for (i, &b) in bytes.iter().enumerate() {
if escape { escape = false; continue; }
if b == b'\\' && in_string { escape = true; continue; }
if b == b'"' { in_string = !in_string; continue; }
if in_string { continue; }
match b {
b'{' => {
if depth == 0 { start = Some(i); }
depth += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
if let Some(s) = start {
objects.push(&text[s..=i]);
}
start = None;
}
}
_ => {}
}
}
objects
}
/// Extract `"key": <integer>` from a JSON object string.
fn json_i64(text: &str, key: &str) -> Option<i64> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let end = after.find(|c: char| !c.is_ascii_digit() && c != '-').unwrap_or(after.len());
after[..end].parse().ok()
}
/// Extract `"key": <bool>` from a JSON object string.
fn json_bool(text: &str, key: &str) -> Option<bool> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
if after.starts_with("true") { return Some(true); }
if after.starts_with("false") { return Some(false); }
None
}
/// Extract `"key": "string value"` from a JSON object string.
fn json_string<'a>(text: &'a str, key: &str) -> Option<String> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let after = after.strip_prefix('"')?;
// Collect until unescaped closing quote.
let mut out = String::new();
let mut chars = after.chars();
loop {
match chars.next()? {
'\\' => { chars.next(); } // skip escaped char
'"' => break,
c => out.push(c),
}
}
Some(out)
}
/// Extract `"key": [a, b]` → (a, b) as i64 pair.
fn json_pair_i64(text: &str, key: &str) -> Option<(i32, i32)> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let after = after.strip_prefix('[')?;
let end = after.find(']')?;
let inner = &after[..end];
let mut parts = inner.split(',');
let a: i32 = parts.next()?.trim().parse().ok()?;
let b: i32 = parts.next()?.trim().parse().ok()?;
Some((a, b))
}
/// Extract `"outer": { "inner_key": <integer> }` — one level of nesting.
fn json_i64_nested(text: &str, outer: &str, inner_key: &str) -> Option<i64> {
let needle = format!("\"{}\"", outer);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let brace_start = after.find('{')?;
let brace_end = after.find('}')?;
let nested = &after[brace_start..=brace_end];
json_i64(nested, inner_key)
}
/// Extract `"key": <float>` from a JSON object string.
fn json_f32(text: &str, key: &str) -> Option<f32> {
let needle = format!("\"{}\"", key);
let pos = text.find(&needle)?;
let after = text[pos + needle.len()..].trim_start();
let after = after.strip_prefix(':')?.trim_start();
let end = after
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
.unwrap_or(after.len());
after[..end].parse().ok()
}
-192
View File
@@ -1,192 +0,0 @@
//! rs-pictures — Wayland screenshot tool
//!
//! Flow:
//! 1. Load config from ~/.config/rs-pictures/config.toml
//! 2. Sleep briefly so the user can switch away from the terminal that
//! launched us, and the compositor has time to repaint.
//! 3. Capture all outputs → frozen desktop snapshot for overlay background.
//! 4. Open fullscreen selection overlay (drag / two-click rubber-band).
//! 5. Sleep 120 ms so compositor repaints after overlay closes.
//! 6. Capture the selected region with libwayshot.
//! 7a. auto_save/auto_copy set → apply effects, act silently, exit.
//! 7b. Otherwise → open review window (effects applied interactively there).
mod capture;
mod config;
mod effects;
mod hyprland;
mod overlay;
mod review;
use anyhow::{Context, Result};
use arboard::{Clipboard, ImageData};
use eframe::egui;
use overlay::{SelectionOverlay, SelectionResult};
use review::ReviewWindow;
fn main() -> Result<()> {
// ── 1. Load config & arguments ────────────────────────────────────────────
let config = config::Config::load().context("Failed to load config")?;
let is_live_mode = config.live_mode || std::env::args().any(|a| a == "--live" || a == "-l");
// ── 2. Query Hyprland metadata (no window open yet) ───────────────────────
// Scale is only needed for window-rect conversion in the overlay.
let scale = hyprland::active_monitor_scale();
let (lw, lh) = hyprland::active_monitor_logical_size().unwrap_or((1920, 1080));
// ── 3. Pre-capture delay ──────────────────────────────────────────────────
// Give the compositor time to unmap the terminal/launcher that started us
// and repaint the desktop before we freeze it.
// Configurable via capture_delay_ms in config.toml (default 800ms).
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms));
// ── 4. Capture full desktop BEFORE opening any window ────────────────────
// In freeze mode (default), we snapshot before the overlay.
// In live mode, we skip this and capture later.
let background_snapshot = if is_live_mode {
None
} else {
Some(capture::capture_all_outputs().context("Failed to capture desktop snapshot")?)
};
// ── 5. Run the selection overlay ─────────────────────────────────────────
let selection_result = {
use std::sync::{Arc, Mutex};
let shared: Arc<Mutex<Option<SelectionResult>>> = Arc::new(Mutex::new(None));
let shared_clone = Arc::clone(&shared);
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_app_id("rs-pictures-overlay")
.with_inner_size([lw as f32, lh as f32])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true)
.with_decorations(false)
.with_transparent(true)
.with_always_on_top()
.with_resizable(false),
..Default::default()
};
let bg_clone = background_snapshot.clone();
eframe::run_native(
"rs-pictures — Select Region",
native_options,
Box::new(move |cc| {
let app = SelectionOverlay::new(cc, bg_clone, scale);
Ok(Box::new(OverlayWrapper {
inner: app,
result_sink: shared_clone,
}) as Box<dyn eframe::App>)
}),
)
.map_err(|e| anyhow::anyhow!("Overlay window error: {e}"))?;
Arc::try_unwrap(shared)
.ok()
.and_then(|m| m.into_inner().ok())
.flatten()
};
// ── 6. Act on the selection ───────────────────────────────────────────────
let region = match selection_result {
Some(SelectionResult::Selected(r)) => r,
Some(SelectionResult::Cancelled) | None => {
eprintln!("Selection cancelled.");
return Ok(());
}
};
// ── 7. Get the raw region image ───────────────────────────────────────────
let raw_image = if let Some(bg) = background_snapshot {
// Freeze mode: Crop directly from the pre-captured snapshot.
let px = (region.x.max(0) as u32).min(bg.width().saturating_sub(1));
let py = (region.y.max(0) as u32).min(bg.height().saturating_sub(1));
let pw = region.width.min(bg.width() - px);
let ph = region.height.min(bg.height() - py);
image::imageops::crop_imm(&bg, px, py, pw, ph).to_image()
} else {
// Live mode: Wait for the compositor to clear the overlay, then capture just the region.
std::thread::sleep(std::time::Duration::from_millis(config.capture_delay_ms.max(200)));
capture::capture_region(region).context("Failed to capture region")?
};
// ── 8a. Auto-mode — no review window ─────────────────────────────────────
if config.auto_save || config.auto_copy {
let final_image = effects::apply_effects(raw_image, &config.effects);
if config.auto_save {
let path = config.output_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Cannot create dir {}", parent.display()))?;
}
final_image.save(&path)
.with_context(|| format!("Failed to save to {}", path.display()))?;
eprintln!("Saved to {}", path.display());
}
if config.auto_copy {
clipboard_copy(&final_image)?;
eprintln!("Copied to clipboard.");
}
return Ok(());
}
// ── 8b. Review window ─────────────────────────────────────────────────────
// The review window applies effects internally on a background thread, so
// we hand it the raw (un-effected) image.
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("rs-pictures — Review")
.with_inner_size([900.0, 700.0])
.with_min_inner_size([400.0, 300.0]),
..Default::default()
};
eframe::run_native(
"rs-pictures — Review",
native_options,
Box::new(move |cc| {
Ok(Box::new(ReviewWindow::new(cc, raw_image, config)) as Box<dyn eframe::App>)
}),
)
.map_err(|e| anyhow::anyhow!("Review window error: {e}"))?;
Ok(())
}
// ─── Clipboard helper (shared with review.rs logic) ───────────────────────────
pub fn clipboard_copy(img: &image::RgbaImage) -> Result<()> {
let (w, h) = img.dimensions();
let bytes = img.as_raw().clone();
let mut cb = Clipboard::new().context("Could not open clipboard")?;
cb.set_image(ImageData {
width: w as usize,
height: h as usize,
bytes: bytes.into(),
})
.context("Failed to write image to clipboard")
}
// ─── Overlay wrapper ──────────────────────────────────────────────────────────
use std::sync::{Arc, Mutex};
struct OverlayWrapper {
inner: SelectionOverlay,
result_sink: Arc<Mutex<Option<SelectionResult>>>,
}
impl eframe::App for OverlayWrapper {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.inner.update(ctx, frame);
if let Some(result) = self.inner.take_result() {
*self.result_sink.lock().unwrap() = Some(result);
}
}
fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
self.inner.clear_color(visuals)
}
}
-359
View File
@@ -1,359 +0,0 @@
//! Region selection overlay.
//!
//! Input modes (all available simultaneously):
//! - Window pick: hover over a window → it highlights; single click captures it.
//! - Drag: press and drag → rubber-band selection (overrides window pick).
//! - Two-click: click once (anchor), move, click again → selection.
//!
//! Coordinate system:
//! egui screen_rect == physical pixels of the monitor.
//! capture::Region also uses physical pixels.
//! Window rects from hyprctl are logical pixels — multiplied by scale to get physical.
//! No scale division anywhere: Region handed to capture.rs is in physical pixels,
//! and capture.rs crops from the physical full-screen capture directly.
use eframe::egui::{self, Color32, CursorIcon, Pos2, Rect, Rounding, Stroke, Vec2};
use image::RgbaImage;
use crate::capture::Region;
use crate::hyprland::WindowRect;
/// Result returned when the user completes or cancels the selection.
#[derive(Debug)]
pub enum SelectionResult {
Selected(Region),
Cancelled,
}
/// Input state machine.
#[derive(Default)]
enum SelectionState {
#[default]
Idle,
Dragging { start: Pos2 },
AwaitingSecondClick { start: Pos2 },
Done { start: Pos2, end: Pos2 },
Cancelled,
}
const MAX_TEX: u32 = 8192;
pub struct SelectionOverlay {
background: Option<egui::TextureHandle>,
/// Hyprland monitor scale (logical→physical). Used only for window rect conversion.
scale: f32,
state: SelectionState,
result: Option<SelectionResult>,
windows: Vec<WindowRect>,
hovered_window: Option<usize>,
diag_printed: bool,
}
impl SelectionOverlay {
pub fn new(cc: &eframe::CreationContext<'_>, background_snapshot: Option<RgbaImage>, scale: f32) -> Self {
let texture = background_snapshot.map(|img| {
let tex_image = fit_to_max_texture(img);
let (tw, th) = tex_image.dimensions();
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[tw as usize, th as usize],
tex_image.as_raw(),
);
cc.egui_ctx
.load_texture("background", color_image, egui::TextureOptions::LINEAR)
});
let windows = crate::hyprland::active_workspace_windows();
Self {
background: texture,
scale,
state: SelectionState::default(),
result: None,
windows,
hovered_window: None,
diag_printed: false,
}
}
pub fn take_result(&mut self) -> Option<SelectionResult> {
self.result.take()
}
/// Convert a hyprctl WindowRect (logical px) to an egui Rect (logical points).
fn window_to_egui_rect(win: &WindowRect, _scale: f32) -> Rect {
Rect::from_min_size(
Pos2::new(win.x as f32, win.y as f32),
Vec2::new(win.width as f32, win.height as f32),
)
}
}
impl eframe::App for SelectionOverlay {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.set_cursor_icon(CursorIcon::Crosshair);
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.state = SelectionState::Cancelled;
}
let screen_rect = ctx.screen_rect();
if !self.diag_printed {
self.diag_printed = true;
eprintln!(
"[overlay diag] screen_rect={screen_rect:?} ppp={} scale={}",
ctx.pixels_per_point(),
self.scale,
);
}
let dim = Color32::from_black_alpha(140);
const DRAG_THRESHOLD: f32 = 4.0;
let (hover_pos, press_origin, primary_down, primary_released) =
ctx.input(|i| {
(
i.pointer.hover_pos(),
i.pointer.press_origin(),
i.pointer.primary_down(),
i.pointer.primary_released(),
)
});
let travel: f32 = match (press_origin, hover_pos) {
(Some(o), Some(p)) => o.distance(p),
_ => 0.0,
};
let is_click = primary_released && travel <= DRAG_THRESHOLD;
let is_drag = primary_down && travel > DRAG_THRESHOLD;
// ── Window hover detection ────────────────────────────────────────────
self.hovered_window = None;
if matches!(self.state, SelectionState::Idle) {
if let Some(pos) = hover_pos {
for (i, win) in self.windows.iter().enumerate().rev() {
if Self::window_to_egui_rect(win, self.scale).contains(pos) {
self.hovered_window = Some(i);
break;
}
}
}
}
// ── State transitions ─────────────────────────────────────────────────
match &self.state {
SelectionState::Idle => {
if is_drag {
self.hovered_window = None;
self.state = SelectionState::Dragging {
start: press_origin.unwrap_or_default(),
};
} else if is_click {
if let Some(idx) = self.hovered_window {
let win = &self.windows[idx];
let rect = Self::window_to_egui_rect(win, self.scale).intersect(screen_rect);
self.state = SelectionState::Done {
start: rect.min,
end: rect.max,
};
} else {
self.state = SelectionState::AwaitingSecondClick {
start: press_origin.unwrap_or_default(),
};
}
}
}
SelectionState::Dragging { start } => {
let start = *start;
if primary_released {
if let Some(end) = hover_pos {
self.state = SelectionState::Done { start, end };
} else {
self.state = SelectionState::Idle;
}
}
if !primary_down && !primary_released {
self.state = SelectionState::AwaitingSecondClick { start };
}
}
SelectionState::AwaitingSecondClick { start } => {
let start = *start;
if is_click {
if let Some(end) = hover_pos {
self.state = SelectionState::Done { start, end };
}
}
}
SelectionState::Done { .. } | SelectionState::Cancelled => {}
}
// ── Current live rect ─────────────────────────────────────────────────
let current_rect: Option<Rect> = match &self.state {
SelectionState::Dragging { start } => hover_pos.map(|p| Rect::from_two_pos(*start, p)),
SelectionState::AwaitingSecondClick { start } => hover_pos.map(|p| Rect::from_two_pos(*start, p)),
SelectionState::Done { start, end } => Some(Rect::from_two_pos(*start, *end)),
_ => None,
};
// ── Draw ──────────────────────────────────────────────────────────────
egui::CentralPanel::default()
.frame(egui::Frame::none())
.show(ctx, |ui| {
let painter = ui.painter();
// 1. Background screenshot (if in freeze mode).
if let Some(bg) = &self.background {
painter.image(
bg.id(),
screen_rect,
Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
}
// 2. Dim overlay.
let active_rect = current_rect.or_else(|| {
self.hovered_window
.map(|i| Self::window_to_egui_rect(&self.windows[i], self.scale).intersect(screen_rect))
});
if let Some(sel) = active_rect {
let s = sel.intersect(screen_rect);
painter.rect_filled(
Rect::from_min_max(screen_rect.min, Pos2::new(screen_rect.max.x, s.min.y)),
Rounding::ZERO, dim,
);
painter.rect_filled(
Rect::from_min_max(Pos2::new(screen_rect.min.x, s.max.y), screen_rect.max),
Rounding::ZERO, dim,
);
painter.rect_filled(
Rect::from_min_max(
Pos2::new(screen_rect.min.x, s.min.y),
Pos2::new(s.min.x, s.max.y),
),
Rounding::ZERO, dim,
);
painter.rect_filled(
Rect::from_min_max(
Pos2::new(s.max.x, s.min.y),
Pos2::new(screen_rect.max.x, s.max.y),
),
Rounding::ZERO, dim,
);
// Only draw a stroke if we are actively dragging a selection.
if current_rect.is_some() {
painter.rect_stroke(s, Rounding::ZERO, Stroke::new(1.5, Color32::from_rgb(100, 180, 255)));
}
// Size label in physical pixels.
let label = format!("{} × {}", s.width().round() as u32, s.height().round() as u32);
let lp = Pos2::new(s.min.x + 4.0, s.min.y - 18.0)
.clamp(Pos2::ZERO, screen_rect.max);
painter.text(lp, egui::Align2::LEFT_TOP, label,
egui::FontId::monospace(13.0), Color32::WHITE);
if let Some(idx) = self.hovered_window {
if current_rect.is_none() {
let title = &self.windows[idx].title;
if !title.is_empty() {
painter.text(
Pos2::new(s.min.x + 4.0, s.min.y + 4.0),
egui::Align2::LEFT_TOP,
title,
egui::FontId::proportional(12.0),
Color32::from_rgba_unmultiplied(255, 190, 50, 220),
);
}
}
}
} else {
painter.rect_filled(screen_rect, Rounding::ZERO, dim);
}
// 3. Crosshair.
if let Some(pos) = hover_pos {
let s = Stroke::new(1.0, Color32::from_white_alpha(180));
painter.line_segment(
[Pos2::new(screen_rect.min.x, pos.y), Pos2::new(screen_rect.max.x, pos.y)], s);
painter.line_segment(
[Pos2::new(pos.x, screen_rect.min.y), Pos2::new(pos.x, screen_rect.max.y)], s);
}
// 4. Hint text.
let hint = match &self.state {
SelectionState::Idle if self.hovered_window.is_some() =>
"Click to capture window | Drag for custom selection | Esc to cancel",
SelectionState::Idle =>
"Click or drag to select | Esc to cancel",
SelectionState::Dragging { .. } => "Release to capture",
SelectionState::AwaitingSecondClick { .. } =>
"Click to set the second corner | Esc to cancel",
_ => "",
};
if !hint.is_empty() {
painter.text(
Pos2::new(screen_rect.center().x, screen_rect.max.y - 28.0),
egui::Align2::CENTER_BOTTOM,
hint,
egui::FontId::proportional(14.0),
Color32::from_white_alpha(200),
);
}
});
// ── Resolve ───────────────────────────────────────────────────────────
match &self.state {
SelectionState::Done { start, end } => {
let rect = Rect::from_two_pos(*start, *end).intersect(screen_rect);
if rect.width() > 2.0 && rect.height() > 2.0 {
let ppp = ctx.pixels_per_point();
// egui coords are logical points — scale to physical pixels.
let x = ((rect.min.x - screen_rect.min.x) * ppp).round() as i32;
let y = ((rect.min.y - screen_rect.min.y) * ppp).round() as i32;
let width = (rect.width() * ppp).round() as u32;
let height = (rect.height() * ppp).round() as u32;
eprintln!("[overlay] physical x={x} y={y} w={width} h={height}");
self.result = Some(SelectionResult::Selected(Region {
x: x.max(0),
y: y.max(0),
width,
height,
}));
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
self.state = SelectionState::Idle;
}
}
SelectionState::Cancelled => {
self.result = Some(SelectionResult::Cancelled);
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
_ => {}
}
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
[0.0, 0.0, 0.0, 0.0]
}
}
fn fit_to_max_texture(img: RgbaImage) -> RgbaImage {
let (w, h) = img.dimensions();
if w <= MAX_TEX && h <= MAX_TEX {
return img;
}
let scale = (MAX_TEX as f32 / w as f32).min(MAX_TEX as f32 / h as f32);
image::imageops::resize(
&img,
(w as f32 * scale) as u32,
(h as f32 * scale) as u32,
image::imageops::FilterType::Triangle,
)
}
-345
View File
@@ -1,345 +0,0 @@
//! After-capture review window.
//!
//! Performance design:
//! - Effects (rounded corners, drop shadow) run on a background thread so the
//! UI never blocks. A `Receiver` is polled each frame; when the result
//! arrives the texture is swapped out.
//! - A debounce timer (`dirty_since`) ensures we only spawn a new worker 150 ms
//! after the last slider change, not on every incremental drag tick.
//! - The raw image is wrapped in Arc so it is shared with worker threads
//! without cloning the pixel data.
//! - Texture uploads are guarded by MAX_TEX so we never panic on large images.
use std::path::PathBuf;
use std::sync::{Arc, mpsc};
use std::time::{Duration, Instant};
use arboard::{Clipboard, ImageData};
use eframe::egui::{self, Color32, ColorImage, Rounding, ScrollArea, Stroke, TextureHandle, TextureOptions, Vec2};
use image::RgbaImage;
use crate::{config::Config, effects::apply_effects};
const MAX_TEX: u32 = 8192;
/// How long to wait after the last setting change before spawning the worker.
const DEBOUNCE: Duration = Duration::from_millis(150);
#[derive(Debug)]
pub enum ReviewAction {
#[allow(dead_code)] // path stored for future use (e.g. desktop notification)
Saved(PathBuf),
Copied,
Discarded,
}
/// Channel message from the background effects worker.
struct EffectsResult(RgbaImage);
pub struct ReviewWindow {
/// Full-resolution raw capture — shared with worker threads via Arc.
raw_image: Arc<RgbaImage>,
/// Last fully-processed preview (what gets saved/copied).
preview_image: Arc<RgbaImage>,
/// GPU texture (may be downscaled for display).
preview_texture: TextureHandle,
pub config: Config,
save_as_path: String,
status_message: Option<String>,
pub action: Option<ReviewAction>,
settings_open: bool,
/// Set when settings change; cleared when a worker is spawned.
dirty_since: Option<Instant>,
/// Receives the processed image from the background worker.
worker_rx: Option<mpsc::Receiver<EffectsResult>>,
/// True while a worker is running.
worker_running: bool,
}
impl ReviewWindow {
pub fn new(cc: &eframe::CreationContext<'_>, raw_image: RgbaImage, config: Config) -> Self {
let raw = Arc::new(raw_image);
let preview = Arc::new(apply_effects((*raw).clone(), &config.effects));
let texture = upload_texture(&cc.egui_ctx, &preview);
let save_as_path = config.output_path().display().to_string();
Self {
raw_image: raw,
preview_image: preview,
preview_texture: texture,
config,
save_as_path,
status_message: None,
action: None,
settings_open: false,
dirty_since: None,
worker_rx: None,
worker_running: false,
}
}
/// Mark settings as changed. A worker will be spawned after the debounce.
fn mark_dirty(&mut self) {
// Only reset the timer if we're not already waiting (avoids pushing
// the debounce out indefinitely on fast slider drag).
if self.dirty_since.is_none() {
self.dirty_since = Some(Instant::now());
}
}
/// Poll for a finished worker result and/or spawn a new one if due.
fn tick_effects(&mut self, ctx: &egui::Context) {
// 1. Check if the running worker is done.
if let Some(rx) = &self.worker_rx {
if let Ok(EffectsResult(img)) = rx.try_recv() {
let img = Arc::new(img);
self.preview_texture = upload_texture(ctx, &img);
self.preview_image = img;
self.worker_rx = None;
self.worker_running = false;
}
}
// 2. Spawn a new worker if debounce has elapsed and none is running.
if let Some(since) = self.dirty_since {
if !self.worker_running && since.elapsed() >= DEBOUNCE {
self.dirty_since = None;
self.worker_running = true;
let raw = Arc::clone(&self.raw_image);
let effects_cfg = self.config.effects.clone();
let (tx, rx) = mpsc::channel();
let ctx_clone = ctx.clone();
std::thread::spawn(move || {
let result = apply_effects((*raw).clone(), &effects_cfg);
let _ = tx.send(EffectsResult(result));
// Wake the egui event loop so the new texture is picked up.
ctx_clone.request_repaint();
});
self.worker_rx = Some(rx);
}
// Keep repainting while waiting for the debounce to fire.
if self.worker_running || self.dirty_since.is_some() {
ctx.request_repaint_after(Duration::from_millis(50));
}
}
}
fn copy_to_clipboard(&mut self) {
match Clipboard::new() {
Ok(mut cb) => {
let (w, h) = self.preview_image.dimensions();
let bytes = self.preview_image.as_raw().clone();
match cb.set_image(ImageData { width: w as usize, height: h as usize, bytes: bytes.into() }) {
Ok(_) => {
self.status_message = Some("Copied to clipboard.".into());
self.action = Some(ReviewAction::Copied);
}
Err(e) => self.status_message = Some(format!("Clipboard error: {e}")),
}
}
Err(e) => self.status_message = Some(format!("Could not open clipboard: {e}")),
}
}
fn save_to_path(&mut self, path: PathBuf) {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.status_message = Some(format!("Could not create directory: {e}"));
return;
}
}
match self.preview_image.save(&path) {
Ok(_) => {
self.status_message = Some(format!("Saved to {}", path.display()));
self.action = Some(ReviewAction::Saved(path));
}
Err(e) => self.status_message = Some(format!("Save error: {e}")),
}
}
}
impl eframe::App for ReviewWindow {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick_effects(ctx);
// ── Global keybinds ───────────────────────────────────────────────────
// Ctrl+C — copy to clipboard immediately.
// Checked before any panel so it works regardless of widget focus.
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::C)) {
self.copy_to_clipboard();
}
// ── Top bar ───────────────────────────────────────────────────────────
egui::TopBottomPanel::top("actions").show(ctx, |ui| {
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("📋 Copy").clicked() {
self.copy_to_clipboard();
}
if ui.button("💾 Save").clicked() {
let path = self.config.output_path();
self.save_to_path(path);
}
ui.separator();
ui.label("Save As:");
ui.add(
egui::TextEdit::singleline(&mut self.save_as_path)
.desired_width(300.0)
.hint_text("/home/user/Pictures/shot.png"),
);
if ui.button("Save").clicked() {
let path = PathBuf::from(&self.save_as_path);
self.save_to_path(path);
}
ui.separator();
let label = if self.settings_open { "▲ Effects" } else { "▼ Effects" };
if ui.button(label).clicked() {
self.settings_open = !self.settings_open;
}
// Spinner while worker is active.
if self.worker_running {
ui.spinner();
}
ui.separator();
if ui.button("✖ Discard").clicked() {
self.action = Some(ReviewAction::Discarded);
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.add_space(4.0);
});
// ── Effects panel ─────────────────────────────────────────────────────
if self.settings_open {
egui::TopBottomPanel::top("settings").show(ctx, |ui| {
ui.add_space(6.0);
ui.heading("Effects");
ui.separator();
let e = &mut self.config.effects;
let mut changed = false;
ui.horizontal(|ui| {
changed |= ui.checkbox(&mut e.rounded_corners, "Rounded corners").changed();
if e.rounded_corners {
ui.label("Radius:");
changed |= ui
.add(egui::Slider::new(&mut e.corner_radius, 1.0..=64.0).suffix(" px"))
.changed();
}
});
ui.horizontal(|ui| {
changed |= ui.checkbox(&mut e.drop_shadow, "Drop shadow").changed();
if e.drop_shadow {
ui.label("Blur:");
changed |= ui
.add(egui::Slider::new(&mut e.shadow_blur_radius, 0.0..=60.0).suffix(" px"))
.changed();
ui.label("X:");
changed |= ui
.add(egui::Slider::new(&mut e.shadow_offset_x, -40.0..=40.0).suffix(" px"))
.changed();
ui.label("Y:");
changed |= ui
.add(egui::Slider::new(&mut e.shadow_offset_y, -40.0..=40.0).suffix(" px"))
.changed();
}
});
if changed {
self.mark_dirty();
let _ = self.config.save();
}
ui.add_space(4.0);
});
}
// ── Status bar ────────────────────────────────────────────────────────
if let Some(msg) = self.status_message.clone() {
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&msg).color(Color32::LIGHT_GREEN));
if ui.small_button("").clicked() {
self.status_message = None;
}
});
ui.add_space(4.0);
});
}
// ── Preview ───────────────────────────────────────────────────────────
egui::CentralPanel::default().show(ctx, |ui| {
ScrollArea::both().show(ui, |ui| {
let tex_size = self.preview_texture.size_vec2();
let available = ui.available_size();
let scale = (available.x / tex_size.x)
.min(available.y / tex_size.y)
.min(1.0);
let display_size = tex_size * scale;
let img_rect = ui.allocate_space(display_size).1;
draw_checkerboard(ui.painter(), img_rect);
ui.painter().image(
self.preview_texture.id(),
img_rect,
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::Pos2::new(1.0, 1.0)),
Color32::WHITE,
);
ui.painter().rect_stroke(
img_rect,
Rounding::ZERO,
Stroke::new(1.0, Color32::from_gray(80)),
);
});
});
// Close after save/copy — give one extra frame so the status message
// is visible for a moment.
if let Some(ReviewAction::Saved(_) | ReviewAction::Copied) = &self.action {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn upload_texture(ctx: &egui::Context, img: &RgbaImage) -> TextureHandle {
let (w, h) = img.dimensions();
let scaled;
let src: &RgbaImage = if w > MAX_TEX || h > MAX_TEX {
let s = (MAX_TEX as f32 / w as f32).min(MAX_TEX as f32 / h as f32);
scaled = image::imageops::resize(
img,
(w as f32 * s) as u32,
(h as f32 * s) as u32,
image::imageops::FilterType::Triangle,
);
&scaled
} else {
img
};
let (uw, uh) = src.dimensions();
let ci = ColorImage::from_rgba_unmultiplied([uw as usize, uh as usize], src.as_raw());
ctx.load_texture("preview", ci, TextureOptions::LINEAR)
}
fn draw_checkerboard(painter: &egui::Painter, rect: egui::Rect) {
let tile = 8.0_f32;
let c0 = Color32::from_gray(200);
let c1 = Color32::from_gray(160);
let cols = (rect.width() / tile).ceil() as u32;
let rows = (rect.height() / tile).ceil() as u32;
for row in 0..rows {
for col in 0..cols {
let color = if (row + col) % 2 == 0 { c0 } else { c1 };
let min = rect.min + Vec2::new(col as f32 * tile, row as f32 * tile);
let max = (min + Vec2::splat(tile)).min(rect.max);
painter.rect_filled(egui::Rect::from_min_max(min, max), Rounding::ZERO, color);
}
}
}
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
fn main() { println!("test"); }
-3
View File
@@ -1,3 +0,0 @@
fn main() {
let _ = eframe::egui::ViewportBuilder::default().with_active(false);
}
View File
+92
View File
@@ -0,0 +1,92 @@
[package]
name = "winiterm"
version = "0.1.0"
edition = "2024"
[lib]
name = "winiterm"
path = "src/lib.rs"
[[bin]]
name = "winiterm"
path = "src/main.rs"
[[bin]]
name = "bench_process"
path = "src/bin/bench_process.rs"
[[bin]]
name = "bench_render"
path = "src/bin/bench_render.rs"
[dependencies]
# Windows API — PTY (ConPTY), process management, DWM transparency
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Console",
"Win32_System_Threading",
"Win32_System_Pipes",
"Win32_Graphics_Dwm",
"Win32_UI_WindowsAndMessaging",
]}
# Error handling
thiserror = "2"
# Logging
log = "0.4"
env_logger = "0.11"
# Compact bitfield attributes for terminal cells
bitflags = "2"
# Window creation and OS event loop
winit = "0.30"
# GPU rendering (DirectX 12 on Windows via wgpu)
wgpu = { version = "22", features = ["wgsl"] }
# Minimal async executor for wgpu initialisation
pollster = "0.3"
# Font discovery (scans C:\Windows\Fonts, pure Rust)
fontdb = "0.16"
# Font rendering: glyph rasterisation + COLR emoji (pure Rust)
swash = "0.1"
# Cast structs to byte slices for GPU buffer uploads
bytemuck = { version = "1", features = ["derive"] }
# Lua 5.4 scripting — config + plugin/event system (Phase 5 & 6)
mlua = { version = "0.10", features = ["lua54", "vendored"] }
# Filesystem watcher for Lua config hot-reload (Phase 5)
notify = "6"
# System tray icon for persistence mode (Phase 11)
tray-icon = "0.24"
# Context menu for the system tray (right-click Show / Quit)
muda = "0.19"
# Image decoding for Kitty Graphics Protocol (Phase 9)
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# base64 decoding for Kitty image data (Phase 9)
base64 = "0.22"
# Clipboard read/write for Ctrl+Shift+V paste
arboard = "3"
# Unicode character display-width (0=combining, 1=normal, 2=wide/CJK)
unicode-width = "0.2"
[features]
default = []
# Enable per-frame GPU timing instrumentation overlay (Phase 12)
render_timing = []
# Strip all logging in release builds
production = ["log/release_max_level_off"]
+33
View File
@@ -0,0 +1,33 @@
-- winiterm configuration
-- Location: %APPDATA%\winiterm\config.lua
--
-- This file is loaded at startup and hot-reloaded whenever it changes.
-- Return a plain table; unknown keys are ignored.
return {
-- Base font size in logical pixels.
-- The actual pixel size is scaled by the display's DPI factor.
font_size = 16.0,
-- Active colour scheme (case-insensitive).
-- Built-in choices: monokai, nord, dracula, one_dark, solarized_dark, gruvbox
color_scheme = "monokai",
-- Shell executable to spawn in each pane.
-- nil = auto-detect: tries pwsh.exe → powershell.exe → cmd.exe
shell = nil,
-- Number of scrollback lines to retain per pane.
scrollback_lines = 10000,
-- Attempt to enable the Windows 11 Mica system backdrop.
-- Requires Windows 11 22H2 or later; silently ignored on older versions.
enable_mica = false,
-- Show a system-tray icon so winiterm persists when all windows are closed.
tray_icon = false,
-- Window opacity in [0.0, 1.0].
-- 1.0 = fully opaque (default). Values below 1.0 require a compositor.
opacity = 1.0,
}
+515
View File
@@ -0,0 +1,515 @@
//! Application entry point (Phase 4 rewrite).
//!
//! [`App`] implements winit's [`ApplicationHandler`] trait.
//!
//! Lifecycle:
//! * `new()` — loads config, sets up filesystem watcher for hot-reload,
//! initialises Lua API.
//! * `resumed()` — creates the window, renderer, workspace (shell PTY).
//! * `window_event()` — handles keyboard input, resize, redraw.
//! * `about_to_wait()` — drains PTY output, polls config changes, requests
//! redraw on dirty state.
use std::sync::{mpsc, Arc};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, Modifiers, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
use crate::config::Config;
use crate::input::{key_to_action, InputAction};
use crate::lua_api::LuaApi;
use crate::renderer::Renderer;
use crate::split::SplitDir;
use crate::terminal::TerminalModes;
use crate::tray::TrayHandle;
use crate::workspace::Workspace;
// ─── App ─────────────────────────────────────────────────────────────────────
pub struct App {
// Configuration — loaded at startup, can be hot-reloaded.
config: Config,
// winit / GPU / workspace.
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
workspace: Option<Workspace>,
modifiers: Modifiers,
// Tray icon handle (keeps the icon alive).
_tray: Option<TrayHandle>,
// Filesystem watcher for config hot-reload.
_watcher: Option<notify::RecommendedWatcher>,
config_rx: Option<mpsc::Receiver<notify::Result<notify::Event>>>,
// Lua event hook system.
lua: Option<LuaApi>,
}
impl App {
pub fn new() -> Self {
let config = Config::load();
// ── Config file watcher (notify) ──────────────────────────────────────
let (watcher, config_rx) = setup_watcher(&config);
// ── Lua API ───────────────────────────────────────────────────────────
let lua = match LuaApi::new() {
Ok(api) => {
// Execute the config file in the Lua VM so that
// `winiterm.on(...)` hooks defined there actually fire (C1).
if let Err(e) = api.exec_config(&Config::config_path()) {
log::warn!("Lua config exec error: {e}");
}
Some(api)
}
Err(e) => {
log::warn!("failed to create Lua state: {e}");
None
}
};
// ── Tray icon ─────────────────────────────────────────────────────────
let tray = if config.tray_icon {
crate::tray::create_tray_icon()
} else {
None
};
Self {
config,
window: None,
renderer: None,
workspace: None,
modifiers: Modifiers::default(),
_tray: tray,
_watcher: watcher,
config_rx,
lua,
}
}
pub fn run() {
let event_loop = EventLoop::new().expect("failed to create event loop");
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = Self::new();
event_loop.run_app(&mut app).expect("event loop error");
}
// ── Private helpers ───────────────────────────────────────────────────────
/// Apply a freshly loaded config to the live renderer and workspace.
fn apply_config_reload(&mut self, new_config: Config) {
// C1: clear existing Lua hooks then re-execute the config file so that
// any `winiterm.on(...)` registrations don't accumulate across reloads.
if let Some(lua) = &self.lua {
lua.clear_hooks();
if let Err(e) = lua.exec_config(&Config::config_path()) {
log::warn!("Lua config exec error on reload: {e}");
}
}
if let Some(renderer) = &mut self.renderer {
renderer.set_color_scheme(new_config.color_scheme());
}
self.config = new_config;
// C2: notify Lua scripts that the config was reloaded.
if let Some(lua) = &self.lua {
lua.dispatch("config_reloaded", "");
}
log::info!("config reloaded");
}
/// Poll the config watcher channel and reload if the file was modified.
fn poll_config_reload(&mut self) {
let changed = if let Some(rx) = &self.config_rx {
rx.try_recv()
.ok()
.and_then(|res| res.ok())
.map(|ev| matches!(ev.kind, notify::EventKind::Modify(_)))
.unwrap_or(false)
} else {
false
};
if changed {
let new_cfg = Config::load();
self.apply_config_reload(new_cfg);
}
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
// ─── ApplicationHandler ───────────────────────────────────────────────────────
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window_attrs = Window::default_attributes()
.with_title("winiterm")
.with_inner_size(winit::dpi::LogicalSize::new(1200u32, 800u32));
let window = Arc::new(
event_loop
.create_window(window_attrs)
.expect("failed to create window"),
);
// Optionally enable Windows 11 Mica backdrop.
#[cfg(target_os = "windows")]
if self.config.enable_mica {
enable_mica_for_window(&window);
}
// Apply configured window opacity (layered window alpha).
#[cfg(target_os = "windows")]
if self.config.opacity < 1.0 {
apply_window_opacity(&window, self.config.opacity);
}
let mut renderer = Renderer::new(window.clone(), self.config.font_size);
renderer.set_color_scheme(self.config.color_scheme());
let (cols, rows) = renderer.terminal_size();
let workspace = Workspace::new(
self.config.shell_ref(),
cols,
rows,
self.config.scrollback_lines,
);
self.window = Some(window);
self.renderer = Some(renderer);
self.workspace = Some(workspace);
// C2: dispatch startup event so Lua config hooks can run once the
// window and workspace are live.
if let Some(lua) = &self.lua {
lua.dispatch("startup", "");
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
// ── Window management ─────────────────────────────────────────────
WindowEvent::CloseRequested => {
if self.config.tray_icon {
// D1: hide to tray instead of exiting.
if let Some(win) = &self.window {
win.set_visible(false);
}
} else {
event_loop.exit();
}
}
WindowEvent::Resized(size) => {
if let Some(renderer) = &mut self.renderer {
renderer.resize(size.width, size.height);
if let Some(workspace) = &mut self.workspace {
workspace.resize_all(
size.width as f32,
size.height as f32,
renderer.cell_width,
renderer.cell_height,
renderer.cell_height, // tab bar = one cell row
);
}
}
}
// ── Input ─────────────────────────────────────────────────────────
WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods;
}
WindowEvent::KeyboardInput { event, .. } => {
if event.state == ElementState::Pressed {
let decckm = self
.workspace
.as_ref()
.and_then(|ws| ws.active_pane())
.map(|p| p.terminal.modes.contains(TerminalModes::DECCKM))
.unwrap_or(false);
if let Some(action) = key_to_action(&event, &self.modifiers, decckm) {
self.dispatch_action(action, event_loop);
}
}
}
// ── Render ────────────────────────────────────────────────────────
WindowEvent::RedrawRequested => {
if let (Some(renderer), Some(workspace)) =
(self.renderer.as_mut(), self.workspace.as_mut())
{
renderer.render_workspace(workspace);
}
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// Drain PTY output.
let dirty = self
.workspace
.as_mut()
.map(|ws| ws.process_all_output())
.unwrap_or(false);
// Exit if all shells have died.
if self
.workspace
.as_ref()
.map(|ws| ws.is_empty())
.unwrap_or(false)
{
log::info!("all panes dead — exiting");
event_loop.exit();
return;
}
// Poll config hot-reload.
self.poll_config_reload();
// Poll tray events.
if let Some(tray) = &self._tray {
match crate::tray::poll_events(tray) {
Some(crate::tray::TrayAction::Show) => {
if let Some(win) = &self.window {
win.set_visible(true);
let _ = win.request_inner_size(win.inner_size());
}
}
Some(crate::tray::TrayAction::Quit) => {
event_loop.exit();
}
None => {}
}
}
if dirty {
if let Some(window) = &self.window {
window.request_redraw();
}
}
}
}
// ─── Action dispatch ──────────────────────────────────────────────────────────
impl App {
fn dispatch_action(&mut self, action: InputAction, event_loop: &ActiveEventLoop) {
let shell = self.config.shell.clone();
let scrollback = self.config.scrollback_lines;
match action {
InputAction::Bytes(bytes) => {
if let Some(ws) = &mut self.workspace {
// Any keystroke snaps the viewport back to live output.
ws.scroll_to_bottom_active();
ws.write_to_active(&bytes);
}
}
InputAction::Paste => {
if let Some(ws) = &self.workspace {
match arboard::Clipboard::new().and_then(|mut cb| cb.get_text()) {
Ok(text) => {
let bracketed = ws
.active_pane()
.map(|p| p.terminal.modes.contains(TerminalModes::BRACKET_PASTE))
.unwrap_or(false);
let bytes = if bracketed {
let mut v = b"\x1b[200~".to_vec();
v.extend_from_slice(text.as_bytes());
v.extend_from_slice(b"\x1b[201~");
v
} else {
text.into_bytes()
};
ws.write_to_active(&bytes);
}
Err(e) => log::warn!("clipboard read failed: {e}"),
}
}
}
InputAction::ScrollUp => {
if let Some(ws) = &mut self.workspace {
ws.scroll_up_active(3);
}
}
InputAction::ScrollDown => {
if let Some(ws) = &mut self.workspace {
ws.scroll_down_active(3);
}
}
InputAction::SplitHorizontal => {
if let Some(ws) = &mut self.workspace {
ws.split_active(SplitDir::Horizontal, shell.as_deref(), scrollback);
self.rebalance_layout();
}
if let Some(lua) = &self.lua {
lua.dispatch("pane_opened", "");
}
}
InputAction::SplitVertical => {
if let Some(ws) = &mut self.workspace {
ws.split_active(SplitDir::Vertical, shell.as_deref(), scrollback);
self.rebalance_layout();
}
if let Some(lua) = &self.lua {
lua.dispatch("pane_opened", "");
}
}
InputAction::FocusNext => {
if let Some(ws) = &mut self.workspace {
ws.focus_next();
}
}
InputAction::FocusPrev => {
if let Some(ws) = &mut self.workspace {
ws.focus_prev();
}
}
InputAction::NewTab => {
if let Some((ws, renderer)) = self.workspace.as_mut().zip(self.renderer.as_ref()) {
let (cols, rows) = renderer.terminal_size();
ws.new_tab(shell.as_deref(), cols, rows, scrollback);
}
if let Some(lua) = &self.lua {
lua.dispatch("pane_opened", "");
}
}
InputAction::SwitchTab(idx) => {
if let Some(ws) = &mut self.workspace {
ws.switch_tab(idx);
}
if let Some(lua) = &self.lua {
lua.dispatch("tab_switched", &idx.to_string());
}
}
InputAction::ClosePane => {
if let Some(ws) = &mut self.workspace {
ws.close_active_pane();
if ws.is_empty() {
event_loop.exit();
return;
}
self.rebalance_layout();
}
if let Some(lua) = &self.lua {
lua.dispatch("pane_closed", "");
}
}
}
// Dispatch title change hook if active pane title changed.
if let (Some(ws), Some(lua)) = (&self.workspace, &self.lua) {
if let Some(pane) = ws.active_pane() {
lua.dispatch("title_change", &pane.title);
}
}
if let Some(window) = &self.window {
window.request_redraw();
}
}
/// After a structural change (split/close), propagate new sizes to all panes.
fn rebalance_layout(&mut self) {
if let (Some(ws), Some(renderer)) = (&mut self.workspace, &self.renderer) {
ws.resize_all(
renderer.surface_size().0 as f32,
renderer.surface_size().1 as f32,
renderer.cell_width,
renderer.cell_height,
renderer.cell_height,
);
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn setup_watcher(
config: &Config,
) -> (
Option<notify::RecommendedWatcher>,
Option<mpsc::Receiver<notify::Result<notify::Event>>>,
) {
use notify::Watcher;
let config_path = Config::config_path();
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let watcher = notify::RecommendedWatcher::new(
move |res| {
let _ = tx.send(res);
},
notify::Config::default(),
)
.ok()
.and_then(|mut w| {
w.watch(&config_path, notify::RecursiveMode::NonRecursive)
.ok()?;
Some(w)
});
let rx = if watcher.is_some() { Some(rx) } else { None };
let _ = config; // used for path derivation via Config::config_path()
(watcher, rx)
}
#[cfg(target_os = "windows")]
fn enable_mica_for_window(window: &Window) {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
if let Ok(handle) = window.window_handle() {
if let RawWindowHandle::Win32(h) = handle.as_raw() {
let hwnd = windows::Win32::Foundation::HWND(h.hwnd.get() as *mut core::ffi::c_void);
if crate::dwm::enable_mica(hwnd) {
log::info!("Mica backdrop enabled");
} else {
log::debug!("Mica not supported on this Windows version");
}
}
}
}
/// Apply a window-wide alpha value using the Win32 layered-window API.
///
/// `opacity` is clamped to `[0.0, 1.0]` and mapped to a 0255 byte.
/// The window gains `WS_EX_LAYERED` if it does not already have it.
#[cfg(target_os = "windows")]
fn apply_window_opacity(window: &Window, opacity: f32) {
use windows::Win32::Foundation::{COLORREF, HWND};
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, SetLayeredWindowAttributes, SetWindowLongPtrW, GWL_EXSTYLE, LWA_ALPHA,
WS_EX_LAYERED,
};
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
if let Ok(handle) = window.window_handle() {
if let RawWindowHandle::Win32(h) = handle.as_raw() {
let hwnd = HWND(h.hwnd.get() as *mut core::ffi::c_void);
unsafe {
let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex_style | WS_EX_LAYERED.0 as isize);
let alpha = (opacity.clamp(0.0, 1.0) * 255.0) as u8;
if let Err(e) = SetLayeredWindowAttributes(hwnd, COLORREF(0), alpha, LWA_ALPHA) {
log::warn!("SetLayeredWindowAttributes failed: {e}");
} else {
log::info!("window opacity set to {opacity:.2} (alpha={alpha})");
}
}
}
}
}
+219
View File
@@ -0,0 +1,219 @@
//! VT parser throughput benchmark.
//!
//! Measures how quickly the terminal can process raw bytes from a PTY stream.
//! Methodology is modelled on Kitty's `kitten __benchmark__` (without `--render`):
//! 1. Generate representative synthetic data for each scenario.
//! 2. Do one warmup pass.
//! 3. Run `REPETITIONS` timed passes, report MB/s and elapsed time.
//!
//! Run with:
//! cargo run --bin bench_process --release
use std::time::Instant;
use winiterm::terminal::Terminal;
use winiterm::vt_parser::Parser;
const REPETITIONS: usize = 100;
// ─── Data generators ─────────────────────────────────────────────────────────
const ASCII_PRINTABLE: &[u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?";
const CONTROL_CHARS: &[u8] = b"\n\t";
/// Simple linear-congruential PRNG (deterministic, no dependency).
#[inline]
fn lcg(rng: &mut u64) -> u64 {
*rng = rng
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
*rng
}
/// Pick a random byte from `alphabet`.
fn random_byte(rng: &mut u64, alphabet: &[u8]) -> u8 {
alphabet[(lcg(rng) >> 33) as usize % alphabet.len()]
}
/// Generate `len` random bytes from the combined ASCII + control alphabet.
fn random_text(len: usize, rng: &mut u64) -> Vec<u8> {
let alphabet: Vec<u8> = ASCII_PRINTABLE
.iter()
.chain(CONTROL_CHARS.iter())
.copied()
.collect();
(0..len).map(|_| random_byte(rng, &alphabet)).collect()
}
// ─── Benchmark runner ─────────────────────────────────────────────────────────
fn run_benchmark<F>(name: &str, data: &[u8], repetitions: usize, setup: F)
where
F: Fn() -> (Terminal, Parser),
{
// Warmup
{
let (mut term, mut parser) = setup();
parser.parse(data, &mut term);
}
// Timed passes
let start = Instant::now();
for _ in 0..repetitions {
let (mut term, mut parser) = setup();
parser.parse(data, &mut term);
}
let elapsed = start.elapsed();
let total_bytes = data.len() * repetitions;
let mb = total_bytes as f64 / 1_048_576.0;
let rate = mb / elapsed.as_secs_f64();
println!(
" {:<30} : {:>6.2}s {:>8.1} MB/s ({} reps × {:.2} MB)",
name,
elapsed.as_secs_f64(),
rate,
repetitions,
data.len() as f64 / 1_048_576.0,
);
}
fn make_terminal() -> (Terminal, Parser) {
(Terminal::new(80, 25, 20_000), Parser::new())
}
// ─── main ─────────────────────────────────────────────────────────────────────
fn main() {
println!("=== winiterm VT Parser Benchmark ===");
println!("(methodology matches Kitty's kitten __benchmark__ without --render)\n");
// ── 1. Plain ASCII text ───────────────────────────────────────────────────
println!("--- Plain ASCII text ---");
{
let target = 1024 * 2048 + 13;
let mut rng: u64 = 0xDEAD_BEEF;
let data = random_text(target, &mut rng);
run_benchmark("ascii_only", &data, REPETITIONS, make_terminal);
}
// ── 2. CSI escape codes interleaved with text ─────────────────────────────
println!("\n--- CSI codes with text ---");
{
let target = 1024 * 1024 + 17;
let mut rng: u64 = 0x1234_5678;
let mut data: Vec<u8> = Vec::with_capacity(target + 128);
while data.len() < target {
match (lcg(&mut rng) >> 33) % 10 {
0 => {
// Plain text burst (172 chars)
let len = ((lcg(&mut rng) >> 33) % 72 + 1) as usize;
data.extend(random_text(len, &mut rng));
}
1 | 2 => {
// Cursor movement + reset
data.extend_from_slice(b"\x1b[m\x1b[?1h\x1b[H");
}
3 => {
// SGR: bold + italic + colours
data.extend_from_slice(b"\x1b[1;3;31;42m");
}
4 => {
// SGR: 256-colour foreground + background
data.extend_from_slice(b"\x1b[38;5;214;48;5;236m");
}
5 => {
// SGR: true-colour (RGB) foreground
data.extend_from_slice(b"\x1b[38;2;255;128;0m");
}
6 | 7 => {
// Cursor movement + erase
data.extend_from_slice(b"\x1b[m\x1b[5A\x1b[2K\x1b[1J");
}
_ => {
// SGR reset + misc cursor
data.extend_from_slice(b"\x1b[0m\x1b[10;20H\x1b[?25h");
}
}
}
data.extend_from_slice(b"\x1b[m");
run_benchmark("csi_with_text", &data, REPETITIONS, make_terminal);
}
// ── 3. Long OSC sequences (window titles) ────────────────────────────────
println!("\n--- Long OSC sequences ---");
{
let title_body: String = (0..512)
.map(|i| ASCII_PRINTABLE[i % ASCII_PRINTABLE.len()] as char)
.collect();
let mut data: Vec<u8> = Vec::new();
for _ in 0..2048 {
data.extend_from_slice(b"\x1b]2;");
data.extend_from_slice(title_body.as_bytes());
data.push(0x07); // BEL terminator
}
run_benchmark("osc_long_titles", &data, REPETITIONS, make_terminal);
}
// ── 4. Dense colour changes (typical syntax-highlighted code output) ──────
println!("\n--- Dense colour output (syntax highlighting) ---");
{
let mut data: Vec<u8> = Vec::new();
let words = ["fn", "main", "let", "mut", "if", "return", "use", "pub"];
let mut rng: u64 = 0xABCD_EF01;
for _ in 0..8192 {
// Random foreground colour (3045)
let fg = 30 + (lcg(&mut rng) >> 33) % 16;
let word = words[(lcg(&mut rng) >> 33) as usize % words.len()];
let seq = format!("\x1b[{fg}m{word} \x1b[m");
data.extend_from_slice(seq.as_bytes());
}
run_benchmark("dense_color_output", &data, REPETITIONS, make_terminal);
}
// ── 5. Mixed: scroll + insert/delete lines ────────────────────────────────
println!("\n--- Scroll-heavy output ---");
{
let mut data: Vec<u8> = Vec::new();
let mut rng: u64 = 0xFEED_FACE;
for _ in 0..4096 {
data.extend(random_text(60, &mut rng));
data.push(b'\n');
// Occasionally insert/delete lines
if (lcg(&mut rng) >> 33) % 8 == 0 {
data.extend_from_slice(b"\x1b[1L"); // insert line
}
}
run_benchmark("scroll_heavy", &data, REPETITIONS, make_terminal);
}
println!("\n=== Benchmark complete ===");
println!("Note: measures CPU parse + terminal state updates only (no GPU rendering).");
println!("Compare against Kitty: kitten __benchmark__ (without --render flag)");
}
// ─── Minimal `write!` support for building data vecs ─────────────────────────
use std::fmt::Write as FmtWrite;
struct VecWriter<'a>(&'a mut Vec<u8>);
impl FmtWrite for VecWriter<'_> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.extend_from_slice(s.as_bytes());
Ok(())
}
}
// Override the `write!` macro to work with our VecWriter.
trait WriteVec {
fn write_fmt(&mut self, args: std::fmt::Arguments<'_>);
}
impl WriteVec for Vec<u8> {
fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) {
let _ = std::fmt::write(&mut VecWriter(self), args);
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Benchmark: CPU-side terminal-cell-instance building throughput.
//!
//! Measures how fast the renderer can produce `CellInstance` data for a
//! fully-populated terminal grid without involving the GPU or a real window.
//!
//! Run with:
//! ```
//! cargo run --bin bench_render --release
//! ```
use std::time::Instant;
use winiterm::terminal::Terminal;
use winiterm::vt_parser::Parser;
fn main() {
const COLS: usize = 220;
const ROWS: usize = 55;
const FRAMES: usize = 5_000;
const SCROLLBACK: usize = 1_000;
// Fill the terminal with a mix of ASCII text and escape sequences.
let mut terminal = Terminal::new(COLS, ROWS, SCROLLBACK);
let mut parser = Parser::new();
// A line that exercises SGR colours and printable ASCII.
let fill_line = format!(
"\x1b[32mHello\x1b[0m \x1b[1;33mworld\x1b[0m! {}",
"abcdefghijklmnopqrstuvwxyz0123456789 ".repeat(4)
);
let fill_bytes = fill_line.as_bytes();
for _ in 0..ROWS {
parser.parse(fill_bytes, &mut terminal);
parser.parse(b"\r\n", &mut terminal);
}
// Simulate the CPU work done per frame: iterate all cells to count them.
// (A real renderer would write CellInstance structs into a Vec here.)
let cell_count = COLS * ROWS;
let mut total: u64 = 0;
let start = Instant::now();
for _ in 0..FRAMES {
for idx in 0..cell_count {
// Simulate the work of push_cell: read cell, inspect attrs.
let cell = &terminal.screen[idx];
total = total.wrapping_add(cell.ch as u64);
}
}
let elapsed = start.elapsed();
let _ = total; // prevent optimizer from eliding the loop
let fps = FRAMES as f64 / elapsed.as_secs_f64();
let mcells_per_sec = (FRAMES as f64 * cell_count as f64) / elapsed.as_secs_f64() / 1_000_000.0;
println!(
"bench_render: {} frames × {}×{} cells → {:.0} fps ({:.0} Mcells/s) [{:.2?}]",
FRAMES, COLS, ROWS, fps, mcells_per_sec, elapsed
);
}
+213
View File
@@ -0,0 +1,213 @@
//! Built-in terminal colour schemes (Phase 7).
//!
//! Each scheme provides the 16 ANSI colours plus default fg/bg/cursor.
//! The active scheme is chosen via the Lua config (`winiterm.color_scheme`).
// ─── Type ─────────────────────────────────────────────────────────────────────
/// A complete terminal colour palette.
#[derive(Clone, Debug)]
pub struct ColorScheme {
pub name: &'static str,
/// ANSI colours 015 as linear [r,g,b] (0255).
pub ansi: [[u8; 3]; 16],
/// Default foreground colour.
pub foreground: [u8; 3],
/// Default background colour.
pub background: [u8; 3],
/// Cursor colour.
pub cursor: [u8; 3],
/// Selection background.
pub selection_bg: [u8; 3],
}
impl ColorScheme {
/// Convert a `[u8; 3]` colour to a `[f32; 4]` RGBA value.
#[inline]
pub fn to_rgba(c: [u8; 3]) -> [f32; 4] {
[
c[0] as f32 / 255.0,
c[1] as f32 / 255.0,
c[2] as f32 / 255.0,
1.0,
]
}
}
// ─── Built-in schemes ─────────────────────────────────────────────────────────
pub const MONOKAI: ColorScheme = ColorScheme {
name: "monokai",
ansi: [
[0x27, 0x28, 0x22], // 0 black
[0xF9, 0x26, 0x72], // 1 red
[0xA6, 0xE2, 0x2E], // 2 green
[0xF4, 0xBF, 0x75], // 3 yellow
[0x66, 0xD9, 0xEF], // 4 blue
[0xAE, 0x81, 0xFF], // 5 magenta
[0x2A, 0xA1, 0x98], // 6 cyan
[0xF8, 0xF8, 0xF2], // 7 white
[0x75, 0x71, 0x5E], // 8 bright black
[0xF9, 0x26, 0x72], // 9 bright red
[0xA6, 0xE2, 0x2E], // 10 bright green
[0xF4, 0xBF, 0x75], // 11 bright yellow
[0x66, 0xD9, 0xEF], // 12 bright blue
[0xAE, 0x81, 0xFF], // 13 bright magenta
[0x2A, 0xA1, 0x98], // 14 bright cyan
[0xF9, 0xF8, 0xF5], // 15 bright white
],
foreground: [0xF8, 0xF8, 0xF2],
background: [0x27, 0x28, 0x22],
cursor: [0xF8, 0xF8, 0xF0],
selection_bg: [0x49, 0x48, 0x3E],
};
pub const NORD: ColorScheme = ColorScheme {
name: "nord",
ansi: [
[0x2E, 0x34, 0x40], // 0 black
[0xBF, 0x61, 0x6A], // 1 red
[0xA3, 0xBE, 0x8C], // 2 green
[0xEB, 0xCB, 0x8B], // 3 yellow
[0x81, 0xA1, 0xC1], // 4 blue
[0xB4, 0x8E, 0xAD], // 5 magenta
[0x88, 0xC0, 0xD0], // 6 cyan
[0xE5, 0xE9, 0xF0], // 7 white
[0x4C, 0x56, 0x6A], // 8 bright black
[0xBF, 0x61, 0x6A], // 9 bright red
[0xA3, 0xBE, 0x8C], // 10 bright green
[0xEB, 0xCB, 0x8B], // 11 bright yellow
[0x81, 0xA1, 0xC1], // 12 bright blue
[0xB4, 0x8E, 0xAD], // 13 bright magenta
[0x8F, 0xBC, 0xBB], // 14 bright cyan
[0xEC, 0xEF, 0xF4], // 15 bright white
],
foreground: [0xD8, 0xDE, 0xE9],
background: [0x2E, 0x34, 0x40],
cursor: [0xD8, 0xDE, 0xE9],
selection_bg: [0x43, 0x4C, 0x5E],
};
pub const DRACULA: ColorScheme = ColorScheme {
name: "dracula",
ansi: [
[0x21, 0x22, 0x2C], // 0 black
[0xFF, 0x55, 0x55], // 1 red
[0x50, 0xFA, 0x7B], // 2 green
[0xF1, 0xFA, 0x8C], // 3 yellow
[0xBD, 0x93, 0xF9], // 4 blue
[0xFF, 0x79, 0xC6], // 5 magenta
[0x8B, 0xE9, 0xFD], // 6 cyan
[0xF8, 0xF8, 0xF2], // 7 white
[0x62, 0x72, 0xA4], // 8 bright black
[0xFF, 0x6E, 0x6E], // 9 bright red
[0x69, 0xFF, 0x94], // 10 bright green
[0xFF, 0xFF, 0xA5], // 11 bright yellow
[0xD6, 0xAC, 0xFF], // 12 bright blue
[0xFF, 0x92, 0xDF], // 13 bright magenta
[0xA4, 0xFF, 0xFF], // 14 bright cyan
[0xFF, 0xFF, 0xFF], // 15 bright white
],
foreground: [0xF8, 0xF8, 0xF2],
background: [0x28, 0x2A, 0x36],
cursor: [0xF8, 0xF8, 0xF2],
selection_bg: [0x44, 0x47, 0x5A],
};
pub const ONE_DARK: ColorScheme = ColorScheme {
name: "one_dark",
ansi: [
[0x28, 0x2C, 0x34], // 0 black
[0xE0, 0x6C, 0x75], // 1 red
[0x98, 0xC3, 0x79], // 2 green
[0xE5, 0xC0, 0x7B], // 3 yellow
[0x61, 0xAF, 0xEF], // 4 blue
[0xC6, 0x78, 0xDD], // 5 magenta
[0x56, 0xB6, 0xC2], // 6 cyan
[0xAB, 0xB2, 0xBF], // 7 white
[0x5C, 0x63, 0x70], // 8 bright black
[0xE0, 0x6C, 0x75], // 9 bright red
[0x98, 0xC3, 0x79], // 10 bright green
[0xE5, 0xC0, 0x7B], // 11 bright yellow
[0x61, 0xAF, 0xEF], // 12 bright blue
[0xC6, 0x78, 0xDD], // 13 bright magenta
[0x56, 0xB6, 0xC2], // 14 bright cyan
[0xFF, 0xFF, 0xFF], // 15 bright white
],
foreground: [0xAB, 0xB2, 0xBF],
background: [0x28, 0x2C, 0x34],
cursor: [0x52, 0x8B, 0xFF],
selection_bg: [0x3E, 0x44, 0x51],
};
pub const SOLARIZED_DARK: ColorScheme = ColorScheme {
name: "solarized_dark",
ansi: [
[0x07, 0x36, 0x42], // 0 base02
[0xDC, 0x32, 0x2F], // 1 red
[0x85, 0x99, 0x00], // 2 green
[0xB5, 0x89, 0x00], // 3 yellow
[0x26, 0x8B, 0xD2], // 4 blue
[0xD3, 0x36, 0x82], // 5 magenta
[0x2A, 0xA1, 0x98], // 6 cyan
[0xEE, 0xE8, 0xD5], // 7 base2
[0x00, 0x2B, 0x36], // 8 base03
[0xCB, 0x4B, 0x16], // 9 orange
[0x58, 0x6E, 0x75], // 10 base01
[0x65, 0x7B, 0x83], // 11 base00
[0x83, 0x94, 0x96], // 12 base0
[0x6C, 0x71, 0xC4], // 13 violet
[0x93, 0xA1, 0xA1], // 14 base1
[0xFD, 0xF6, 0xE3], // 15 base3
],
foreground: [0x83, 0x94, 0x96],
background: [0x00, 0x2B, 0x36],
cursor: [0x26, 0x8B, 0xD2],
selection_bg: [0x07, 0x36, 0x42],
};
pub const GRUVBOX: ColorScheme = ColorScheme {
name: "gruvbox",
ansi: [
[0x28, 0x28, 0x28], // 0 bg
[0xCC, 0x24, 0x1D], // 1 red
[0x98, 0x97, 0x1A], // 2 green
[0xD7, 0x99, 0x21], // 3 yellow
[0x45, 0x85, 0x88], // 4 blue
[0xB1, 0x62, 0x86], // 5 purple
[0x68, 0x9D, 0x6A], // 6 aqua
[0xA8, 0x99, 0x84], // 7 fg4
[0x92, 0x83, 0x74], // 8 gray
[0xFB, 0x49, 0x34], // 9 bright red
[0xB8, 0xBB, 0x26], // 10 bright green
[0xFA, 0xBD, 0x2F], // 11 bright yellow
[0x83, 0xA5, 0x98], // 12 bright blue
[0xD3, 0x86, 0x9B], // 13 bright purple
[0x8E, 0xC0, 0x7C], // 14 bright aqua
[0xEB, 0xDB, 0xB2], // 15 fg
],
foreground: [0xEB, 0xDB, 0xB2],
background: [0x28, 0x28, 0x28],
cursor: [0xEB, 0xDB, 0xB2],
selection_bg: [0x3C, 0x38, 0x36],
};
// ─── Registry ─────────────────────────────────────────────────────────────────
/// All built-in schemes. The first entry is the default.
pub const ALL: &[&ColorScheme] = &[
&MONOKAI,
&NORD,
&DRACULA,
&ONE_DARK,
&SOLARIZED_DARK,
&GRUVBOX,
];
/// Look up a scheme by name (case-insensitive). Falls back to Monokai.
pub fn by_name(name: &str) -> &'static ColorScheme {
ALL.iter()
.find(|s| s.name.eq_ignore_ascii_case(name))
.copied()
.unwrap_or(&MONOKAI)
}
+112
View File
@@ -0,0 +1,112 @@
//! Application configuration — loaded from a Lua script (Phase 5).
//!
//! The config file lives at `%APPDATA%\winiterm\config.lua` and is expected
//! to `return` a plain table of key/value pairs. Missing keys fall back to
//! built-in defaults. Parse errors are logged and defaults are used.
use crate::colorscheme::{by_name, ColorScheme};
// ─── Config struct ─────────────────────────────────────────────────────────────
/// All user-tunable settings.
#[derive(Debug, Clone)]
pub struct Config {
/// Base font size in logical pixels (DPI-scaled automatically).
pub font_size: f32,
/// Name of the active colour scheme (case-insensitive).
pub color_scheme: String,
/// Shell executable path. `None` → auto-detect.
pub shell: Option<String>,
/// Scrollback buffer size (lines).
pub scrollback_lines: usize,
/// Try to enable Windows 11 Mica backdrop transparency.
pub enable_mica: bool,
/// Show a persistent system-tray icon.
pub tray_icon: bool,
/// Window opacity in \[0, 1\].
pub opacity: f32,
}
impl Default for Config {
fn default() -> Self {
Self {
font_size: 16.0,
color_scheme: "monokai".to_string(),
shell: None,
scrollback_lines: 10_000,
enable_mica: false,
tray_icon: false,
opacity: 1.0,
}
}
}
impl Config {
// ── File location ─────────────────────────────────────────────────────────
/// Path to the Lua config file.
pub fn config_path() -> std::path::PathBuf {
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string());
std::path::PathBuf::from(appdata)
.join("winiterm")
.join("config.lua")
}
// ── Loading ───────────────────────────────────────────────────────────────
/// Load configuration from disk. Falls back to defaults on any error.
pub fn load() -> Self {
let path = Self::config_path();
if !path.exists() {
log::debug!("config not found at {}, using defaults", path.display());
return Self::default();
}
let code = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
log::warn!("could not read config {}: {e}", path.display());
return Self::default();
}
};
match Self::parse_lua(&code) {
Ok(c) => {
log::info!("loaded config from {}", path.display());
c
}
Err(e) => {
log::warn!("config parse error: {e}");
Self::default()
}
}
}
fn parse_lua(code: &str) -> mlua::Result<Self> {
use mlua::Lua;
let lua = Lua::new();
let tbl: mlua::Table = lua.load(code).eval()?;
Ok(Self {
font_size: tbl.get::<f32>("font_size").unwrap_or(16.0),
color_scheme: tbl
.get::<String>("color_scheme")
.unwrap_or_else(|_| "monokai".to_string()),
shell: tbl.get::<Option<String>>("shell").unwrap_or(None),
scrollback_lines: tbl.get::<usize>("scrollback_lines").unwrap_or(10_000),
enable_mica: tbl.get::<bool>("enable_mica").unwrap_or(false),
tray_icon: tbl.get::<bool>("tray_icon").unwrap_or(false),
opacity: tbl.get::<f32>("opacity").unwrap_or(1.0),
})
}
// ── Convenience accessors ─────────────────────────────────────────────────
/// Look up the active [`ColorScheme`] by name.
pub fn color_scheme(&self) -> &'static ColorScheme {
by_name(&self.color_scheme)
}
/// Shell as `Option<&str>`, ready to pass to `Pty::spawn`.
pub fn shell_ref(&self) -> Option<&str> {
self.shell.as_deref()
}
}
+33
View File
@@ -0,0 +1,33 @@
//! DWM window transparency — Windows 11 Mica backdrop (Phase 8).
//!
//! `enable_mica` applies the Mica system backdrop to the given window handle.
//! On older Windows versions (pre-22H2) the DWM attribute is unknown and the
//! call fails silently, returning `false`.
use windows::Win32::Foundation::HWND;
use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWINDOWATTRIBUTE};
/// `DWMWA_SYSTEMBACKDROP_TYPE` — attribute index 38, added in Windows 11 22H2.
const DWMWA_SYSTEMBACKDROP_TYPE: DWMWINDOWATTRIBUTE = DWMWINDOWATTRIBUTE(38);
/// `DWMSBT_MAINWINDOW` — Mica effect value for `DWMWA_SYSTEMBACKDROP_TYPE`.
const DWMSBT_MAINWINDOW: u32 = 2;
/// Attempt to enable the Mica backdrop on `hwnd`.
///
/// Returns `true` if the DWM call succeeded. Returns `false` (no-ops) on
/// pre-Windows 11 22H2 where the attribute is unsupported.
pub fn enable_mica(hwnd: HWND) -> bool {
let backdrop: u32 = DWMSBT_MAINWINDOW;
// SAFETY: `DwmSetWindowAttribute` is a standard Win32 API. We pass a
// correctly-sized pointer to a `u32` and its byte-size.
unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_SYSTEMBACKDROP_TYPE,
(&backdrop as *const u32).cast(),
std::mem::size_of::<u32>() as u32,
)
.is_ok()
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Keyboard event → terminal action translation.
//!
//! [`key_to_action`] converts a winit key event + modifier state into an
//! [`InputAction`]. Multiplexer hotkeys (Ctrl+Shift+…) are checked first and
//! take precedence over normal VT byte generation.
//!
//! New in A-series:
//! * Alt key: prefixes generated bytes with `\x1b` (xterm metaSendsEscape).
//! * Paste: `Ctrl+Shift+V` → [`InputAction::Paste`].
//! * Scrollback: `Ctrl+Shift+Up/Down` → [`InputAction::ScrollUp`] / `ScrollDown`.
//! * DECCKM: `decckm` parameter selects SS3 vs CSI cursor key encoding.
use winit::event::{ElementState, KeyEvent, Modifiers};
use winit::keyboard::{Key, KeyCode, NamedKey, PhysicalKey};
// ─── InputAction ──────────────────────────────────────────────────────────────
/// The result of translating a key press.
#[derive(Debug, Clone)]
pub enum InputAction {
/// Raw bytes to write to the active pane's PTY stdin.
Bytes(Vec<u8>),
// ── Multiplexer commands ────────────────────────────────────────────────
/// Split active pane horizontally (left | right).
SplitHorizontal,
/// Split active pane vertically (top / bottom).
SplitVertical,
/// Move focus to the next pane in tree order.
FocusNext,
/// Move focus to the previous pane in tree order.
FocusPrev,
/// Open a new tab.
NewTab,
/// Switch to tab by 0-based index.
SwitchTab(usize),
/// Close the active pane (or current tab if it is the last pane).
ClosePane,
/// Paste text from the system clipboard (Ctrl+Shift+V).
Paste,
/// Scroll the active pane up into the scrollback buffer (Ctrl+Shift+Up).
ScrollUp,
/// Scroll the active pane down towards live output (Ctrl+Shift+Down).
ScrollDown,
}
// ─── Public API ───────────────────────────────────────────────────────────────
/// Translate a pressed key event into an [`InputAction`].
///
/// `decckm` — whether DECCKM (application cursor key mode, `?1h`) is active
/// in the current terminal. When `true`, arrow keys send `\x1bOA` (SS3)
/// instead of `\x1b[A` (CSI).
///
/// Returns `None` for release events, unhandled keys, or pure-modifier presses.
pub fn key_to_action(event: &KeyEvent, mods: &Modifiers, decckm: bool) -> Option<InputAction> {
if event.state == ElementState::Released {
return None;
}
let ctrl = mods.state().control_key();
let shift = mods.state().shift_key();
let alt = mods.state().alt_key();
// ── Multiplexer hotkeys (Ctrl+Shift) ──────────────────────────────────────
// These take precedence and never reach the shell.
if ctrl && shift {
let action = match &event.physical_key {
PhysicalKey::Code(KeyCode::Backslash) => Some(InputAction::SplitHorizontal),
PhysicalKey::Code(KeyCode::Minus) => Some(InputAction::SplitVertical),
PhysicalKey::Code(KeyCode::BracketLeft) => Some(InputAction::FocusPrev),
PhysicalKey::Code(KeyCode::BracketRight) => Some(InputAction::FocusNext),
PhysicalKey::Code(KeyCode::KeyT) => Some(InputAction::NewTab),
PhysicalKey::Code(KeyCode::KeyW) => Some(InputAction::ClosePane),
PhysicalKey::Code(KeyCode::KeyV) => Some(InputAction::Paste),
PhysicalKey::Code(KeyCode::ArrowUp) => Some(InputAction::ScrollUp),
PhysicalKey::Code(KeyCode::ArrowDown) => Some(InputAction::ScrollDown),
PhysicalKey::Code(c) => digit_to_tab(c),
_ => None,
};
if action.is_some() {
return action;
}
}
// ── Normal VT byte generation ─────────────────────────────────────────────
let action = match &event.logical_key {
Key::Character(s) => {
if ctrl {
let ch = s.chars().next()?.to_ascii_lowercase();
let byte = match ch {
'a'..='z' => ch as u8 - b'a' + 1,
'[' => 0x1b, // ESC
'\\' => 0x1c,
']' => 0x1d,
'^' => 0x1e,
'_' => 0x1f,
_ => return None,
};
Some(InputAction::Bytes(vec![byte]))
} else {
Some(InputAction::Bytes(s.as_bytes().to_vec()))
}
}
Key::Named(named) => translate_named(named, shift, decckm),
Key::Unidentified(_) | Key::Dead(_) => None,
};
// Alt (xterm metaSendsEscape): prefix generated bytes with ESC.
if alt {
if let Some(InputAction::Bytes(ref bytes)) = action {
let mut v = Vec::with_capacity(bytes.len() + 1);
v.push(0x1b);
v.extend_from_slice(bytes);
return Some(InputAction::Bytes(v));
}
}
action
}
// ─── Private helpers ──────────────────────────────────────────────────────────
fn digit_to_tab(code: &KeyCode) -> Option<InputAction> {
let n: usize = match code {
KeyCode::Digit1 => 1,
KeyCode::Digit2 => 2,
KeyCode::Digit3 => 3,
KeyCode::Digit4 => 4,
KeyCode::Digit5 => 5,
KeyCode::Digit6 => 6,
KeyCode::Digit7 => 7,
KeyCode::Digit8 => 8,
KeyCode::Digit9 => 9,
_ => return None,
};
Some(InputAction::SwitchTab(n - 1))
}
fn translate_named(key: &NamedKey, shift: bool, decckm: bool) -> Option<InputAction> {
let bytes: &[u8] = match key {
NamedKey::Enter => b"\r",
NamedKey::Backspace => b"\x7f",
NamedKey::Tab => {
return Some(InputAction::Bytes(if shift {
b"\x1b[Z".to_vec() // Shift+Tab = back-tab
} else {
b"\t".to_vec()
}));
}
NamedKey::Escape => b"\x1b",
NamedKey::Space => b" ",
// Cursor keys — DECCKM selects SS3 (application) vs CSI (normal).
NamedKey::ArrowUp => {
if decckm {
b"\x1bOA"
} else {
b"\x1b[A"
}
}
NamedKey::ArrowDown => {
if decckm {
b"\x1bOB"
} else {
b"\x1b[B"
}
}
NamedKey::ArrowRight => {
if decckm {
b"\x1bOC"
} else {
b"\x1b[C"
}
}
NamedKey::ArrowLeft => {
if decckm {
b"\x1bOD"
} else {
b"\x1b[D"
}
}
// Home / End also use SS3 in application cursor mode.
NamedKey::Home => {
if decckm {
b"\x1bOH"
} else {
b"\x1b[H"
}
}
NamedKey::End => {
if decckm {
b"\x1bOF"
} else {
b"\x1b[F"
}
}
// Editing keys.
NamedKey::Insert => b"\x1b[2~",
NamedKey::Delete => b"\x1b[3~",
NamedKey::PageUp => b"\x1b[5~",
NamedKey::PageDown => b"\x1b[6~",
// Function keys (xterm encoding).
NamedKey::F1 => b"\x1bOP",
NamedKey::F2 => b"\x1bOQ",
NamedKey::F3 => b"\x1bOR",
NamedKey::F4 => b"\x1bOS",
NamedKey::F5 => b"\x1b[15~",
NamedKey::F6 => b"\x1b[17~",
NamedKey::F7 => b"\x1b[18~",
NamedKey::F8 => b"\x1b[19~",
NamedKey::F9 => b"\x1b[20~",
NamedKey::F10 => b"\x1b[21~",
NamedKey::F11 => b"\x1b[23~",
NamedKey::F12 => b"\x1b[24~",
_ => return None,
};
Some(InputAction::Bytes(bytes.to_vec()))
}
+190
View File
@@ -0,0 +1,190 @@
//! Kitty Graphics Protocol — APC sequence parser and image registry (Phase 9).
//!
//! Parses `ESC_G…ST` sequences emitted by Kitty-protocol-aware applications
//! and assembles them into decoded RGBA images stored in a [`KittyRegistry`].
//!
//! Integration note: the VT parser accumulates APC bytes in its `dcs_buf`
//! (state `SosPmApcString`). Phase 9 wiring will route those bytes through
//! [`KittyRegistry::ingest`] before discarding them.
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use std::collections::HashMap;
// ─── Control block ────────────────────────────────────────────────────────────
/// Key/value data decoded from a single Kitty APC control block.
#[derive(Debug, Default, Clone)]
pub struct KittyControl {
/// Action: `'t'` transmit, `'p'` put, `'d'` delete, `'q'` query.
pub action: Option<char>,
/// Image ID.
pub image_id: Option<u32>,
/// Placement ID.
pub placement_id: Option<u32>,
/// Data format: 32 = RGBA, 24 = RGB, 100 = PNG.
pub format: Option<u32>,
/// Image width in pixels.
pub width: Option<u32>,
/// Image height in pixels.
pub height: Option<u32>,
/// `true` when more chunks are expected (`m=1`).
pub more: bool,
/// Suppress response (`q≠0`).
pub quiet: bool,
}
// ─── Image ────────────────────────────────────────────────────────────────────
/// A fully decoded Kitty image (RGBA pixels, row-major).
#[derive(Debug)]
pub struct KittyImage {
pub id: u32,
pub width: u32,
pub height: u32,
/// Raw RGBA pixels: `width * height * 4` bytes.
pub rgba: Vec<u8>,
}
// ─── Registry ─────────────────────────────────────────────────────────────────
/// Stores assembled Kitty images keyed by image ID, and accumulates partial
/// multi-chunk transmissions until the final chunk arrives.
#[derive(Default)]
pub struct KittyRegistry {
images: HashMap<u32, KittyImage>,
/// Partial payloads awaiting their final chunk: id → (control, raw bytes).
pending: HashMap<u32, (KittyControl, Vec<u8>)>,
}
impl KittyRegistry {
pub fn new() -> Self {
Self::default()
}
/// Ingest the raw bytes of a Kitty APC sequence — everything between the
/// `ESC_G` introducer and the `ST` terminator.
pub fn ingest(&mut self, payload: &[u8]) {
let Some((ctrl_bytes, b64)) = split_payload(payload) else {
return;
};
let ctrl = parse_control(ctrl_bytes);
let mut decoded = B64.decode(b64).unwrap_or_default();
let id = ctrl.image_id.unwrap_or(0);
if ctrl.more {
// Accumulate partial chunk.
self.pending
.entry(id)
.and_modify(|(_, buf)| buf.append(&mut decoded))
.or_insert_with(|| (ctrl, decoded));
} else {
// Final chunk — assemble the complete image.
let (base_ctrl, mut buf) = self
.pending
.remove(&id)
.unwrap_or_else(|| (ctrl.clone(), Vec::new()));
buf.append(&mut decoded);
let w = base_ctrl.width.unwrap_or(0);
let h = base_ctrl.height.unwrap_or(0);
if w > 0 && h > 0 {
let rgba = match base_ctrl.format.unwrap_or(32) {
100 => decode_png(&buf),
32 => buf,
24 => rgb_to_rgba(&buf),
_ => buf,
};
self.images.insert(
id,
KittyImage {
id,
width: w,
height: h,
rgba,
},
);
}
}
}
/// Retrieve a decoded image by ID.
pub fn get(&self, id: u32) -> Option<&KittyImage> {
self.images.get(&id)
}
/// Remove a single image and any pending chunks for it by ID.
pub fn remove(&mut self, id: u32) {
self.images.remove(&id);
self.pending.remove(&id);
}
/// Remove all stored images and pending chunks.
pub fn clear(&mut self) {
self.images.clear();
self.pending.clear();
}
}
// ─── Public helper ────────────────────────────────────────────────────────────
/// Parse only the control header of a raw Kitty APC payload
/// (everything after the leading `'G'` byte and before `ST`).
///
/// Returns a default [`KittyControl`] when the payload has no `';'` separator.
pub fn parse_header(payload: &[u8]) -> KittyControl {
split_payload(payload)
.map(|(ctrl, _)| parse_control(ctrl))
.unwrap_or_default()
}
// ─── Private helpers ──────────────────────────────────────────────────────────
/// Split the payload at the first `';'` separator.
fn split_payload(payload: &[u8]) -> Option<(&[u8], &[u8])> {
payload
.iter()
.position(|&b| b == b';')
.map(|i| (&payload[..i], &payload[i + 1..]))
}
/// Parse comma-separated `key=value` control data.
fn parse_control(ctrl: &[u8]) -> KittyControl {
let mut c = KittyControl::default();
for kv in ctrl.split(|&b| b == b',') {
if kv.len() < 3 || kv[1] != b'=' {
continue;
}
let val = &kv[2..];
match kv[0] {
b'a' => c.action = val.first().map(|&b| b as char),
b'i' => c.image_id = parse_u32(val),
b'p' => c.placement_id = parse_u32(val),
b'f' => c.format = parse_u32(val),
b's' => c.width = parse_u32(val),
b'v' => c.height = parse_u32(val),
b'm' => c.more = val == b"1",
b'q' => c.quiet = val != b"0",
_ => {}
}
}
c
}
fn parse_u32(bytes: &[u8]) -> Option<u32> {
std::str::from_utf8(bytes).ok()?.parse().ok()
}
fn decode_png(data: &[u8]) -> Vec<u8> {
image::load_from_memory(data)
.map(|img| img.into_rgba8().into_raw())
.unwrap_or_default()
}
fn rgb_to_rgba(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() / 3 * 4);
for chunk in data.chunks_exact(3) {
out.extend_from_slice(chunk);
out.push(255);
}
out
}
+20
View File
@@ -0,0 +1,20 @@
//! winiterm — GPU-accelerated terminal emulator for Windows.
//!
//! Library root: exposes all core modules for use by the main binary
//! and standalone benchmark binaries.
pub mod app;
pub mod colorscheme;
pub mod config;
pub mod dwm;
pub mod input;
pub mod kitty;
pub mod lua_api;
pub mod pane;
pub mod pty;
pub mod renderer;
pub mod split;
pub mod terminal;
pub mod tray;
pub mod vt_parser;
pub mod workspace;
+108
View File
@@ -0,0 +1,108 @@
//! Lua event-hook system (Phase 6).
//!
//! [`LuaApi`] owns a Lua 5.4 state and exposes a `winiterm.on(event, fn)`
//! function that plugins / the config file can call to register callbacks.
//! Events are dispatched synchronously from the Rust side via
//! [`LuaApi::dispatch`].
//!
//! # Usage in config.lua
//! ```lua
//! local winiterm = require 'winiterm' -- or just access the global
//! winiterm.on("title_change", function(title)
//! print("title changed to " .. title)
//! end)
//! ```
use mlua::{Function as LuaFunction, Lua, Table as LuaTable};
// ─── LuaApi ───────────────────────────────────────────────────────────────────
/// Owns the Lua state and the hook registry.
pub struct LuaApi {
lua: Lua,
}
impl LuaApi {
/// Create a new Lua state and set up the `winiterm` global module.
pub fn new() -> mlua::Result<Self> {
let lua = Lua::new();
Self::setup_globals(&lua)?;
Ok(Self { lua })
}
fn setup_globals(lua: &Lua) -> mlua::Result<()> {
// Internal storage: _winiterm_hooks[event_name] = { fn, fn, … }
let hooks: LuaTable = lua.create_table()?;
lua.globals().set("_winiterm_hooks", hooks)?;
// winiterm.on(event, callback)
let on_fn = lua.create_function(|lua, (event, cb): (String, LuaFunction)| {
let hooks: LuaTable = lua.globals().get("_winiterm_hooks")?;
let list: LuaTable = hooks
.get::<Option<LuaTable>>(event.as_str())?
.unwrap_or(lua.create_table()?);
list.push(cb)?;
hooks.set(event.as_str(), list)?;
Ok(())
})?;
let winiterm: LuaTable = lua.create_table()?;
winiterm.set("on", on_fn)?;
lua.globals().set("winiterm", winiterm)?;
Ok(())
}
/// Execute a Lua script in the current state.
///
/// Can be called multiple times (e.g. once for the built-in defaults and
/// once for the user config).
pub fn exec(&self, code: &str) -> mlua::Result<()> {
self.lua.load(code).exec()
}
/// Read the config file at `path` and execute it in this Lua state,
/// registering any `winiterm.on(...)` hooks it defines.
///
/// Silently succeeds if the file does not exist yet.
pub fn exec_config(&self, path: &std::path::Path) -> mlua::Result<()> {
if !path.exists() {
return Ok(());
}
let code =
std::fs::read_to_string(path).map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
self.lua.load(code.as_str()).exec()
}
/// Clear all registered event hooks (call before re-executing the config
/// file on hot-reload so that callbacks do not accumulate).
pub fn clear_hooks(&self) {
if let Ok(hooks) = self.lua.create_table() {
let _ = self.lua.globals().set("_winiterm_hooks", hooks);
}
}
/// Dispatch an event to all registered callbacks, passing `arg` as the
/// single string argument.
pub fn dispatch(&self, event: &str, arg: &str) {
let hooks: LuaTable = match self.lua.globals().get("_winiterm_hooks") {
Ok(t) => t,
Err(_) => return,
};
let list: LuaTable = match hooks.get::<Option<LuaTable>>(event) {
Ok(Some(t)) => t,
_ => return,
};
for cb in list.sequence_values::<LuaFunction>() {
if let Ok(f) = cb {
let _ = f.call::<()>(arg.to_owned());
}
}
}
}
impl Default for LuaApi {
fn default() -> Self {
Self::new().expect("failed to create Lua state")
}
}
+77
View File
@@ -0,0 +1,77 @@
//! winiterm — GPU-accelerated terminal emulator for Windows.
//!
//! Declared as a Windows GUI application so that Windows does not allocate or
//! attach a console to this process on startup. Without this attribute the
//! process would inherit cargo's console, which causes `STATUS_DLL_INIT_FAILED`
//! (0xC0000142) in the ConPTY child process (e.g. pwsh.exe) because the
//! ConHost.exe backing the pseudo-console conflicts with the already-attached
//! parent console.
//!
//! Even with `windows_subsystem = "windows"`, launching from an existing
//! terminal (bash, cmd, Windows Terminal) causes the process to *inherit* the
//! parent's console handle. `FreeConsole` releases that handle before any
//! ConPTY is created, so child processes never see a conflicting console.
//! Calling it when there is no console (double-click launch) is a safe no-op.
//!
//! Logs are written to `%APPDATA%\winiterm\winiterm.log` so they are always
//! accessible regardless of how the binary was launched. Set `RUST_LOG` to
//! override the default `info` level (e.g. `RUST_LOG=debug`).
#![windows_subsystem = "windows"]
fn main() {
// Log to a file rather than stderr: for a windows_subsystem = "windows"
// binary the stderr handle is often not connected to anything useful when
// launched from PowerShell or by double-clicking.
init_logging();
// Detach from any inherited console before creating ConPTY children.
// This prevents STATUS_DLL_INIT_FAILED (0xC0000142) regardless of how
// the binary was launched. File handles (including the log file) are
// unaffected by FreeConsole.
#[cfg(target_os = "windows")]
// SAFETY: FreeConsole has no preconditions and is always safe to call.
unsafe {
let _ = windows::Win32::System::Console::FreeConsole();
}
winiterm::app::App::run();
}
/// Initialise `env_logger` to write to `%APPDATA%\winiterm\winiterm.log`.
///
/// The directory is created if it does not exist. Falls back to stderr if
/// the file cannot be opened. Default level is `info`; set `RUST_LOG` to
/// override (e.g. `RUST_LOG=debug` or `RUST_LOG=winiterm=trace`).
fn init_logging() {
let log_path = {
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let dir = std::path::PathBuf::from(appdata).join("winiterm");
let _ = std::fs::create_dir_all(&dir);
dir.join("winiterm.log")
};
let mut builder = env_logger::Builder::new();
// Default level: info. Honour RUST_LOG if set.
builder.filter_level(log::LevelFilter::Info);
if let Ok(spec) = std::env::var("RUST_LOG") {
builder.parse_filters(&spec);
}
match std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&log_path)
{
Ok(file) => {
builder.target(env_logger::Target::Pipe(Box::new(file)));
}
Err(_) => {
builder.target(env_logger::Target::Stderr);
}
}
builder.init();
log::info!("winiterm {} starting", env!("CARGO_PKG_VERSION"));
log::info!("log: {}", log_path.display());
}
+109
View File
@@ -0,0 +1,109 @@
//! A single terminal pane: owns a `Terminal`, `Parser`, and `Pty`.
//!
//! `TerminalPane` is the unit of state for one shell session. Multiple panes
//! can be laid out in a split tree inside a `Workspace` (Phase 4).
use crate::pty::{Pty, PtyError};
use crate::terminal::Terminal;
use crate::vt_parser::Parser;
// ─── TerminalPane ─────────────────────────────────────────────────────────────
pub struct TerminalPane {
pub id: usize,
pub terminal: Terminal,
pub parser: Parser,
pub pty: Option<Pty>,
/// Window title set by the shell (OSC 0/2).
pub title: String,
/// How many lines the user has scrolled back into the scrollback buffer.
/// 0 = at the live bottom of output.
pub scroll_offset: usize,
}
impl TerminalPane {
/// Spawn a new pane with a child shell process.
pub fn spawn(
id: usize,
shell: Option<&str>,
cols: u16,
rows: u16,
scrollback: usize,
) -> Result<Self, PtyError> {
let pty = Pty::spawn(shell, cols, rows)?;
Ok(Self {
id,
terminal: Terminal::new(cols as usize, rows as usize, scrollback),
parser: Parser::new(),
pty: Some(pty),
title: "shell".to_string(),
scroll_offset: 0,
})
}
/// Drain the PTY output channel and feed bytes through the VT parser.
///
/// Returns `true` if any data was processed (i.e. a redraw is needed).
pub fn process_output(&mut self) -> bool {
// Collect all pending chunks without holding &self.pty across the
// mutable borrow of self.parser / self.terminal.
let chunks: Vec<Vec<u8>> = self
.pty
.as_ref()
.and_then(|p| p.data_receiver.as_ref())
.map(|rx| {
let mut v = Vec::new();
while let Ok(data) = rx.try_recv() {
v.push(data);
}
v
})
.unwrap_or_default();
let dirty = !chunks.is_empty();
for chunk in chunks {
self.parser.parse(&chunk, &mut self.terminal);
}
// Flush any VT response bytes queued by the terminal (DSR, DA1, etc.).
let responses: Vec<Vec<u8>> = self.terminal.pending_responses.drain(..).collect();
for r in responses {
self.write(&r);
}
// Sync the title from the terminal model (set by OSC 0/2).
if !self.terminal.title.is_empty() && self.terminal.title != self.title {
self.title.clone_from(&self.terminal.title);
}
dirty
}
/// Write raw bytes to the shell's stdin.
pub fn write(&self, data: &[u8]) {
if let Some(pty) = &self.pty {
let _ = pty.write(data);
}
}
/// Resize the terminal grid and the pseudo console.
pub fn resize(&mut self, cols: u16, rows: u16) {
self.terminal.resize(cols as usize, rows as usize);
if let Some(pty) = &self.pty {
let _ = pty.resize(cols, rows);
}
}
/// Returns `true` if the child process has exited.
pub fn is_dead(&self) -> bool {
let dead = self.pty.as_ref().map_or(true, |p| p.child_exited());
if dead {
log::info!(
"pane {} is_dead=true (pty_present={})",
self.id,
self.pty.is_some()
);
}
dead
}
}
+27
View File
@@ -0,0 +1,27 @@
//! PTY (Pseudo Terminal) abstraction.
//!
//! On Windows we use the ConPTY API introduced in Windows 10 1809
//! (`CreatePseudoConsole` / `ResizePseudoConsole` / `ClosePseudoConsole`).
mod windows;
pub use windows::Pty;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PtyError {
#[error("Windows API error: {0}")]
Windows(#[from] ::windows::core::Error),
#[error("Failed to create pseudo console: {0}")]
CreateConsole(String),
#[error("Failed to spawn shell process: {0}")]
SpawnProcess(String),
#[error("Failed to resize PTY: {0}")]
Resize(String),
#[error("I/O error: {0}")]
Io(String),
}
+394
View File
@@ -0,0 +1,394 @@
//! ConPTY-based PTY for Windows.
//!
//! Architecture:
//! - `Pty::spawn` creates two anonymous pipes, a pseudo console, and a child process.
//! - A dedicated OS thread reads from the child's stdout pipe and sends chunks via
//! an `mpsc` channel so the caller can process output asynchronously.
//! - Writing to the child's stdin uses the standard `std::io::Write` trait to avoid
//! dependency on windows-rs ReadFile/WriteFile (which moved between crate versions).
// `spawn_inner` is a large unsafe function full of Win32 API calls; the body
// is intentionally entirely unsafe, so we suppress the Rust 2024 lint that
// requires explicit `unsafe {}` blocks inside `unsafe fn`.
#![allow(unsafe_op_in_unsafe_fn)]
use super::PtyError;
use std::io::{Read, Write};
use std::os::windows::io::FromRawHandle;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::thread;
use windows::core::PWSTR;
use windows::Win32::Foundation::{CloseHandle, BOOL, HANDLE, INVALID_HANDLE_VALUE};
use windows::Win32::Security::SECURITY_ATTRIBUTES;
use windows::Win32::System::Console::{
ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON,
};
use windows::Win32::System::Pipes::CreatePipe;
use windows::Win32::System::Threading::{
CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess,
InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute,
EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_CREATION_FLAGS,
PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW,
};
/// `UpdateProcThreadAttribute` attribute identifier for pseudo consoles.
///
/// Computed as `ProcThreadAttributeValue(22, FALSE, TRUE, FALSE)`:
/// `(22 & 0xFFFF) | (1 << 17)` = `0x00020016`.
const PROC_THREAD_ATTR_PSEUDOCONSOLE: usize = 0x0002_0016;
/// Win32 STILL_ACTIVE process exit code (259 = 0x103).
const STILL_ACTIVE: u32 = 259;
// ─── Handle wrappers ────────────────────────────────────────────────────────
/// Wraps a Win32 `HANDLE` so it can be sent across threads.
///
/// # Safety
/// The caller must guarantee the handle is not used from more than one thread
/// concurrently without external synchronisation.
struct SendHandle(HANDLE);
unsafe impl Send for SendHandle {}
unsafe impl Sync for SendHandle {}
impl SendHandle {
#[inline]
fn get(&self) -> HANDLE {
self.0
}
}
/// RAII wrapper that calls `ClosePseudoConsole` on drop.
struct PseudoConsole(HPCON);
unsafe impl Send for PseudoConsole {}
impl Drop for PseudoConsole {
fn drop(&mut self) {
unsafe {
ClosePseudoConsole(self.0);
}
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
/// A pseudo terminal backed by the Windows ConPTY API.
pub struct Pty {
/// Pseudo console handle (closed on drop via `PseudoConsole`).
hpc: PseudoConsole,
/// Write end of the stdin pipe — send data here to reach the child's stdin.
stdin_write: SendHandle,
/// Child process handle.
process_handle: SendHandle,
/// Process ID of the spawned shell.
pub process_id: u32,
/// Receives output chunks produced by the child process.
/// Populated by the background reader thread.
pub data_receiver: Option<Receiver<Vec<u8>>>,
}
impl Pty {
/// Spawn a shell inside a new pseudo console of `cols × rows` cells.
///
/// `shell` — path to the shell executable.
/// Pass `None` to auto-detect: `pwsh` → `powershell` → `cmd`.
pub fn spawn(shell: Option<&str>, cols: u16, rows: u16) -> Result<Self, PtyError> {
// SAFETY: All ConPTY invariants are upheld below.
unsafe { Self::spawn_inner(shell, cols, rows) }
}
unsafe fn spawn_inner(shell: Option<&str>, cols: u16, rows: u16) -> Result<Self, PtyError> {
// ── Pipe pair for stdin (terminal writes → child reads) ──────────────
let mut stdin_read = HANDLE::default();
let mut stdin_write = HANDLE::default();
CreatePipe(
&mut stdin_read,
&mut stdin_write,
None::<*const SECURITY_ATTRIBUTES>,
0,
)
.map_err(PtyError::Windows)?;
// ── Pipe pair for stdout (child writes → terminal reads) ─────────────
let mut stdout_read = HANDLE::default();
let mut stdout_write = HANDLE::default();
if let Err(e) = CreatePipe(
&mut stdout_read,
&mut stdout_write,
None::<*const SECURITY_ATTRIBUTES>,
0,
) {
let _ = CloseHandle(stdin_read);
let _ = CloseHandle(stdin_write);
return Err(PtyError::Windows(e));
}
// ── Create the pseudo console ────────────────────────────────────────
// windows-rs 0.58: CreatePseudoConsole(size, hinput, houtput, dwFlags) -> Result<HPCON>
let size = COORD {
X: cols as i16,
Y: rows as i16,
};
let hpc = match CreatePseudoConsole(size, stdin_read, stdout_write, 0) {
Ok(h) => h,
Err(e) => {
let _ = CloseHandle(stdin_read);
let _ = CloseHandle(stdin_write);
let _ = CloseHandle(stdout_read);
let _ = CloseHandle(stdout_write);
return Err(PtyError::CreateConsole(e.to_string()));
}
};
// The pseudo console now owns stdin_read and stdout_write.
let _ = CloseHandle(stdin_read);
let _ = CloseHandle(stdout_write);
// ── Build STARTUPINFOEXW with the pseudo console attribute ────────────
let mut attr_list_size = 0usize;
// First call: get required buffer size (expectedly "fails").
let _ = InitializeProcThreadAttributeList(
LPPROC_THREAD_ATTRIBUTE_LIST::default(),
1,
0,
&mut attr_list_size,
);
let mut attr_buf = vec![0u8; attr_list_size];
let attr_list = LPPROC_THREAD_ATTRIBUTE_LIST(attr_buf.as_mut_ptr() as *mut _);
InitializeProcThreadAttributeList(attr_list, 1, 0, &mut attr_list_size).map_err(|e| {
PtyError::SpawnProcess(format!("InitializeProcThreadAttributeList: {e}"))
})?;
// Attach pseudo console to the attribute list.
//
// For PROC_THREAD_ATTR_PSEUDOCONSOLE the Windows kernel uses lpValue
// as the HPCON handle VALUE directly (it does not dereference it).
// Pass hpc.0 (the raw ConHost handle) — NOT &hpc (address of our
// local variable). Passing the wrong value here is what causes
// STATUS_DLL_INIT_FAILED (0xC0000142) in the child: the child's
// kernel32 console init receives a bogus "stack address" as the HPCON,
// fails to connect to ConHost, and DllMain returns FALSE.
// Reference: every ConPTY C++ sample (including Microsoft's own) and
// Rust implementations (alacritty, wezterm) pass the handle value.
UpdateProcThreadAttribute(
attr_list,
0,
PROC_THREAD_ATTR_PSEUDOCONSOLE,
Some(hpc.0 as *const std::ffi::c_void),
std::mem::size_of::<HPCON>(),
None,
None,
)
.map_err(|e| PtyError::SpawnProcess(format!("UpdateProcThreadAttribute: {e}")))?;
let mut si_ex: STARTUPINFOEXW = std::mem::zeroed();
si_ex.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
si_ex.lpAttributeList = attr_list;
// Explicitly tell Windows NOT to inherit the parent's standard handles.
// Without this, the child's DLL init connects to the parent's console
// handles at the same time the ConPTY's conhost.exe is connecting, which
// causes STATUS_DLL_INIT_FAILED (0xC0000142). The ConPTY attribute
// (PROC_THREAD_ATTR_PSEUDOCONSOLE) handles all console I/O independently.
si_ex.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
si_ex.StartupInfo.hStdInput = INVALID_HANDLE_VALUE;
si_ex.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE;
si_ex.StartupInfo.hStdError = INVALID_HANDLE_VALUE;
// ── Resolve shell command line ────────────────────────────────────────
let shell_path = match shell {
Some(s) => {
log::info!("shell: using config override {:?}", s);
s.to_string()
}
None => detect_shell().unwrap_or_else(|| "cmd.exe".to_string()),
};
let mut command_line: Vec<u16> = shell_path
.encode_utf16()
.chain(std::iter::once(0u16))
.collect();
// ── Spawn the shell process ───────────────────────────────────────────
let mut pi: PROCESS_INFORMATION = std::mem::zeroed();
let creation_flags = PROCESS_CREATION_FLAGS(EXTENDED_STARTUPINFO_PRESENT.0);
log::info!("spawning shell: {:?} cols={cols} rows={rows}", shell_path);
if let Err(e) = CreateProcessW(
None,
PWSTR(command_line.as_mut_ptr()),
None::<*const SECURITY_ATTRIBUTES>,
None::<*const SECURITY_ATTRIBUTES>,
BOOL(0),
creation_flags,
None,
None,
&si_ex.StartupInfo as *const STARTUPINFOW,
&mut pi,
) {
DeleteProcThreadAttributeList(attr_list);
let _ = CloseHandle(stdin_write);
let _ = CloseHandle(stdout_read);
return Err(PtyError::SpawnProcess(format!("CreateProcessW: {e}")));
}
DeleteProcThreadAttributeList(attr_list);
// We don't need the thread handle.
let _ = CloseHandle(pi.hThread);
let process_id = pi.dwProcessId;
let process_handle = pi.hProcess;
// ── Start background reader thread ────────────────────────────────────
let (tx, rx) = channel::<Vec<u8>>();
let stdout_send = SendHandle(stdout_read);
thread::Builder::new()
.name("winiterm-pty-reader".to_string())
.spawn(move || {
// Transfer ownership of stdout_read into this thread.
// std::fs::File takes ownership and closes the handle on drop.
let mut file = std::fs::File::from_raw_handle(stdout_send.get().0 as _);
pty_read_loop(&mut file, tx);
// file dropped here → handle closed
})
.map_err(|e| PtyError::Io(e.to_string()))?;
Ok(Self {
hpc: PseudoConsole(hpc),
stdin_write: SendHandle(stdin_write),
process_handle: SendHandle(process_handle),
process_id,
data_receiver: Some(rx),
})
}
/// Write `data` to the child process's stdin.
///
/// Uses `std::io::Write` on a non-owning `File` view of the handle,
/// then calls `forget` to prevent the `File` from closing it.
pub fn write(&self, data: &[u8]) -> Result<(), PtyError> {
if data.is_empty() {
return Ok(());
}
// SAFETY: stdin_write is a valid writable handle for the lifetime of Pty.
// We use forget() to prevent File::drop from closing it.
let mut file = unsafe { std::fs::File::from_raw_handle(self.stdin_write.get().0 as _) };
let result = file
.write_all(data)
.map_err(|e| PtyError::Io(e.to_string()));
std::mem::forget(file);
result
}
/// Resize the pseudo console to `cols × rows` cells.
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), PtyError> {
unsafe {
ResizePseudoConsole(
self.hpc.0,
COORD {
X: cols as i16,
Y: rows as i16,
},
)
.map_err(|e| PtyError::Resize(e.to_string()))?;
}
Ok(())
}
/// Returns `true` if the child process has exited.
pub fn child_exited(&self) -> bool {
let mut exit_code = 0u32;
unsafe {
match GetExitCodeProcess(self.process_handle.get(), &mut exit_code) {
Ok(()) => {
if exit_code != STILL_ACTIVE {
log::info!("child_exited: exit_code={exit_code}");
true
} else {
false
}
}
Err(e) => {
log::info!("child_exited: GetExitCodeProcess error: {e}");
true
}
}
}
}
/// Take the data receiver out of this `Pty`.
pub fn take_receiver(&mut self) -> Option<Receiver<Vec<u8>>> {
self.data_receiver.take()
}
}
impl Drop for Pty {
fn drop(&mut self) {
unsafe {
let _ = TerminateProcess(self.process_handle.get(), 0);
let _ = CloseHandle(self.stdin_write.get());
let _ = CloseHandle(self.process_handle.get());
// hpc is closed by PseudoConsole::drop.
}
}
}
// ─── Internal helpers ────────────────────────────────────────────────────────
/// Blocking read loop — runs on the dedicated PTY reader thread.
fn pty_read_loop(file: &mut std::fs::File, sender: Sender<Vec<u8>>) {
let mut buf = vec![0u8; 8192];
loop {
match file.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if sender.send(buf[..n].to_vec()).is_err() {
break; // Receiver was dropped — stop reading.
}
}
}
}
}
/// Auto-detect the preferred shell.
///
/// `cmd.exe` is always preferred: it is a plain Win32 binary with no complex
/// DLL initialisation. `pwsh.exe` and `powershell.exe` load the .NET / CLR
/// runtime during DLL init, which can fail with `STATUS_DLL_INIT_FAILED`
/// (0xC0000142) inside a ConPTY. Users who prefer PowerShell can set
/// `shell = "pwsh.exe"` (or the full path) in their `config.lua`.
///
/// We look for `cmd.exe` via its guaranteed path (`%SystemRoot%\System32\`)
/// rather than `where.exe` so that the check succeeds even in minimal PATH
/// environments and even after `FreeConsole()` detaches the parent console.
fn detect_shell() -> Option<String> {
// Primary: guaranteed path for cmd.exe.
if let Ok(root) = std::env::var("SystemRoot") {
let path = format!(r"{root}\System32\cmd.exe");
if std::path::Path::new(&path).exists() {
log::info!("detect_shell: using {}", path);
return Some(path);
}
}
// Fallback: PATH-based search for Win32 shells only (no pwsh / .NET).
for candidate in &["cmd.exe", "powershell.exe"] {
if std::process::Command::new("where")
.arg(candidate)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
log::info!("detect_shell: found {} via PATH", candidate);
return Some(candidate.to_string());
}
}
log::warn!("detect_shell: all candidates failed, defaulting to cmd.exe");
Some("cmd.exe".to_string())
}
+187
View File
@@ -0,0 +1,187 @@
//! Glyph atlas: font discovery, rasterisation, and UV lookup.
//!
//! Uses [`fontdb`] to locate the best available monospace font and [`swash`]
//! to rasterise glyphs into a single R8-greyscale texture atlas.
//!
//! Layout: the atlas is a uniform grid of `cell_width × cell_height` slots.
//! Each slot holds exactly one character; unused slots are zeroed.
use std::collections::HashMap;
// ─── Atlas ───────────────────────────────────────────────────────────────────
/// Greyscale (R8) glyph texture atlas for a monospace font.
pub struct GlyphAtlas {
/// Raw R8 pixel data (one byte per pixel).
pub data: Vec<u8>,
/// Atlas dimensions in pixels.
pub width: u32,
pub height: u32,
/// Dimensions of each cell slot.
pub cell_width: u32,
pub cell_height: u32,
/// Distance from the top of a cell to the text baseline (pixels).
pub baseline: u32,
/// Number of cell slots per atlas row.
cols_per_row: u32,
/// Char → (slot_column, slot_row).
entries: HashMap<char, (u32, u32)>,
/// Index of the next free slot (for future on-demand rasterisation).
next_slot: u32,
}
impl GlyphAtlas {
const ATLAS_W: u32 = 1024;
const ATLAS_H: u32 = 1024;
/// Build an atlas at `font_size` physical pixels using the best available
/// monospace system font.
pub fn build(font_size: f32) -> Self {
let mut db = fontdb::Database::new();
db.load_system_fonts();
let query = fontdb::Query {
families: &[
fontdb::Family::Name("Cascadia Code"),
fontdb::Family::Name("Cascadia Mono"),
fontdb::Family::Name("Consolas"),
fontdb::Family::Name("Courier New"),
fontdb::Family::Monospace,
],
..Default::default()
};
let id = db
.query(&query)
.expect("no monospace font found on this system");
db.with_face_data(id, |data, face_index| {
Self::build_from_data(data, face_index as usize, font_size)
})
.expect("font face data unavailable")
}
fn build_from_data(font_data: &[u8], face_index: usize, font_size: f32) -> Self {
use swash::scale::{Render, ScaleContext, Source};
use swash::zeno::Format;
let font =
swash::FontRef::from_index(font_data, face_index).expect("invalid font face index");
// ── Metrics ──────────────────────────────────────────────────────────
let metrics = font.metrics(&[]).scale(font_size);
let cell_width = metrics.average_width.ceil().max(1.0) as u32;
let ascent = metrics.ascent.ceil() as u32;
// `descent` is typically negative; take the magnitude.
let descent = metrics.descent.abs().ceil() as u32;
let cell_height = ascent + descent;
let baseline = ascent;
let cols_per_row = Self::ATLAS_W / cell_width;
let mut data = vec![0u8; (Self::ATLAS_W * Self::ATLAS_H) as usize];
let mut entries = HashMap::new();
let mut next_slot = 0u32;
// ── Unicode ranges to pre-rasterise ──────────────────────────────────
//
// These ranges cover everything a modern terminal is likely to render:
// • Basic Latin (printable ASCII)
// • Latin-1 Supplement + Extended-A (accented European characters)
// • Box Drawing, Block Elements, Geometric Shapes (TUI apps: htop,
// vim, tmux, ncurses borders)
// • Powerline Symbols (oh-my-zsh, starship, powerline prompts)
const RASTERIZE_RANGES: &[(u32, u32)] = &[
(0x0020, 0x007E), // Basic Latin (printable ASCII)
(0x00A0, 0x00FF), // Latin-1 Supplement
(0x0100, 0x017F), // Latin Extended-A
(0x2500, 0x257F), // Box Drawing
(0x2580, 0x259F), // Block Elements
(0x25A0, 0x25FF), // Geometric Shapes
(0xE0A0, 0xE0D4), // Powerline Symbols
];
let charmap = font.charmap();
let mut ctx = ScaleContext::new();
let mut scaler = ctx.builder(font).size(font_size).hint(true).build();
for &(range_start, range_end) in RASTERIZE_RANGES {
for codepoint in range_start..=range_end {
let ch = match char::from_u32(codepoint) {
Some(c) => c,
None => continue,
};
let glyph_id = charmap.map(ch);
let slot_col = next_slot % cols_per_row;
let slot_row = next_slot / cols_per_row;
entries.insert(ch, (slot_col, slot_row));
next_slot += 1;
// Abort if we've run out of atlas space (should never happen
// with the current ranges at any reasonable font size).
if (slot_row + 1) * cell_height > Self::ATLAS_H {
break;
}
let image = Render::new(&[Source::Outline])
.format(Format::Alpha)
.render(&mut scaler, glyph_id);
if let Some(img) = image {
let p = &img.placement;
// Pixel position of the glyph image's top-left corner within
// the atlas (accounts for left-bearing and baseline offset).
let origin_x = (slot_col * cell_width) as i32 + p.left;
let origin_y = (slot_row * cell_height) as i32 + (baseline as i32 - p.top);
for gy in 0..p.height as i32 {
for gx in 0..p.width as i32 {
let ax = origin_x + gx;
let ay = origin_y + gy;
if ax >= 0
&& ay >= 0
&& (ax as u32) < Self::ATLAS_W
&& (ay as u32) < Self::ATLAS_H
{
let src = (gy * p.width as i32 + gx) as usize;
let dst = (ay as u32 * Self::ATLAS_W + ax as u32) as usize;
if src < img.data.len() {
data[dst] = img.data[src];
}
}
}
}
}
}
}
Self {
data,
width: Self::ATLAS_W,
height: Self::ATLAS_H,
cell_width,
cell_height,
baseline,
cols_per_row,
entries,
next_slot,
}
}
/// Return `(u_min, v_min, u_size, v_size)` for `ch` in normalised UV space.
///
/// Falls back to the space character for any unmapped codepoint.
pub fn glyph_uv(&self, ch: char) -> (f32, f32, f32, f32) {
let (col, row) = self
.entries
.get(&ch)
.or_else(|| self.entries.get(&' '))
.copied()
.unwrap_or((0, 0));
let u = (col * self.cell_width) as f32 / self.width as f32;
let v = (row * self.cell_height) as f32 / self.height as f32;
let uw = self.cell_width as f32 / self.width as f32;
let vh = self.cell_height as f32 / self.height as f32;
(u, v, uw, vh)
}
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
// ─── Uniforms ────────────────────────────────────────────────────────────────
//
// Screen size in physical pixels. Padded to 16 bytes for uniform alignment.
struct Uniforms {
screen_size: vec2<f32>,
_pad: vec2<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var atlas_tex: texture_2d<f32>;
@group(0) @binding(2) var atlas_smp: sampler;
// ─── Vertex stage ─────────────────────────────────────────────────────────────
//
// Buffer 0 (step: Vertex): quad corners in unit space [(0,0)..(1,1)]
// Buffer 1 (step: Instance): per-cell data
struct VertexInput {
@location(0) pos: vec2<f32>,
}
struct InstanceInput {
@location(1) cell_pos: vec2<f32>, // top-left pixel coord of the cell
@location(2) cell_size: vec2<f32>, // cell width × height in pixels
@location(3) uv_offset: vec2<f32>, // atlas UV top-left (0..1)
@location(4) uv_size: vec2<f32>, // atlas UV width × height (0..1)
@location(5) fg_color: vec4<f32>, // foreground RGBA (sRGB linear)
@location(6) bg_color: vec4<f32>, // background RGBA (sRGB linear)
@location(7) attrs: u32, // CellAttrs bits (underline, strikethrough, …)
}
struct VertexOutput {
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>, // atlas texture UV
@location(1) fg_color: vec4<f32>,
@location(2) bg_color: vec4<f32>,
@location(3) cell_uv: vec2<f32>, // unit quad position (0..1 within cell)
@location(4) @interpolate(flat) attrs: u32, // cell attribute bits (flat — no interpolation)
}
@vertex
fn vs_main(vert: VertexInput, inst: InstanceInput) -> VertexOutput {
var out: VertexOutput;
// Transform unit quad → cell in pixel-space, then → NDC.
let px = inst.cell_pos + vert.pos * inst.cell_size;
let clip_x = (px.x / uniforms.screen_size.x) * 2.0 - 1.0;
let clip_y = -(px.y / uniforms.screen_size.y) * 2.0 + 1.0;
out.clip_pos = vec4<f32>(clip_x, clip_y, 0.0, 1.0);
out.uv = inst.uv_offset + vert.pos * inst.uv_size;
out.fg_color = inst.fg_color;
out.bg_color = inst.bg_color;
out.cell_uv = vert.pos; // (0,0) = top-left, (1,1) = bottom-right of cell
out.attrs = inst.attrs;
return out;
}
// ─── Fragment stage ───────────────────────────────────────────────────────────
//
// The atlas is R8Unorm grayscale coverage.
// alpha = 0 → pure background, alpha = 1 → pure foreground.
//
// CellAttrs bit masks (must match Rust CellAttrs bitflags):
// UNDERLINE = 1u << 3u = 8u
// UNDERLINE2 = 1u << 4u = 16u (double underline)
// STRIKETHROUGH = 1u << 8u = 256u
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let coverage = textureSample(atlas_tex, atlas_smp, in.uv).r;
var color = mix(in.bg_color, in.fg_color, coverage);
let y = in.cell_uv.y;
// Underline: solid bar at bottom ~14% of cell.
if (in.attrs & 8u) != 0u && y > 0.84 {
color = in.fg_color;
}
// Double underline: two thin bars near the bottom.
if (in.attrs & 16u) != 0u && ((y > 0.74 && y < 0.80) || (y > 0.88 && y < 0.94)) {
color = in.fg_color;
}
// Strikethrough: horizontal bar at ~50% cell height.
if (in.attrs & 256u) != 0u && y > 0.44 && y < 0.54 {
color = in.fg_color;
}
return color;
}
// ─── Kitty Graphics Protocol fragment stage ───────────────────────────────────
//
// For Kitty images the texture is Rgba8Unorm; output all four channels directly.
@fragment
fn fs_kitty_main(in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(atlas_tex, atlas_smp, in.uv);
}
+189
View File
@@ -0,0 +1,189 @@
//! Pane split-tree and pixel-layout (Phase 4).
//!
//! A `SplitNode` is either a `Leaf` (one pane ID) or a `Split` that divides
//! its allocated `Rect` between two child nodes. Calling `layout()` on the
//! root node with the full window rect produces a `HashMap<pane_id, Rect>`.
use std::collections::HashMap;
// ─── Direction ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitDir {
/// Divide horizontally: left pane | right pane.
Horizontal,
/// Divide vertically: top pane / bottom pane.
Vertical,
}
// ─── Pixel rectangle ─────────────────────────────────────────────────────────
/// An axis-aligned rectangle in physical pixel coordinates.
#[derive(Debug, Clone, Copy)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl Rect {
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Self { x, y, w, h }
}
#[inline]
pub fn cell_cols(&self, cell_w: f32) -> u16 {
(self.w / cell_w).floor().max(2.0) as u16
}
#[inline]
pub fn cell_rows(&self, cell_h: f32) -> u16 {
(self.h / cell_h).floor().max(2.0) as u16
}
}
// ─── Split node ───────────────────────────────────────────────────────────────
pub enum SplitNode {
Leaf(usize), // pane_id
Split {
dir: SplitDir,
/// Fraction [0,1] allocated to `first`.
ratio: f32,
first: Box<SplitNode>,
second: Box<SplitNode>,
},
}
impl SplitNode {
// ── Layout ───────────────────────────────────────────────────────────────
/// Recursively assign a pixel `Rect` to every leaf pane.
pub fn layout(&self, rect: Rect, out: &mut HashMap<usize, Rect>) {
match self {
SplitNode::Leaf(id) => {
out.insert(*id, rect);
}
SplitNode::Split {
dir,
ratio,
first,
second,
} => {
let (r1, r2) = split_rect(rect, *dir, *ratio);
first.layout(r1, out);
second.layout(r2, out);
}
}
}
// ── Queries ───────────────────────────────────────────────────────────────
/// All pane IDs in tree order (depth-first).
pub fn all_panes(&self) -> Vec<usize> {
match self {
SplitNode::Leaf(id) => vec![*id],
SplitNode::Split { first, second, .. } => {
let mut v = first.all_panes();
v.extend(second.all_panes());
v
}
}
}
/// The first leaf ID in tree order.
pub fn first_pane(&self) -> usize {
match self {
SplitNode::Leaf(id) => *id,
SplitNode::Split { first, .. } => first.first_pane(),
}
}
// ── Mutations ────────────────────────────────────────────────────────────
/// Insert a new split at the `target_id` leaf, placing the new pane as
/// the second child. Returns `true` if `target_id` was found.
pub fn split_at(&mut self, target_id: usize, dir: SplitDir, new_id: usize) -> bool {
match self {
SplitNode::Leaf(id) if *id == target_id => {
let old = SplitNode::Leaf(target_id);
*self = SplitNode::Split {
dir,
ratio: 0.5,
first: Box::new(old),
second: Box::new(SplitNode::Leaf(new_id)),
};
true
}
SplitNode::Leaf(_) => false,
SplitNode::Split { first, second, .. } => {
first.split_at(target_id, dir, new_id) || second.split_at(target_id, dir, new_id)
}
}
}
/// Remove the leaf `target_id` and collapse its parent split into the
/// surviving sibling. The surviving sibling is written into `*self`.
/// Returns `true` if removed.
pub fn remove(&mut self, target_id: usize) -> bool {
let replaced = match self {
SplitNode::Split { first, second, .. } => {
// Check if first is the target leaf.
if matches!(first.as_ref(), SplitNode::Leaf(id) if *id == target_id) {
Some(true) // replace self with second
} else if matches!(second.as_ref(), SplitNode::Leaf(id) if *id == target_id) {
Some(false) // replace self with first
} else {
None
}
}
_ => None,
};
match replaced {
Some(take_second) => {
let inner = match std::mem::replace(self, SplitNode::Leaf(0)) {
SplitNode::Split { first, second, .. } => {
if take_second {
*second
} else {
*first
}
}
other => other,
};
*self = inner;
true
}
None => match self {
SplitNode::Split { first, second, .. } => {
first.remove(target_id) || second.remove(target_id)
}
_ => false,
},
}
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
fn split_rect(rect: Rect, dir: SplitDir, ratio: f32) -> (Rect, Rect) {
match dir {
SplitDir::Horizontal => {
let w1 = (rect.w * ratio).floor();
let w2 = rect.w - w1;
(
Rect::new(rect.x, rect.y, w1, rect.h),
Rect::new(rect.x + w1, rect.y, w2, rect.h),
)
}
SplitDir::Vertical => {
let h1 = (rect.h * ratio).floor();
let h2 = rect.h - h1;
(
Rect::new(rect.x, rect.y, rect.w, h1),
Rect::new(rect.x, rect.y + h1, rect.w, h2),
)
}
}
}
+991
View File
@@ -0,0 +1,991 @@
//! Terminal state: grid, cursor, scrollback, and all VT operations.
//!
//! The `Terminal` struct is the authoritative model of what is displayed.
//! The VT parser drives it by calling public methods in response to escape
//! sequences. The renderer (Phase 3) reads it to produce GPU draw calls.
use bitflags::bitflags;
use std::collections::VecDeque;
use crate::kitty::KittyRegistry;
// ─── Color ───────────────────────────────────────────────────────────────────
/// A terminal color: default (inherits theme), a 256-color palette index, or
/// a 24-bit RGB value.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Color {
#[default]
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
// ─── Cell attributes ─────────────────────────────────────────────────────────
bitflags! {
/// Text rendering attributes packed into a single `u16`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct CellAttrs: u16 {
const BOLD = 1 << 0;
const DIM = 1 << 1;
const ITALIC = 1 << 2;
const UNDERLINE = 1 << 3;
const UNDERLINE2 = 1 << 4; // double underline
const BLINK = 1 << 5;
const REVERSE = 1 << 6;
const INVISIBLE = 1 << 7;
const STRIKETHROUGH = 1 << 8;
const WIDE = 1 << 9; // occupies two columns (CJK / wide emoji)
const WIDE_CONT = 1 << 10; // second column of a wide character
}
}
// ─── Cell ────────────────────────────────────────────────────────────────────
/// A single terminal grid cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub fg: Color,
pub bg: Color,
pub attrs: CellAttrs,
}
impl Cell {
/// A blank cell with the given colors/attributes.
#[inline]
pub const fn new(ch: char, fg: Color, bg: Color, attrs: CellAttrs) -> Self {
Self { ch, fg, bg, attrs }
}
/// Blank cell using default colors.
#[inline]
pub const fn blank() -> Self {
Self::new(' ', Color::Default, Color::Default, CellAttrs::empty())
}
}
impl Default for Cell {
fn default() -> Self {
Self::blank()
}
}
// ─── Cursor ──────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CursorShape {
#[default]
Block,
Beam,
Underline,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Cursor {
pub col: usize,
pub row: usize,
pub shape: CursorShape,
}
// ─── Terminal modes ───────────────────────────────────────────────────────────
bitflags! {
#[derive(Clone, Copy, Debug, Default)]
pub struct TerminalModes: u32 {
/// DECAWM — auto-wrap at end of line.
const AUTO_WRAP = 1 << 0;
/// DECTCEM — cursor visible.
const CURSOR_VIS = 1 << 1;
/// DECSET 1049 — alternate screen active.
const ALT_SCREEN = 1 << 2;
/// XTerm mouse tracking (any mode active).
const MOUSE = 1 << 3;
/// DECSET 2004 — bracketed paste.
const BRACKET_PASTE = 1 << 4;
/// DECSET 1004 — focus events.
const FOCUS_EVENTS = 1 << 5;
/// DECSET 25 / DECTCEM — separate flag mirroring CURSOR_VIS for clarity.
const INSERT_MODE = 1 << 6;
/// DECCKM — application cursor keys (SS3 instead of CSI for arrows).
const DECCKM = 1 << 7;
}
}
// ─── Kitty Graphics ──────────────────────────────────────────────────────────
/// A placed Kitty image: the image is rendered at cell `(col, row)`.
#[derive(Debug, Clone)]
pub struct KittyPlacement {
pub image_id: u32,
pub col: usize,
pub row: usize,
pub pixel_width: u32,
pub pixel_height: u32,
}
// ─── Terminal ────────────────────────────────────────────────────────────────
/// The complete state of a terminal emulator instance.
pub struct Terminal {
pub cols: usize,
pub rows: usize,
/// Active screen buffer, row-major: `screen[row * cols + col]`.
pub screen: Vec<Cell>,
/// Scrollback lines; oldest at index 0, newest at the back.
pub scrollback: VecDeque<Vec<Cell>>,
/// Maximum number of scrollback lines to retain.
pub scrollback_lines: usize,
// ── Cursor ───────────────────────────────────────────────────────────────
pub cursor: Cursor,
/// DECSC / DECRC saved cursor.
saved_cursor: Option<Cursor>,
/// When `true` the cursor is logically *past* the last column and will wrap
/// before the next printable character is written.
pub pending_wrap: bool,
// ── Alternate screen ─────────────────────────────────────────────────────
alt_screen: Option<Vec<Cell>>,
alt_cursor: Option<Cursor>,
// ── Scroll region (0-indexed, inclusive) ─────────────────────────────────
pub scroll_top: usize,
pub scroll_bottom: usize,
// ── Current SGR state applied to newly written cells ─────────────────────
pub current_fg: Color,
pub current_bg: Color,
pub current_attrs: CellAttrs,
// ── Miscellaneous ─────────────────────────────────────────────────────────
pub modes: TerminalModes,
pub title: String,
/// Hardware tab stops (one bool per column).
tab_stops: Vec<bool>,
// ── Mouse tracking mode ───────────────────────────────────────────────────
pub mouse_mode: u16,
// ── Pending VT responses (DSR, DA1) ──────────────────────────────────────
/// Bytes queued to be written back to the PTY (device status responses, etc.)
pub pending_responses: Vec<Vec<u8>>,
// ── Kitty Graphics Protocol ───────────────────────────────────────────────
/// Received and assembled Kitty images, keyed by image ID.
pub kitty: KittyRegistry,
/// Images that have been placed on screen (from `a=T` or `a=p` actions).
pub kitty_placements: Vec<KittyPlacement>,
/// Image IDs that were deleted by `a=d` since the last renderer frame.
/// The renderer drains this list each frame to evict GPU textures.
pub kitty_deleted_ids: Vec<u32>,
}
impl Terminal {
/// Create a new terminal with the given dimensions and scrollback capacity.
pub fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Self {
let cell_count = cols * rows;
let mut tab_stops = vec![false; cols];
// Default tab stop every 8 columns.
for i in (0..cols).step_by(8) {
tab_stops[i] = true;
}
let mut modes = TerminalModes::empty();
modes.insert(TerminalModes::AUTO_WRAP);
modes.insert(TerminalModes::CURSOR_VIS);
Self {
cols,
rows,
screen: vec![Cell::blank(); cell_count],
scrollback: VecDeque::new(),
scrollback_lines,
cursor: Cursor::default(),
saved_cursor: None,
pending_wrap: false,
alt_screen: None,
alt_cursor: None,
scroll_top: 0,
scroll_bottom: rows.saturating_sub(1),
current_fg: Color::Default,
current_bg: Color::Default,
current_attrs: CellAttrs::empty(),
modes,
title: String::new(),
tab_stops,
mouse_mode: 0,
pending_responses: Vec::new(),
kitty: KittyRegistry::new(),
kitty_placements: Vec::new(),
kitty_deleted_ids: Vec::new(),
}
}
// ─── Resize ──────────────────────────────────────────────────────────────
/// Resize the terminal to `cols × rows`, preserving as much content as possible.
pub fn resize(&mut self, cols: usize, rows: usize) {
if cols == self.cols && rows == self.rows {
return;
}
let mut new_screen = vec![Cell::blank(); cols * rows];
let copy_cols = self.cols.min(cols);
let copy_rows = self.rows.min(rows);
for r in 0..copy_rows {
for c in 0..copy_cols {
new_screen[r * cols + c] = self.screen[r * self.cols + c];
}
}
self.screen = new_screen;
self.cols = cols;
self.rows = rows;
self.scroll_top = 0;
self.scroll_bottom = rows.saturating_sub(1);
self.tab_stops.resize(cols, false);
for i in (0..cols).step_by(8) {
if !self.tab_stops[i] {
self.tab_stops[i] = true;
}
}
// Clamp cursor.
self.cursor.col = self.cursor.col.min(cols.saturating_sub(1));
self.cursor.row = self.cursor.row.min(rows.saturating_sub(1));
self.pending_wrap = false;
}
// ─── Cell accessors ───────────────────────────────────────────────────────
#[inline]
pub fn cell(&self, col: usize, row: usize) -> &Cell {
&self.screen[row * self.cols + col]
}
#[inline]
pub fn cell_mut(&mut self, col: usize, row: usize) -> &mut Cell {
&mut self.screen[row * self.cols + col]
}
// ─── Write a character ────────────────────────────────────────────────────
/// Place `ch` at the cursor position and advance.
///
/// Wide characters (CJK, wide emoji — display width 2) occupy two columns:
/// the character is placed in the first column with `WIDE` set, and a blank
/// placeholder with `WIDE_CONT` is written to the second column.
///
/// Zero-width / combining characters are placed on the current column
/// without advancing the cursor.
pub fn write_char(&mut self, ch: char) {
use unicode_width::UnicodeWidthChar;
// Resolve pending wrap before writing.
if self.pending_wrap {
self.pending_wrap = false;
self.do_linefeed();
self.cursor.col = 0;
}
let width = ch.width().unwrap_or(1);
if width == 0 {
// Combining / zero-width: overlay on the current (or previous) cell.
let col = self.cursor.col.min(self.cols.saturating_sub(1));
let row = self.cursor.row.min(self.rows.saturating_sub(1));
self.cell_mut(col, row).ch = ch;
return;
}
let col = self.cursor.col.min(self.cols.saturating_sub(1));
let row = self.cursor.row.min(self.rows.saturating_sub(1));
if width == 2 && col + 1 < self.cols {
// Wide character: write char in first column, blank continuation in second.
let mut attrs = self.current_attrs;
attrs.insert(CellAttrs::WIDE);
*self.cell_mut(col, row) = Cell {
ch,
fg: self.current_fg,
bg: self.current_bg,
attrs,
};
let mut cont_attrs = self.current_attrs;
cont_attrs.insert(CellAttrs::WIDE_CONT);
*self.cell_mut(col + 1, row) = Cell {
ch: ' ',
fg: self.current_fg,
bg: self.current_bg,
attrs: cont_attrs,
};
if col + 2 >= self.cols {
if self.modes.contains(TerminalModes::AUTO_WRAP) {
self.pending_wrap = true;
}
} else {
self.cursor.col = col + 2;
}
} else {
// Normal (or wide that doesn't fit) — write as single cell.
*self.cell_mut(col, row) = Cell {
ch,
fg: self.current_fg,
bg: self.current_bg,
attrs: self.current_attrs,
};
if col + 1 >= self.cols {
if self.modes.contains(TerminalModes::AUTO_WRAP) {
self.pending_wrap = true;
}
} else {
self.cursor.col = col + 1;
}
}
}
// ─── C0 control characters ────────────────────────────────────────────────
/// Execute a C0 control byte.
pub fn execute_c0(&mut self, byte: u8) {
match byte {
0x07 => {} // BEL — ignore (audio bell could be added later)
0x08 => {
// BS — backspace
if self.cursor.col > 0 {
self.cursor.col -= 1;
self.pending_wrap = false;
}
}
0x09 => self.advance_tab(),
0x0a | 0x0b | 0x0c => {
// LF, VT, FF — line feed
self.do_linefeed();
}
0x0d => {
// CR
self.cursor.col = 0;
self.pending_wrap = false;
}
_ => {}
}
}
/// Advance cursor to the next tab stop.
fn advance_tab(&mut self) {
let start = self.cursor.col + 1;
for col in start..self.cols {
if self.tab_stops[col] {
self.cursor.col = col;
return;
}
}
self.cursor.col = self.cols.saturating_sub(1);
}
// ─── Line feed / scrolling ────────────────────────────────────────────────
/// Perform a line feed within the scroll region.
fn do_linefeed(&mut self) {
if self.cursor.row == self.scroll_bottom {
self.scroll_up(1);
} else {
self.cursor.row = (self.cursor.row + 1).min(self.rows.saturating_sub(1));
}
self.pending_wrap = false;
}
/// Reverse index (scroll down when cursor is at the top of the scroll region).
pub fn reverse_index(&mut self) {
if self.cursor.row == self.scroll_top {
self.scroll_down(1);
} else if self.cursor.row > 0 {
self.cursor.row -= 1;
}
}
/// Scroll the contents of the scroll region up by `n` lines.
/// Lines scrolled off the top are pushed into the scrollback buffer.
pub fn scroll_up(&mut self, n: usize) {
let n = n.min(self.scroll_bottom - self.scroll_top + 1);
for _ in 0..n {
// Push top line of the scroll region into scrollback (only when region = full screen).
if self.scroll_top == 0 {
let top: Vec<Cell> = self.screen[..self.cols].to_vec();
if self.scrollback.len() >= self.scrollback_lines {
self.scrollback.pop_front();
}
self.scrollback.push_back(top);
}
// Shift rows up.
for r in self.scroll_top..self.scroll_bottom {
let src = (r + 1) * self.cols;
let dst = r * self.cols;
self.screen.copy_within(src..src + self.cols, dst);
}
// Clear the bottom row of the scroll region.
let bottom_start = self.scroll_bottom * self.cols;
let bg = self.current_bg;
let fg = self.current_fg;
for cell in &mut self.screen[bottom_start..bottom_start + self.cols] {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
}
/// Scroll the scroll region down by `n` lines.
pub fn scroll_down(&mut self, n: usize) {
let n = n.min(self.scroll_bottom - self.scroll_top + 1);
for _ in 0..n {
for r in (self.scroll_top + 1..=self.scroll_bottom).rev() {
let src = (r - 1) * self.cols;
let dst = r * self.cols;
self.screen.copy_within(src..src + self.cols, dst);
}
// Clear the top row of the scroll region.
let top_start = self.scroll_top * self.cols;
let bg = self.current_bg;
let fg = self.current_fg;
for cell in &mut self.screen[top_start..top_start + self.cols] {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
}
// ─── Cursor movement (CSI sequences) ─────────────────────────────────────
/// CUP / HVP — move cursor to `(row, col)` (0-indexed).
pub fn cursor_pos(&mut self, row: usize, col: usize) {
let max_row = if self.modes.contains(TerminalModes::AUTO_WRAP) {
self.rows.saturating_sub(1)
} else {
self.rows.saturating_sub(1)
};
self.cursor.row = row.min(max_row);
self.cursor.col = col.min(self.cols.saturating_sub(1));
self.pending_wrap = false;
}
/// CUU — cursor up `n`.
pub fn cursor_up(&mut self, n: usize) {
self.cursor.row = self.cursor.row.saturating_sub(n).max(self.scroll_top);
self.pending_wrap = false;
}
/// CUD — cursor down `n`.
pub fn cursor_down(&mut self, n: usize) {
self.cursor.row = (self.cursor.row + n).min(self.scroll_bottom);
self.pending_wrap = false;
}
/// CUF — cursor forward (right) `n`.
pub fn cursor_forward(&mut self, n: usize) {
self.cursor.col = (self.cursor.col + n).min(self.cols.saturating_sub(1));
self.pending_wrap = false;
}
/// CUB — cursor back (left) `n`.
pub fn cursor_back(&mut self, n: usize) {
self.cursor.col = self.cursor.col.saturating_sub(n);
self.pending_wrap = false;
}
/// CNL — cursor next line `n`.
pub fn cursor_next_line(&mut self, n: usize) {
self.cursor.row = (self.cursor.row + n).min(self.rows.saturating_sub(1));
self.cursor.col = 0;
self.pending_wrap = false;
}
/// CPL — cursor previous line `n`.
pub fn cursor_prev_line(&mut self, n: usize) {
self.cursor.row = self.cursor.row.saturating_sub(n);
self.cursor.col = 0;
self.pending_wrap = false;
}
/// CHA / HPA — cursor horizontal absolute (column, 0-indexed).
pub fn cursor_col(&mut self, col: usize) {
self.cursor.col = col.min(self.cols.saturating_sub(1));
self.pending_wrap = false;
}
/// VPA — vertical position absolute (row, 0-indexed).
pub fn cursor_row(&mut self, row: usize) {
self.cursor.row = row.min(self.rows.saturating_sub(1));
self.pending_wrap = false;
}
// ─── Erase operations ─────────────────────────────────────────────────────
/// ED — erase in display.
///
/// `mode`: 0 = from cursor to end, 1 = from start to cursor, 2 = whole screen,
/// 3 = whole screen + scrollback.
pub fn erase_in_display(&mut self, mode: u16) {
let col = self.cursor.col;
let row = self.cursor.row;
let fg = self.current_fg;
let bg = self.current_bg;
match mode {
0 => {
// From cursor to end of screen.
let start = row * self.cols + col;
for cell in &mut self.screen[start..] {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
1 => {
// From start of screen to cursor.
let end = row * self.cols + col + 1;
for cell in &mut self.screen[..end] {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
2 | 3 => {
// Whole screen.
for cell in &mut self.screen {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
if mode == 3 {
self.scrollback.clear();
}
}
_ => {}
}
}
/// EL — erase in line.
///
/// `mode`: 0 = from cursor to end, 1 = from start to cursor, 2 = whole line.
pub fn erase_in_line(&mut self, mode: u16) {
let col = self.cursor.col;
let row = self.cursor.row;
let fg = self.current_fg;
let bg = self.current_bg;
let row_start = row * self.cols;
match mode {
0 => {
for c in col..self.cols {
self.screen[row_start + c] = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
1 => {
for c in 0..=col {
self.screen[row_start + c] = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
2 => {
for c in 0..self.cols {
self.screen[row_start + c] = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
_ => {}
}
}
/// ECH — erase `n` characters starting at the cursor.
pub fn erase_chars(&mut self, n: usize) {
let row = self.cursor.row;
let col = self.cursor.col;
let end = (col + n).min(self.cols);
let fg = self.current_fg;
let bg = self.current_bg;
let row_start = row * self.cols;
for c in col..end {
self.screen[row_start + c] = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
// ─── Insert / Delete ──────────────────────────────────────────────────────
/// ICH — insert `n` blank characters at cursor, shifting right.
pub fn insert_chars(&mut self, n: usize) {
let row = self.cursor.row;
let col = self.cursor.col;
let n = n.min(self.cols - col);
let row_start = row * self.cols;
let row_end = row_start + self.cols;
// Shift chars right.
for c in (col..self.cols - n).rev() {
self.screen[row_start + c + n] = self.screen[row_start + c];
}
let fg = self.current_fg;
let bg = self.current_bg;
for c in col..col + n {
self.screen[row_start + c] = Cell::new(' ', fg, bg, CellAttrs::empty());
}
let _ = row_end; // suppress lint
}
/// DCH — delete `n` characters at cursor, pulling right chars left.
pub fn delete_chars(&mut self, n: usize) {
let row = self.cursor.row;
let col = self.cursor.col;
let n = n.min(self.cols - col);
let row_start = row * self.cols;
for c in col..self.cols - n {
self.screen[row_start + c] = self.screen[row_start + c + n];
}
let fg = self.current_fg;
let bg = self.current_bg;
for c in self.cols - n..self.cols {
self.screen[row_start + c] = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
/// IL — insert `n` blank lines at cursor row.
pub fn insert_lines(&mut self, n: usize) {
let row = self.cursor.row;
// Only insert within the scroll region.
if row < self.scroll_top || row > self.scroll_bottom {
return;
}
let n = n.min(self.scroll_bottom - row + 1);
// Shift rows down (within scroll region).
for r in (row..=self.scroll_bottom - n).rev() {
let src = r * self.cols;
let dst = (r + n) * self.cols;
self.screen.copy_within(src..src + self.cols, dst);
}
let fg = self.current_fg;
let bg = self.current_bg;
for r in row..row + n {
let s = r * self.cols;
for cell in &mut self.screen[s..s + self.cols] {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
self.cursor.col = 0;
}
/// DL — delete `n` lines at cursor row.
pub fn delete_lines(&mut self, n: usize) {
let row = self.cursor.row;
if row < self.scroll_top || row > self.scroll_bottom {
return;
}
let n = n.min(self.scroll_bottom - row + 1);
for r in row..=self.scroll_bottom - n {
let src = (r + n) * self.cols;
let dst = r * self.cols;
self.screen.copy_within(src..src + self.cols, dst);
}
let fg = self.current_fg;
let bg = self.current_bg;
for r in self.scroll_bottom - n + 1..=self.scroll_bottom {
let s = r * self.cols;
for cell in &mut self.screen[s..s + self.cols] {
*cell = Cell::new(' ', fg, bg, CellAttrs::empty());
}
}
self.cursor.col = 0;
}
// ─── SGR (Select Graphic Rendition) ──────────────────────────────────────
/// Process an SGR sequence (CSI `m`). `params` is the flat semicolon-separated
/// parameter list; an empty slice is equivalent to `[0]` (reset).
pub fn process_sgr(&mut self, params: &[u32]) {
if params.is_empty() {
self.sgr_reset();
return;
}
let mut i = 0;
while i < params.len() {
match params[i] {
0 => self.sgr_reset(),
1 => self.current_attrs.insert(CellAttrs::BOLD),
2 => self.current_attrs.insert(CellAttrs::DIM),
3 => self.current_attrs.insert(CellAttrs::ITALIC),
4 => self.current_attrs.insert(CellAttrs::UNDERLINE),
5 | 6 => self.current_attrs.insert(CellAttrs::BLINK),
7 => self.current_attrs.insert(CellAttrs::REVERSE),
8 => self.current_attrs.insert(CellAttrs::INVISIBLE),
9 => self.current_attrs.insert(CellAttrs::STRIKETHROUGH),
21 => self.current_attrs.insert(CellAttrs::UNDERLINE2),
22 => self.current_attrs.remove(CellAttrs::BOLD | CellAttrs::DIM),
23 => self.current_attrs.remove(CellAttrs::ITALIC),
24 => self
.current_attrs
.remove(CellAttrs::UNDERLINE | CellAttrs::UNDERLINE2),
25 => self.current_attrs.remove(CellAttrs::BLINK),
27 => self.current_attrs.remove(CellAttrs::REVERSE),
28 => self.current_attrs.remove(CellAttrs::INVISIBLE),
29 => self.current_attrs.remove(CellAttrs::STRIKETHROUGH),
// Foreground 16 colors
30..=37 => self.current_fg = Color::Indexed((params[i] - 30) as u8),
38 => {
if let Some(color) = self.parse_extended_color(params, &mut i) {
self.current_fg = color;
}
}
39 => self.current_fg = Color::Default,
// Background 16 colors
40..=47 => self.current_bg = Color::Indexed((params[i] - 40) as u8),
48 => {
if let Some(color) = self.parse_extended_color(params, &mut i) {
self.current_bg = color;
}
}
49 => self.current_bg = Color::Default,
// Bright / high-intensity foreground (815)
90..=97 => self.current_fg = Color::Indexed((params[i] - 90 + 8) as u8),
// Bright / high-intensity background (815)
100..=107 => self.current_bg = Color::Indexed((params[i] - 100 + 8) as u8),
_ => {}
}
i += 1;
}
}
fn sgr_reset(&mut self) {
self.current_fg = Color::Default;
self.current_bg = Color::Default;
self.current_attrs = CellAttrs::empty();
}
/// Parse an extended color (38;5;n or 38;2;r;g;b) starting *after* `params[i]`.
/// Returns the color and advances `i` by the number of consumed extra params.
fn parse_extended_color(&self, params: &[u32], i: &mut usize) -> Option<Color> {
if *i + 1 >= params.len() {
return None;
}
match params[*i + 1] {
5 if *i + 2 < params.len() => {
*i += 2;
Some(Color::Indexed(params[*i] as u8))
}
2 if *i + 4 < params.len() => {
*i += 4;
Some(Color::Rgb(
params[*i - 2] as u8,
params[*i - 1] as u8,
params[*i] as u8,
))
}
_ => None,
}
}
// ─── Scroll region ────────────────────────────────────────────────────────
/// DECSTBM — set scrolling region, `top` and `bottom` are 0-indexed.
pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {
let bottom = bottom.min(self.rows.saturating_sub(1));
if top < bottom {
self.scroll_top = top;
self.scroll_bottom = bottom;
}
// Reset cursor to home on region change.
self.cursor_pos(0, 0);
}
// ─── Save / Restore cursor ────────────────────────────────────────────────
/// DECSC — save cursor.
pub fn save_cursor(&mut self) {
self.saved_cursor = Some(self.cursor);
}
/// DECRC — restore cursor.
pub fn restore_cursor(&mut self) {
if let Some(c) = self.saved_cursor {
self.cursor = c;
self.cursor.col = self.cursor.col.min(self.cols.saturating_sub(1));
self.cursor.row = self.cursor.row.min(self.rows.saturating_sub(1));
self.pending_wrap = false;
}
}
// ─── Alternate screen ─────────────────────────────────────────────────────
/// Switch to or from the alternate screen (DECSET/DECRST 1049).
pub fn set_alt_screen(&mut self, enable: bool) {
let currently_alt = self.modes.contains(TerminalModes::ALT_SCREEN);
if enable == currently_alt {
return;
}
if enable {
// Save main screen + cursor, switch to blank alt screen.
self.alt_screen = Some(self.screen.clone());
self.alt_cursor = Some(self.cursor);
self.screen = vec![Cell::blank(); self.cols * self.rows];
self.cursor = Cursor::default();
self.pending_wrap = false;
self.modes.insert(TerminalModes::ALT_SCREEN);
} else {
// Restore main screen.
if let Some(s) = self.alt_screen.take() {
self.screen = s;
}
if let Some(c) = self.alt_cursor.take() {
self.cursor = c;
}
self.pending_wrap = false;
self.modes.remove(TerminalModes::ALT_SCREEN);
}
}
// ─── Mode setting (DECSET / DECRST) ──────────────────────────────────────
/// Apply a DEC private mode set (`?h`) or reset (`?l`).
pub fn set_dec_mode(&mut self, mode: u16, value: bool) {
match mode {
1 => self.modes.set(TerminalModes::DECCKM, value), // DECCKM — application cursor keys
6 => {} // DECOM — origin mode (simplified: ignore)
7 => {
self.modes.set(TerminalModes::AUTO_WRAP, value);
}
12 => {} // DECSCNM / cursor blink
25 => {
self.modes.set(TerminalModes::CURSOR_VIS, value);
}
1000 | 1002 | 1003 => {
self.modes.set(TerminalModes::MOUSE, value);
self.mouse_mode = if value { mode } else { 0 };
}
1004 => {
self.modes.set(TerminalModes::FOCUS_EVENTS, value);
}
1049 => self.set_alt_screen(value),
2004 => {
self.modes.set(TerminalModes::BRACKET_PASTE, value);
}
_ => {}
}
}
/// Apply an ANSI mode set (`h`) or reset (`l`).
pub fn set_ansi_mode(&mut self, mode: u16, value: bool) {
match mode {
4 => {
self.modes.set(TerminalModes::INSERT_MODE, value);
}
20 => {} // LNM — linefeed / newline mode
_ => {}
}
}
// ─── Window title ─────────────────────────────────────────────────────────
pub fn set_title(&mut self, title: &str) {
self.title = title.to_string();
}
// ─── VT response queue ────────────────────────────────────────────────────
/// DSR 6 — cursor position report: `\x1b[{row};{col}R`.
pub fn cursor_pos_report(&mut self) {
let row = self.cursor.row + 1;
let col = self.cursor.col + 1;
self.pending_responses
.push(format!("\x1b[{row};{col}R").into_bytes());
}
/// DSR 5 — operating status: `\x1b[0n` (device OK).
pub fn operating_status_report(&mut self) {
self.pending_responses.push(b"\x1b[0n".to_vec());
}
/// DA1 — primary device attributes: `\x1b[?1;2c` (VT100 + advanced video).
pub fn device_attrs_report(&mut self) {
self.pending_responses.push(b"\x1b[?1;2c".to_vec());
}
// ─── Tab stops ────────────────────────────────────────────────────────────
/// TBC — tab clear. `mode` 0 = clear current stop, 3 = clear all stops.
pub fn clear_tab_stops(&mut self, mode: u16) {
match mode {
0 => {
let col = self.cursor.col;
if col < self.tab_stops.len() {
self.tab_stops[col] = false;
}
}
3 => {
for t in &mut self.tab_stops {
*t = false;
}
}
_ => {}
}
}
/// HTS — set tab stop at current column.
pub fn set_tab_stop(&mut self) {
let col = self.cursor.col;
if col < self.tab_stops.len() {
self.tab_stops[col] = true;
}
}
/// CBT — cursor backward tab `n` stops.
pub fn cursor_backward_tab(&mut self, n: usize) {
let mut count = n;
let mut col = self.cursor.col;
while col > 0 && count > 0 {
col -= 1;
if self.tab_stops[col] {
count -= 1;
}
}
self.cursor.col = col;
self.pending_wrap = false;
}
// ─── Reset ────────────────────────────────────────────────────────────────
/// RIS — full reset (ESC c).
pub fn full_reset(&mut self) {
let cols = self.cols;
let rows = self.rows;
let sl = self.scrollback_lines;
*self = Self::new(cols, rows, sl);
}
// ─── Kitty Graphics Protocol ──────────────────────────────────────────────
/// Process the payload of a Kitty Graphics Protocol APC sequence.
///
/// `payload` is everything after the leading `'G'` byte that distinguishes
/// a Kitty APC from other SOS/PM/APC sequences. The caller (the VT parser)
/// is responsible for stripping that leading byte.
///
/// When the action (`a=T` or `a=p`) requires placing an image and the final
/// chunk has arrived, a [`KittyPlacement`] is pushed at the current cursor
/// position.
pub fn dispatch_apc(&mut self, payload: &[u8]) {
let ctrl = crate::kitty::parse_header(payload);
let action = ctrl.action.unwrap_or('t'); // default = transmit only
let id = ctrl.image_id.unwrap_or(0);
// Delete action: evict the image and mark GPU texture for removal.
if action == 'd' {
if id != 0 {
self.kitty.remove(id);
self.kitty_placements.retain(|p| p.image_id != id);
self.kitty_deleted_ids.push(id);
}
return;
}
// Ingest this chunk (handles multi-chunk assembly internally).
self.kitty.ingest(payload);
// If this is the final chunk and the action requires display, place it.
if !ctrl.more && matches!(action, 'p' | 'T') {
if let Some(img) = self.kitty.get(id) {
self.kitty_placements.push(KittyPlacement {
image_id: id,
col: self.cursor.col,
row: self.cursor.row,
pixel_width: img.width,
pixel_height: img.height,
});
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! System tray icon (Phase 11).
//!
//! Creates a persistent system-tray presence using the `tray-icon` crate.
//! The tray icon lets the user restore the winiterm window after it has been
//! hidden (e.g. by closing it while `tray_icon = true` is set in the Lua
//! config).
//!
//! # Right-click menu (D2)
//!
//! A context menu with two items is attached:
//! * **Show winiterm** — brings the window back to the foreground.
//! * **Quit** — terminates the process cleanly.
//!
//! # Event model
//!
//! We use the simple polling model: `poll_events` is called from winit's
//! `about_to_wait` hook and reads from the crossbeam channels exposed by
//! `TrayIconEvent::receiver()` (left-clicks) and `muda::MenuEvent::receiver()`
//! (context-menu item clicks).
use muda::{Menu, MenuEvent, MenuId, MenuItem};
use tray_icon::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent};
// ─── TrayAction ───────────────────────────────────────────────────────────────
/// The action that the caller should take after a tray event.
pub enum TrayAction {
/// Make the main window visible and bring it to the foreground.
Show,
/// Exit the application.
Quit,
}
// ─── TrayHandle ───────────────────────────────────────────────────────────────
/// Opaque handle to a live system tray icon.
///
/// The inner [`TrayIcon`] keeps the OS tray entry alive for as long as this
/// value exists. Drop it to remove the icon from the system tray.
pub struct TrayHandle {
_icon: TrayIcon,
/// The menu-item ID of the "Quit" entry, used to match `MenuEvent`s.
quit_id: MenuId,
}
// ─── Public API ───────────────────────────────────────────────────────────────
/// Create a system tray icon with a solid-cyan 16 × 16 default icon and a
/// right-click context menu containing "Show winiterm" and "Quit" items.
///
/// Returns `None` if the tray icon could not be created (e.g. because no
/// system tray is available, or the underlying platform call failed).
pub fn create_tray_icon() -> Option<TrayHandle> {
// Build the right-click context menu.
let menu = Menu::new();
let show_item = MenuItem::new("Show winiterm", true, None);
let quit_item = MenuItem::new("Quit", true, None);
let quit_id = quit_item.id().clone();
let _ = menu.append(&show_item);
let _ = menu.append(&quit_item);
let icon = make_default_icon()?;
let tray = TrayIconBuilder::new()
.with_tooltip("winiterm")
.with_icon(icon)
.with_menu(Box::new(menu))
.build()
.map_err(|e| log::warn!("tray icon creation failed: {e}"))
.ok()?;
log::info!("system tray icon created");
Some(TrayHandle {
_icon: tray,
quit_id,
})
}
/// Poll for pending tray events without blocking.
///
/// Returns `Some(TrayAction)` when a relevant event occurred, or `None` when
/// the queues are empty.
///
/// Priority: left-click (show) is checked first, then the context-menu channel.
pub fn poll_events(tray: &TrayHandle) -> Option<TrayAction> {
// Left-click on the tray icon → show window.
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if let TrayIconEvent::Click {
button: MouseButton::Left,
..
} = event
{
return Some(TrayAction::Show);
}
}
// Context-menu item clicks.
while let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == tray.quit_id {
return Some(TrayAction::Quit);
} else {
// Any other item (currently just "Show winiterm") → show window.
return Some(TrayAction::Show);
}
}
None
}
// ─── Icon helper ──────────────────────────────────────────────────────────────
/// Generate a plain 16 × 16 RGBA icon filled with the winiterm brand colour
/// (a bright cyan, #1EB3FF).
fn make_default_icon() -> Option<tray_icon::Icon> {
const W: u32 = 16;
const H: u32 = 16;
// Draw a rounded-ish "W" shape in cyan on a dark background.
let mut rgba = vec![0u8; (W * H * 4) as usize];
for y in 0..H {
for x in 0..W {
let i = ((y * W + x) * 4) as usize;
// Background: dark navy
rgba[i] = 0x10; // R
rgba[i + 1] = 0x14; // G
rgba[i + 2] = 0x1e; // B
rgba[i + 3] = 0xff; // A
// "W" glyph pattern: two outer legs + a valley + two inner legs
// (pixel art at 16 × 16)
let in_w = matches!(
(x, y),
// top row
(1, 2) | (2, 2) | (13, 2) | (14, 2) |
// left leg descending
(2, 3) | (2, 4) | (2, 5) | (2, 6) | (2, 7) |
(3, 7) | (3, 8) |
// right leg descending
(13, 3) | (13, 4) | (13, 5) | (13, 6) | (13, 7) |
(12, 7) | (12, 8) |
// inner left leg ascending
(4, 9) | (4, 10) | (5, 10) | (5, 11) |
// inner right leg ascending
(11, 9) | (11, 10) | (10, 10) | (10, 11) |
// valley bottom
(6, 11) | (7, 11) | (8, 11) | (9, 11) |
// base closure
(1, 3) | (14, 3)
);
if in_w {
rgba[i] = 0x1e; // R
rgba[i + 1] = 0xb3; // G
rgba[i + 2] = 0xff; // B
}
}
}
tray_icon::Icon::from_rgba(rgba, W, H)
.map_err(|e| log::warn!("tray icon pixel data invalid: {e}"))
.ok()
}
+680
View File
@@ -0,0 +1,680 @@
//! VT/ANSI escape-sequence parser.
//!
//! Implements a state machine based on Paul Williams' VT100/VT500 parser:
//! <https://vt100.net/emu/dec_ansi_parser>
//!
//! The parser processes raw bytes and dispatches semantic actions directly to
//! a [`Terminal`]. It is deliberately zero-allocation on the hot path; OSC and
//! DCS buffers are `Vec<u8>` but only extended on unusual sequences.
use crate::terminal::Terminal;
// ─── State ────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Ground,
Escape,
EscapeIntermediate,
CsiEntry,
CsiParam,
CsiIntermediate,
CsiIgnore,
OscString,
OscStringSt, // received ESC inside OSC, waiting for '\' to complete ST
DcsEntry,
DcsPassthrough,
DcsIgnore,
SosPmApcString,
SosPmApcStringSt, // received ESC inside SosPmApcString, waiting for '\'
}
// ─── Parser ───────────────────────────────────────────────────────────────────
/// Maximum number of CSI parameters we track.
const MAX_PARAMS: usize = 32;
/// Maximum number of CSI intermediate bytes.
const MAX_INTERMEDIATES: usize = 4;
/// Stateful VT/ANSI byte-stream parser.
pub struct Parser {
state: State,
/// Raw parameter values. Parameters are `u32` so that very large values
/// (e.g. OSC colour indices) can be accumulated without truncation.
params: [u32; MAX_PARAMS],
/// Number of valid entries in `params`.
param_count: usize,
/// Digit accumulator for the parameter currently being parsed.
current_param: u32,
/// Optional DEC-private marker byte: one of `?`, `<`, `=`, `>`.
private_marker: u8,
/// Intermediate bytes collected during an escape/CSI sequence.
intermediates: [u8; MAX_INTERMEDIATES],
intermediate_count: usize,
/// OSC string accumulation buffer.
osc_buf: Vec<u8>,
/// DCS / APC string accumulation buffer (ignored in Phase 2).
dcs_buf: Vec<u8>,
// ── UTF-8 multi-byte decoder ────────────────────────────────────────────
utf8_buf: [u8; 4],
utf8_idx: usize,
utf8_needed: usize,
}
impl Parser {
pub fn new() -> Self {
Self {
state: State::Ground,
params: [0; MAX_PARAMS],
param_count: 0,
current_param: 0,
private_marker: 0,
intermediates: [0; MAX_INTERMEDIATES],
intermediate_count: 0,
osc_buf: Vec::new(),
dcs_buf: Vec::new(),
utf8_buf: [0; 4],
utf8_idx: 0,
utf8_needed: 0,
}
}
/// Feed `data` through the parser, dispatching actions to `terminal`.
#[inline]
pub fn parse(&mut self, data: &[u8], terminal: &mut Terminal) {
for &byte in data {
self.process_byte(byte, terminal);
}
}
// ─── Per-byte dispatch ────────────────────────────────────────────────────
#[inline]
fn process_byte(&mut self, byte: u8, terminal: &mut Terminal) {
// ── State-independent C0 transitions ─────────────────────────────────
// CAN (0x18) and SUB (0x1a): cancel current sequence → Ground.
if byte == 0x18 || byte == 0x1a {
self.state = State::Ground;
return;
}
match self.state {
State::Ground => self.ground(byte, terminal),
State::Escape => self.escape(byte, terminal),
State::EscapeIntermediate => self.escape_intermediate(byte, terminal),
State::CsiEntry => self.csi_entry(byte, terminal),
State::CsiParam => self.csi_param(byte, terminal),
State::CsiIntermediate => self.csi_intermediate(byte, terminal),
State::CsiIgnore => self.csi_ignore(byte),
State::OscString => self.osc_string(byte, terminal),
State::OscStringSt => self.osc_string_st(byte, terminal),
State::DcsEntry | State::DcsPassthrough | State::DcsIgnore => {
self.dcs(byte);
}
State::SosPmApcString => self.sos_pm_apc(byte, terminal),
State::SosPmApcStringSt => self.sos_pm_apc_st(byte, terminal),
}
}
// ─── State handlers ───────────────────────────────────────────────────────
fn ground(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
// C0 controls
0x00..=0x08 | 0x0e..=0x1a | 0x1c..=0x1f => {
terminal.execute_c0(byte);
}
0x09 | 0x0a | 0x0b | 0x0c | 0x0d => {
terminal.execute_c0(byte);
}
0x1b => {
self.reset_params();
self.state = State::Escape;
}
0x7f => {} // DEL — ignore
// ASCII printable
0x20..=0x7e => {
terminal.write_char(byte as char);
}
// UTF-8 multi-byte sequence start
0xc0..=0xdf => self.utf8_start(byte, 2),
0xe0..=0xef => self.utf8_start(byte, 3),
0xf0..=0xf7 => self.utf8_start(byte, 4),
// UTF-8 continuation or invalid — try to continue accumulation
0x80..=0xbf => self.utf8_continue(byte, terminal),
_ => {}
}
}
fn escape(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
// Enter CSI
b'[' => {
self.reset_params();
self.state = State::CsiEntry;
}
// Enter OSC
b']' => {
self.osc_buf.clear();
self.state = State::OscString;
}
// Enter DCS
b'P' => {
self.dcs_buf.clear();
self.state = State::DcsEntry;
}
// SOS / PM / APC (all absorbed, APC used by Kitty in a later phase)
b'X' | b'^' | b'_' => {
self.dcs_buf.clear();
self.state = State::SosPmApcString;
}
// String terminator (bare ESC \) — only meaningful inside OSC/DCS
b'\\' => {
self.state = State::Ground;
}
// Intermediate byte (0x200x2f)
0x20..=0x2f => {
self.collect_intermediate(byte);
self.state = State::EscapeIntermediate;
}
// Two-character ESC sequences (final byte 0x300x7e)
0x30..=0x7e => {
self.dispatch_esc(byte, terminal);
self.state = State::Ground;
}
// Nested ESC — re-enter Escape, reset intermediates
0x1b => {
self.reset_params();
}
_ => {
self.state = State::Ground;
}
}
}
fn escape_intermediate(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
0x20..=0x2f => self.collect_intermediate(byte),
0x30..=0x7e => {
self.dispatch_esc(byte, terminal);
self.state = State::Ground;
}
_ => {
self.state = State::Ground;
}
}
}
fn csi_entry(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
// DEC private marker
b'?' | b'<' | b'=' | b'>' => {
self.private_marker = byte;
self.state = State::CsiParam;
}
// Digit / semicolon — start parameter
b'0'..=b'9' => {
self.current_param = (byte - b'0') as u32;
self.state = State::CsiParam;
}
b';' => {
self.push_param();
self.state = State::CsiParam;
}
// Colon sub-parameter: treat as semicolon for now
b':' => {
self.push_param();
self.state = State::CsiParam;
}
// Intermediate bytes
0x20..=0x2f => {
self.collect_intermediate(byte);
self.state = State::CsiIntermediate;
}
// Final byte
0x40..=0x7e => {
self.push_param();
self.dispatch_csi(byte, terminal);
self.state = State::Ground;
}
0x1b => {
self.state = State::Escape;
}
_ => {}
}
}
fn csi_param(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
b'0'..=b'9' => {
self.current_param = self
.current_param
.saturating_mul(10)
.saturating_add((byte - b'0') as u32);
}
b';' | b':' => {
self.push_param();
}
0x20..=0x2f => {
self.push_param();
self.collect_intermediate(byte);
self.state = State::CsiIntermediate;
}
0x40..=0x7e => {
self.push_param();
self.dispatch_csi(byte, terminal);
self.state = State::Ground;
}
// Invalid parameter byte in range 0x3c0x3f while in CsiParam
0x3c..=0x3f => {
self.state = State::CsiIgnore;
}
0x1b => {
self.state = State::Escape;
}
_ => {}
}
}
fn csi_intermediate(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
0x20..=0x2f => self.collect_intermediate(byte),
0x40..=0x7e => {
self.dispatch_csi(byte, terminal);
self.state = State::Ground;
}
0x30..=0x3f => {
self.state = State::CsiIgnore;
}
0x1b => {
self.state = State::Escape;
}
_ => {}
}
}
fn csi_ignore(&mut self, byte: u8) {
if matches!(byte, 0x40..=0x7e) {
self.state = State::Ground;
}
}
fn osc_string(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
0x07 => {
// BEL terminates OSC.
let osc = self.osc_buf.clone();
self.dispatch_osc(&osc, terminal);
self.osc_buf.clear();
self.state = State::Ground;
}
0x1b => {
// ESC: could be start of ST (ESC \).
self.state = State::OscStringSt;
}
0x20..=0xff => {
self.osc_buf.push(byte);
}
_ => {}
}
}
fn osc_string_st(&mut self, byte: u8, terminal: &mut Terminal) {
if byte == b'\\' {
// ESC \ = ST — terminate OSC.
let osc = self.osc_buf.clone();
self.dispatch_osc(&osc, terminal);
self.osc_buf.clear();
}
// Regardless of what follows ESC inside an OSC, return to Ground.
self.state = State::Ground;
}
fn dcs(&mut self, byte: u8) {
// Phase 2: absorb all DCS content.
if byte == 0x07 || (byte == b'\\') {
self.state = State::Ground;
}
}
fn sos_pm_apc(&mut self, byte: u8, terminal: &mut Terminal) {
// Accumulate SOS/PM/APC content; dispatch when ST arrives.
match byte {
// BEL (0x07) — abbreviated string terminator used by some apps.
0x07 => {
self.dispatch_apc_inner(terminal);
self.state = State::Ground;
}
// ESC — could be the start of ST (ESC \).
0x1b => {
self.state = State::SosPmApcStringSt;
}
// Anything else: accumulate.
_ => {
self.dcs_buf.push(byte);
}
}
}
fn sos_pm_apc_st(&mut self, byte: u8, terminal: &mut Terminal) {
match byte {
// ESC \ = ST — dispatch and return to Ground.
b'\\' => {
self.dispatch_apc_inner(terminal);
self.state = State::Ground;
}
// Another ESC: the previous ESC was literal data; push it and stay
// in this state waiting for the actual '\'.
0x1b => {
self.dcs_buf.push(0x1b);
// stay in SosPmApcStringSt
}
// Anything else: the previous ESC was literal data; push both and
// return to accumulating.
_ => {
self.dcs_buf.push(0x1b);
self.dcs_buf.push(byte);
self.state = State::SosPmApcString;
}
}
}
/// Dispatch the accumulated APC buffer, then clear it.
///
/// Only Kitty Graphics Protocol sequences (first byte `'G'`) are handled.
fn dispatch_apc_inner(&mut self, terminal: &mut Terminal) {
if self.dcs_buf.first() == Some(&b'G') {
// Pass everything after the leading 'G' to the terminal's Kitty handler.
terminal.dispatch_apc(&self.dcs_buf[1..]);
}
self.dcs_buf.clear();
}
// ─── UTF-8 multi-byte decoder ─────────────────────────────────────────────
#[inline]
fn utf8_start(&mut self, byte: u8, needed: usize) {
self.utf8_buf[0] = byte;
self.utf8_idx = 1;
self.utf8_needed = needed;
}
#[inline]
fn utf8_continue(&mut self, byte: u8, terminal: &mut Terminal) {
if self.utf8_needed == 0 {
// Stray continuation byte — skip.
return;
}
self.utf8_buf[self.utf8_idx] = byte;
self.utf8_idx += 1;
if self.utf8_idx == self.utf8_needed {
let slice = &self.utf8_buf[..self.utf8_needed];
if let Ok(s) = std::str::from_utf8(slice) {
if let Some(ch) = s.chars().next() {
terminal.write_char(ch);
}
}
self.utf8_needed = 0;
self.utf8_idx = 0;
}
}
// ─── Parameter helpers ────────────────────────────────────────────────────
#[inline]
fn push_param(&mut self) {
if self.param_count < MAX_PARAMS {
self.params[self.param_count] = self.current_param;
self.param_count += 1;
}
self.current_param = 0;
}
#[inline]
fn reset_params(&mut self) {
self.param_count = 0;
self.current_param = 0;
self.private_marker = 0;
self.intermediate_count = 0;
}
#[inline]
fn collect_intermediate(&mut self, byte: u8) {
if self.intermediate_count < MAX_INTERMEDIATES {
self.intermediates[self.intermediate_count] = byte;
self.intermediate_count += 1;
}
}
/// Return the parsed parameter slice (including the last accumulated digit run).
#[inline]
fn params(&self) -> &[u32] {
&self.params[..self.param_count]
}
/// Get parameter at `idx`, defaulting to `default` if absent or zero.
#[inline]
fn param_or(&self, idx: usize, default: u32) -> u32 {
let p = self.params.get(idx).copied().unwrap_or(0);
if p == 0 {
default
} else {
p
}
}
// ─── ESC dispatch ─────────────────────────────────────────────────────────
fn dispatch_esc(&mut self, final_byte: u8, terminal: &mut Terminal) {
let inter = if self.intermediate_count > 0 {
self.intermediates[0]
} else {
0
};
match (inter, final_byte) {
// DECSC — save cursor (ESC 7)
(0, b'7') => terminal.save_cursor(),
// DECRC — restore cursor (ESC 8)
(0, b'8') => terminal.restore_cursor(),
// IND — index (line feed without CR)
(0, b'D') => terminal.execute_c0(0x0a),
// NEL — next line
(0, b'E') => {
terminal.execute_c0(0x0a);
terminal.execute_c0(0x0d);
}
// HTS — set tab stop at current column
(0, b'H') => terminal.set_tab_stop(),
// RI — reverse index
(0, b'M') => terminal.reverse_index(),
// RIS — reset to initial state
(0, b'c') => terminal.full_reset(),
// Character set designations (G0/G1) — accept but ignore
(b'(' | b')' | b'*' | b'+', _) => {}
// DECPAM / DECPNM (application / normal keypad) — ignore in Phase 2
(0, b'=') | (0, b'>') => {}
_ => {}
}
}
// ─── CSI dispatch ─────────────────────────────────────────────────────────
fn dispatch_csi(&mut self, final_byte: u8, terminal: &mut Terminal) {
let priv_marker = self.private_marker;
let inter = if self.intermediate_count > 0 {
self.intermediates[0]
} else {
0
};
let params = self.params();
match (priv_marker, inter, final_byte) {
// ── Cursor movement ───────────────────────────────────────────────
// CUU — cursor up
(0, 0, b'A') => terminal.cursor_up(self.param_or(0, 1) as usize),
// CUD — cursor down
(0, 0, b'B') => terminal.cursor_down(self.param_or(0, 1) as usize),
// CUF — cursor forward (right)
(0, 0, b'C') => terminal.cursor_forward(self.param_or(0, 1) as usize),
// CUB — cursor back (left)
(0, 0, b'D') => terminal.cursor_back(self.param_or(0, 1) as usize),
// CNL — cursor next line
(0, 0, b'E') => terminal.cursor_next_line(self.param_or(0, 1) as usize),
// CPL — cursor previous line
(0, 0, b'F') => terminal.cursor_prev_line(self.param_or(0, 1) as usize),
// CHA / HPA — cursor column absolute (1-based → 0-based)
(0, 0, b'G') | (0, 0, b'`') => {
terminal.cursor_col(self.param_or(0, 1).saturating_sub(1) as usize)
}
// CUP / HVP — cursor position (1-based)
(0, 0, b'H') | (0, 0, b'f') => {
let row = self.param_or(0, 1).saturating_sub(1) as usize;
let col = self.param_or(1, 1).saturating_sub(1) as usize;
terminal.cursor_pos(row, col);
}
// VPA — vertical position absolute (1-based)
(0, 0, b'd') => terminal.cursor_row(self.param_or(0, 1).saturating_sub(1) as usize),
// ── Tab ───────────────────────────────────────────────────────────
// CBT — cursor backward tab
(0, 0, b'Z') => terminal.cursor_backward_tab(self.param_or(0, 1) as usize),
// TBC — tab clear
(0, 0, b'g') => terminal.clear_tab_stops(params.first().copied().unwrap_or(0) as u16),
// ── Erase ─────────────────────────────────────────────────────────
// ED — erase in display
(0, 0, b'J') => terminal.erase_in_display(params.first().copied().unwrap_or(0) as u16),
// EL — erase in line
(0, 0, b'K') => terminal.erase_in_line(params.first().copied().unwrap_or(0) as u16),
// ECH — erase characters
(0, 0, b'X') => terminal.erase_chars(self.param_or(0, 1) as usize),
// ── Insert / Delete ───────────────────────────────────────────────
// ICH — insert characters
(0, 0, b'@') => terminal.insert_chars(self.param_or(0, 1) as usize),
// IL — insert lines
(0, 0, b'L') => terminal.insert_lines(self.param_or(0, 1) as usize),
// DL — delete lines
(0, 0, b'M') => terminal.delete_lines(self.param_or(0, 1) as usize),
// DCH — delete characters
(0, 0, b'P') => terminal.delete_chars(self.param_or(0, 1) as usize),
// ── Scroll ────────────────────────────────────────────────────────
// SU — scroll up
(0, 0, b'S') => terminal.scroll_up(self.param_or(0, 1) as usize),
// SD — scroll down
(0, 0, b'T') => terminal.scroll_down(self.param_or(0, 1) as usize),
// ── SGR ───────────────────────────────────────────────────────────
(0, 0, b'm') => terminal.process_sgr(params),
// ── Scroll region ─────────────────────────────────────────────────
// DECSTBM — set top/bottom margin (1-based)
(0, 0, b'r') => {
let top = self.param_or(0, 1).saturating_sub(1) as usize;
let bottom = self.param_or(1, terminal.rows as u32).saturating_sub(1) as usize;
terminal.set_scroll_region(top, bottom);
}
// ── Save / Restore cursor ─────────────────────────────────────────
(0, 0, b's') => terminal.save_cursor(),
(0, 0, b'u') => terminal.restore_cursor(),
// ── Device status report ──────────────────────────────────────────
// DSR (CSI n) — respond to operating-status (5) and cursor-pos (6) queries.
(0, 0, b'n') => match params.first().copied().unwrap_or(0) {
5 => terminal.operating_status_report(),
6 => terminal.cursor_pos_report(),
_ => {}
},
// ── Device attributes ─────────────────────────────────────────────
// DA1 (CSI c / CSI 0 c) — report VT100 with advanced video option.
(0, 0, b'c') => {
if params.first().copied().unwrap_or(0) == 0 {
terminal.device_attrs_report();
}
}
// ── DEC private modes (? prefix) ──────────────────────────────────
(b'?', 0, b'h') => {
for &p in params {
terminal.set_dec_mode(p as u16, true);
}
}
(b'?', 0, b'l') => {
for &p in params {
terminal.set_dec_mode(p as u16, false);
}
}
// ── ANSI modes ────────────────────────────────────────────────────
(0, 0, b'h') => {
for &p in params {
terminal.set_ansi_mode(p as u16, true);
}
}
(0, 0, b'l') => {
for &p in params {
terminal.set_ansi_mode(p as u16, false);
}
}
// ── Cursor style (DECSCUSR) ───────────────────────────────────────
(0, b' ', b'q') => {
use crate::terminal::CursorShape;
let shape = match params.first().copied().unwrap_or(0) {
1 | 2 => CursorShape::Block,
3 | 4 => CursorShape::Underline,
5 | 6 => CursorShape::Beam,
_ => CursorShape::Block,
};
terminal.cursor.shape = shape;
}
// ── Window manipulation (xterm CSI t) — ignore ────────────────────
(0, 0, b't') => {}
// Anything else: silently ignore.
_ => {}
}
}
// ─── OSC dispatch ─────────────────────────────────────────────────────────
fn dispatch_osc(&self, data: &[u8], terminal: &mut Terminal) {
// Split on the first ';' to separate the command code from the payload.
let Some(sep) = data.iter().position(|&b| b == b';') else {
return;
};
let command_bytes = &data[..sep];
let payload = &data[sep + 1..];
let command: u32 = std::str::from_utf8(command_bytes)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(u32::MAX);
match command {
// OSC 0 / OSC 2 — set window title
0 | 2 => {
if let Ok(title) = std::str::from_utf8(payload) {
terminal.set_title(title);
}
}
// OSC 8 — hyperlinks (Phase 3+)
8 => {}
// OSC 52 — clipboard (Phase 5+, Lua API)
52 => {}
// Everything else is silently ignored.
_ => {}
}
}
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Workspace: tab and pane management (Phase 4).
//!
//! A [`Workspace`] owns a set of [`TerminalPane`]s and organises them into
//! [`Tab`]s, each of which has a [`SplitNode`] tree describing how its panes
//! are laid out on screen.
use std::collections::HashMap;
use crate::pane::TerminalPane;
use crate::split::{Rect, SplitDir, SplitNode};
// ─── Tab ─────────────────────────────────────────────────────────────────────
/// A single tab in the terminal multiplexer.
pub struct Tab {
/// Display name shown in the tab bar.
pub name: String,
/// Split tree for this tab's panes.
pub root: SplitNode,
/// ID of the currently focused pane.
pub active_pane: usize,
}
// ─── Workspace ────────────────────────────────────────────────────────────────
/// Manages all tabs and panes for one winiterm window.
pub struct Workspace {
/// All tabs, in display order.
pub tabs: Vec<Tab>,
/// Index of the currently visible tab.
pub active_tab: usize,
/// All live panes keyed by ID.
panes: HashMap<usize, TerminalPane>,
/// Monotonically increasing ID counter.
next_id: usize,
}
impl Workspace {
// ── Construction ─────────────────────────────────────────────────────────
/// Create a workspace with one initial tab containing one shell pane.
pub fn new(shell: Option<&str>, cols: u16, rows: u16, scrollback: usize) -> Self {
let id = 0usize;
let mut panes = HashMap::new();
match TerminalPane::spawn(id, shell, cols, rows, scrollback) {
Ok(pane) => {
log::info!(
"initial shell spawned (pid={})",
pane.pty.as_ref().map_or(0, |p| p.process_id)
);
panes.insert(id, pane);
}
Err(e) => {
log::error!("failed to spawn initial shell: {e}");
}
}
log::info!("workspace created, pane count = {}", panes.len());
let tab = Tab {
name: "1".to_string(),
root: SplitNode::Leaf(id),
active_pane: id,
};
Self {
tabs: vec![tab],
active_tab: 0,
panes,
next_id: 1,
}
}
// ── Internal helpers ──────────────────────────────────────────────────────
fn current_tab(&self) -> &Tab {
&self.tabs[self.active_tab]
}
fn current_tab_mut(&mut self) -> &mut Tab {
&mut self.tabs[self.active_tab]
}
fn alloc_id(&mut self) -> usize {
let id = self.next_id;
self.next_id += 1;
id
}
// ── Pane accessors ────────────────────────────────────────────────────────
/// The currently focused pane ID.
pub fn active_pane_id(&self) -> usize {
self.current_tab().active_pane
}
/// Reference to the active pane (immutable).
pub fn active_pane(&self) -> Option<&TerminalPane> {
self.panes.get(&self.current_tab().active_pane)
}
/// Reference to the active pane (mutable).
pub fn active_pane_mut(&mut self) -> Option<&mut TerminalPane> {
let id = self.current_tab().active_pane;
self.panes.get_mut(&id)
}
/// Reference to any pane by ID.
pub fn pane(&self, id: usize) -> Option<&TerminalPane> {
self.panes.get(&id)
}
/// Mutable reference to any pane by ID.
pub fn pane_mut(&mut self, id: usize) -> Option<&mut TerminalPane> {
self.panes.get_mut(&id)
}
// ── Tab management ────────────────────────────────────────────────────────
/// Open a new tab with a fresh shell pane.
pub fn new_tab(&mut self, shell: Option<&str>, cols: u16, rows: u16, scrollback: usize) {
let id = self.alloc_id();
match TerminalPane::spawn(id, shell, cols, rows, scrollback) {
Ok(pane) => {
self.panes.insert(id, pane);
}
Err(e) => {
log::error!("failed to spawn shell for new tab: {e}");
return;
}
}
let n = self.tabs.len() + 1;
self.tabs.push(Tab {
name: n.to_string(),
root: SplitNode::Leaf(id),
active_pane: id,
});
self.active_tab = self.tabs.len() - 1;
}
/// Switch to tab at `idx` (silently ignores out-of-bounds).
pub fn switch_tab(&mut self, idx: usize) {
if idx < self.tabs.len() {
self.active_tab = idx;
}
}
/// Close the tab at `idx`, killing all its panes.
///
/// Does nothing if only one tab remains.
pub fn close_tab(&mut self, idx: usize) {
if self.tabs.len() <= 1 {
return;
}
let pane_ids: Vec<usize> = self.tabs[idx].root.all_panes();
for id in pane_ids {
self.panes.remove(&id);
}
self.tabs.remove(idx);
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
}
}
// ── Pane splitting ────────────────────────────────────────────────────────
/// Split the active pane along `dir`, spawning a new shell in the new slot.
pub fn split_active(&mut self, dir: SplitDir, shell: Option<&str>, scrollback: usize) {
let (cols, rows) = {
match self.active_pane() {
Some(p) => ((p.terminal.cols as u16).max(2) / 2, p.terminal.rows as u16),
None => return,
}
};
let new_id = self.alloc_id();
match TerminalPane::spawn(new_id, shell, cols, rows, scrollback) {
Ok(pane) => {
self.panes.insert(new_id, pane);
}
Err(e) => {
log::error!("failed to spawn shell for split: {e}");
return;
}
}
let active_id = self.current_tab().active_pane;
self.current_tab_mut().root.split_at(active_id, dir, new_id);
self.current_tab_mut().active_pane = new_id;
}
/// Close the active pane. If it is the last pane in the tab, closes the
/// tab instead (unless it is the last tab, in which case nothing happens).
pub fn close_active_pane(&mut self) {
let active_id = self.current_tab().active_pane;
let panes_in_tab = self.current_tab().root.all_panes();
if panes_in_tab.len() <= 1 {
let idx = self.active_tab;
self.close_tab(idx);
return;
}
self.panes.remove(&active_id);
self.current_tab_mut().root.remove(active_id);
let new_active = self.current_tab().root.first_pane();
self.current_tab_mut().active_pane = new_active;
}
// ── Focus management ──────────────────────────────────────────────────────
/// Move focus to the next pane in tree order (wraps around).
pub fn focus_next(&mut self) {
let panes = self.current_tab().root.all_panes();
let cur = self.current_tab().active_pane;
if let Some(pos) = panes.iter().position(|&id| id == cur) {
let next = panes[(pos + 1) % panes.len()];
self.current_tab_mut().active_pane = next;
}
}
/// Move focus to the previous pane in tree order (wraps around).
pub fn focus_prev(&mut self) {
let panes = self.current_tab().root.all_panes();
let cur = self.current_tab().active_pane;
if let Some(pos) = panes.iter().position(|&id| id == cur) {
let prev = if pos == 0 { panes.len() - 1 } else { pos - 1 };
self.current_tab_mut().active_pane = panes[prev];
}
}
// ── Layout ────────────────────────────────────────────────────────────────
/// Compute the pixel [`Rect`] for every pane in the active tab.
///
/// `tab_bar_h` pixels are reserved at the top for the tab bar.
pub fn layout(&self, w: f32, h: f32, tab_bar_h: f32) -> HashMap<usize, Rect> {
let mut out = HashMap::new();
let content = Rect::new(0.0, tab_bar_h, w, (h - tab_bar_h).max(0.0));
self.current_tab().root.layout(content, &mut out);
out
}
/// Resize every pane in the active tab to fit the new window dimensions.
pub fn resize_all(&mut self, w: f32, h: f32, cell_w: f32, cell_h: f32, tab_bar_h: f32) {
let rects = self.layout(w, h, tab_bar_h);
for (id, rect) in &rects {
let cols = rect.cell_cols(cell_w);
let rows = rect.cell_rows(cell_h);
if let Some(pane) = self.panes.get_mut(id) {
pane.resize(cols, rows);
}
}
}
// ── Output processing ─────────────────────────────────────────────────────
/// Drain PTY output for **all** panes across all tabs.
///
/// Returns `true` if any pane changed (i.e. a redraw is needed).
pub fn process_all_output(&mut self) -> bool {
let mut dirty = false;
for pane in self.panes.values_mut() {
if pane.process_output() {
dirty = true;
}
}
// Collect dead panes after iteration.
let dead: Vec<usize> = self
.panes
.iter()
.filter(|(_, p)| p.is_dead())
.map(|(id, _)| *id)
.collect();
for id in dead {
log::info!("removing dead pane {id}");
self.panes.remove(&id);
dirty = true;
// Remove from each tab's split tree; close empty tabs.
let mut empty_tabs: Vec<usize> = Vec::new();
for (idx, tab) in self.tabs.iter_mut().enumerate() {
let count = tab.root.all_panes().len();
if count == 1 && tab.root.all_panes().first() == Some(&id) {
empty_tabs.push(idx);
} else {
tab.root.remove(id);
if tab.active_pane == id {
tab.active_pane = tab.root.first_pane();
}
}
}
// Close empty tabs in reverse order to preserve indices.
for &idx in empty_tabs.iter().rev() {
if self.tabs.len() > 1 {
self.tabs.remove(idx);
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
}
}
}
}
dirty
}
// ── Scrollback viewport ───────────────────────────────────────────────────
/// Scroll the active pane's viewport up by `lines` into the scrollback buffer.
pub fn scroll_up_active(&mut self, lines: usize) {
let id = self.current_tab().active_pane;
if let Some(pane) = self.panes.get_mut(&id) {
let max = pane.terminal.scrollback.len();
pane.scroll_offset = (pane.scroll_offset + lines).min(max);
}
}
/// Scroll the active pane's viewport down by `lines` (towards live output).
pub fn scroll_down_active(&mut self, lines: usize) {
let id = self.current_tab().active_pane;
if let Some(pane) = self.panes.get_mut(&id) {
pane.scroll_offset = pane.scroll_offset.saturating_sub(lines);
}
}
/// Snap the active pane's viewport back to the bottom (live output).
pub fn scroll_to_bottom_active(&mut self) {
let id = self.current_tab().active_pane;
if let Some(pane) = self.panes.get_mut(&id) {
pane.scroll_offset = 0;
}
}
// ── Write to active pane ──────────────────────────────────────────────────
/// Write raw bytes to the active pane's shell stdin.
pub fn write_to_active(&self, data: &[u8]) {
if let Some(pane) = self.active_pane() {
pane.write(data);
}
}
// ── Queries ───────────────────────────────────────────────────────────────
/// Pane IDs for the active tab, in tree order.
pub fn current_tab_panes(&self) -> Vec<usize> {
self.current_tab().root.all_panes()
}
/// Number of open tabs.
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
/// `true` when all panes in all tabs are dead (signal to exit the app).
pub fn is_empty(&self) -> bool {
self.panes.is_empty()
}
}
View File
+694
View File
@@ -0,0 +1,694 @@
[2026-06-09T07:39:17Z DEBUG winiterm::config] config not found at C:\Users\DG2210\AppData\Roaming\winiterm\config.lua, using defaults
[2026-06-09T07:39:17Z WARN wgpu_hal::vulkan::instance] InstanceFlags::VALIDATION requested, but unable to find layer: VK_LAYER_KHRONOS_validation
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
No valid vk_loader_settings.json file found, no loader settings will be active
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_app_package_manifest_path: Failed to find mapping layers packages by family name
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Checking for Layer Manifest files in Registry at HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: opening device PCI\VEN_8086&DEV_A721&SUBSYS_8B7C103C&REV_04\3&11583659&0&10
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(1) Does not contain a value for "VulkanImplicitLayers"
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 2 - SWD\DRIVERENUM\IGS&4&168EC99&0
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(2) Does not contain a value for "VulkanImplicitLayers"
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 3 - DISPLAY\LGD071E\4&168EC99&0&UID8388688
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: GUID for 3 is not SoftwareComponent skipping
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 4 - DISPLAY\PHL095C\4&168EC99&0&UID8261
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: GUID for 4 is not SoftwareComponent skipping
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: found no registry files
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Located json file "C:\ProgramData\obs-studio-hook\obs-vulkan64.json" from registry "HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers"
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found manifest file C:\ProgramData\obs-studio-hook\obs-vulkan64.json (file version 1.1.2)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Checking for Layer Manifest files in Registry at HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ExplicitLayers
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: opening device PCI\VEN_8086&DEV_A721&SUBSYS_8B7C103C&REV_04\3&11583659&0&10
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(1) Does not contain a value for "VulkanExplicitLayers"
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 2 - SWD\DRIVERENUM\IGS&4&168EC99&0
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(2) Does not contain a value for "VulkanExplicitLayers"
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 3 - DISPLAY\LGD071E\4&168EC99&0&UID8388688
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: GUID for 3 is not SoftwareComponent skipping
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 4 - DISPLAY\PHL095C\4&168EC99&0&UID8261
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: GUID for 4 is not SoftwareComponent skipping
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: found no registry files
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found no registry files in HKEY_CURRENT_USER\SOFTWARE\Khronos\Vulkan\ExplicitLayers
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z WARN wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_read_data_files_in_registry: Registry lookup failed to get layer manifest files.
[2026-06-09T07:39:17Z WARN wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_app_package_manifest_path: Failed to find mapping layers packages by family name
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Checking for Driver Manifest files in Registry at HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_add_json_entry: Located json file "C:\WINDOWS\System32\DriverStore\FileRepository\iigd_dch.inf_amd64_0eac281dc2d07a5f\igvk64.json" from PnP registry: E
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found no registry files in HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found ICD manifest file C:\WINDOWS\System32\DriverStore\FileRepository\iigd_dch.inf_amd64_0eac281dc2d07a5f\igvk64.json, version 1.0.0
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Searching for ICD drivers named .\igvk64.dll
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Loading layer library C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Insert instance layer "VK_LAYER_OBS_HOOK" (C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
vkCreateInstance layer callstack setup to:
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Application>
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Loader>
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
VK_LAYER_OBS_HOOK
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Type: Implicit
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Enabled By: Implicit Layer
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Disable Env Var: DISABLE_VULKAN_OBS_CAPTURE
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Manifest: C:\ProgramData\obs-studio-hook\obs-vulkan64.json
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Library: C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Drivers>
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] Instance version: 0x404139
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] Enabling debug utils
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::instance] Enabling device properties2
[2026-06-09T07:39:17Z DEBUG wgpu_core::instance] Instance::new: created Vulkan backend
[2026-06-09T07:39:17Z DEBUG wgpu_hal::dx12::instance] Using FXC for shader compilation
[2026-06-09T07:39:17Z DEBUG wgpu_core::instance] Instance::new: created Dx12 backend
[2026-06-09T07:39:17Z DEBUG wgpu_hal::gles::wgl] Enabling GL debug output
[2026-06-09T07:39:17Z DEBUG wgpu_core::instance] Instance::new: created Gl backend
[2026-06-09T07:39:17Z DEBUG wgpu_hal::gles::adapter] Vendor: Intel
[2026-06-09T07:39:17Z DEBUG wgpu_hal::gles::adapter] Renderer: Intel(R) UHD Graphics
[2026-06-09T07:39:17Z DEBUG wgpu_hal::gles::adapter] Version: 4.6.0 - Build 32.0.101.7084
[2026-06-09T07:39:17Z DEBUG wgpu_hal::gles::adapter] SL version: 4.60 - Build 32.0.101.7084
[2026-06-09T07:39:17Z DEBUG wgpu_hal::gles::adapter] Supported GL Extensions: {
"GL_ARB_map_buffer_range",
"GL_ARB_texture_rg",
"GL_ARB_multisample",
"GL_ARB_robustness",
"GL_ARB_texture_rectangle",
"GL_INTEL_fragment_shader_ordering",
"GL_ARB_shader_draw_parameters",
"GL_ARB_transform_feedback_overflow_query",
"GL_EXT_texture_swizzle",
"GL_ARB_half_float_pixel",
"GL_ARB_seamless_cubemap_per_texture",
"GL_ARB_window_pos",
"GL_KHR_shader_subgroup_basic",
"GL_ARB_sync",
"GL_SGIS_texture_lod",
"GL_ARB_texture_filter_anisotropic",
"GL_EXT_stencil_two_side",
"GL_ARB_texture_multisample",
"GL_ARB_geometry_shader4",
"GL_AMD_vertex_shader_viewport_index",
"GL_SGIS_texture_edge_clamp",
"GL_EXT_separate_specular_color",
"GL_ARB_shading_language_420pack",
"GL_ARB_color_buffer_float",
"GL_ARB_multi_draw_indirect",
"GL_ARB_occlusion_query",
"GL_EXT_texture_filter_anisotropic",
"GL_KHR_shader_subgroup_clustered",
"GL_ARB_texture_env_dot3",
"GL_ARB_viewport_array",
"GL_EXT_texture_compression_s3tc",
"GL_ARB_compute_shader",
"GL_ARB_shader_bit_encoding",
"GL_ARB_vertex_type_10f_11f_11f_rev",
"GL_ARB_direct_state_access",
"GL_KHR_shader_subgroup_ballot",
"GL_ARB_shader_atomic_counter_ops",
"GL_ARB_shader_atomic_counters",
"GL_INTEL_map_texture",
"GL_EXT_shadow_funcs",
"GL_ARB_texture_compression_bptc",
"GL_ARB_conservative_depth",
"GL_ARB_framebuffer_no_attachments",
"GL_ARB_invalidate_subdata",
"GL_ARB_pipeline_statistics_query",
"GL_ARB_draw_elements_base_vertex",
"GL_KHR_shader_subgroup_shuffle",
"GL_ARB_get_texture_sub_image",
"GL_ARB_texture_cube_map",
"GL_ARB_transform_feedback3",
"GL_NV_conditional_render",
"GL_ARB_texture_border_clamp",
"GL_ARB_texture_query_lod",
"GL_INTEL_framebuffer_CMAA",
"GL_ARB_base_instance",
"GL_KHR_blend_equation_advanced_coherent",
"GL_ARB_texture_buffer_range",
"GL_EXT_shader_framebuffer_fetch",
"GL_EXT_packed_depth_stencil",
"GL_EXT_blend_color",
"GL_EXT_framebuffer_blit",
"GL_ARB_point_sprite",
"GL_ARB_texture_env_add",
"GL_ARB_bindless_texture",
"GL_ARB_program_interface_query",
"GL_ARB_depth_texture",
"GL_ARB_get_program_binary",
"GL_EXT_rescale_normal",
"GL_ARB_debug_output",
"GL_EXT_draw_range_elements",
"GL_ARB_buffer_storage",
"GL_ARB_clear_buffer_object",
"GL_ARB_cull_distance",
"WGL_EXT_swap_control",
"GL_EXT_texture_edge_clamp",
"GL_SGIS_generate_mipmap",
"GL_ARB_ES2_compatibility",
"GL_ARB_uniform_buffer_object",
"GL_ARB_depth_clamp",
"GL_ARB_separate_shader_objects",
"GL_EXT_gpu_shader4",
"GL_ARB_texture_mirrored_repeat",
"GL_ARB_robustness_isolation",
"GL_ARB_texture_env_crossbar",
"GL_ARB_shader_objects",
"GL_EXT_blend_subtract",
"GL_ARB_explicit_attrib_location",
"GL_ARB_fragment_shader_interlock",
"GL_ARB_gpu_shader_fp64",
"GL_ARB_enhanced_layouts",
"GL_KHR_shader_subgroup_shuffle_relative",
"GL_NV_timeline_semaphore",
"GL_EXT_abgr",
"GL_EXT_texture_sRGB",
"GL_EXT_geometry_shader4",
"GL_ARB_instanced_arrays",
"GL_EXT_blend_equation_separate",
"GL_EXT_compiled_vertex_array",
"GL_ARB_conditional_render_inverted",
"GL_EXT_gpu_program_parameters",
"GL_ARB_shader_image_load_store",
"GL_ARB_transpose_matrix",
"GL_ARB_cl_event",
"GL_ARB_fragment_shader",
"GL_ARB_texture_storage_multisample",
"GL_ARB_vertex_attrib_64bit",
"GL_EXT_bgra",
"GL_ARB_explicit_uniform_location",
"GL_EXT_timer_query",
"GL_SUN_multi_draw_arrays",
"GL_ARB_texture_env_combine",
"GL_KHR_debug",
"GL_ARB_tessellation_shader",
"GL_ARB_shader_group_vote",
"GL_ARB_fragment_program",
"GL_ARB_gpu_shader5",
"GL_EXT_texture_env_add",
"GL_EXT_texture_env_combine",
"GL_INTEL_conservative_rasterization",
"GL_KHR_shader_subgroup_vote",
"GL_ARB_clear_texture",
"GL_ARB_texture_swizzle",
"GL_ARB_copy_buffer",
"GL_EXT_direct_state_access",
"GL_ARB_fragment_coord_conventions",
"GL_KHR_shader_subgroup_quad",
"GL_ARB_provoking_vertex",
"GL_EXT_texture_snorm",
"GL_ARB_polygon_offset_clamp",
"GL_ARB_shader_subroutine",
"GL_ARB_texture_barrier",
"GL_ARB_texture_query_levels",
"GL_EXT_blend_minmax",
"GL_ARB_draw_buffers_blend",
"GL_EXT_memory_object",
"GL_EXT_clip_volume_hint",
"GL_AMD_depth_clamp_separate",
"GL_ARB_shadow",
"GL_EXT_texture_rectangle",
"GL_NV_texgen_reflection",
"GL_ARB_internalformat_query2",
"GL_EXT_texture_array",
"GL_ARB_gl_spirv",
"GL_ARB_internalformat_query",
"GL_EXT_packed_pixels",
"GL_ARB_compressed_texture_pixel_storage",
"GL_EXT_stencil_wrap",
"GL_ARB_multi_bind",
"GL_ARB_query_buffer_object",
"GL_ARB_robust_buffer_access_behavior",
"GL_ARB_vertex_array_bgra",
"GL_ATI_separate_stencil",
"GL_EXT_packed_float",
"GL_ARB_arrays_of_arrays",
"GL_ARB_shading_language_packing",
"GL_EXT_semaphore_win32",
"GL_ARB_pixel_buffer_object",
"GL_ARB_clip_control",
"GL_ARB_blend_func_extended",
"GL_ARB_multitexture",
"GL_ARB_shader_precision",
"GL_ARB_map_buffer_alignment",
"GL_ARB_vertex_attrib_binding",
"GL_ARB_texture_buffer_object",
"GL_ARB_draw_instanced",
"GL_ARB_texture_rgb10_a2ui",
"GL_EXT_secondary_color",
"GL_ARB_derivative_control",
"GL_EXT_fog_coord",
"GL_ARB_vertex_type_2_10_10_10_rev",
"GL_ARB_vertex_array_object",
"GL_ARB_fragment_layer_viewport",
"GL_ARB_sample_shading",
"GL_ARB_ES3_1_compatibility",
"GL_ARB_shader_texture_image_samples",
"GL_OVR_multiview",
"GL_EXT_texture3D",
"GL_ARB_framebuffer_object",
"GL_EXT_semaphore",
"GL_ARB_texture_stencil8",
"GL_ARB_texture_view",
"GL_KHR_no_error",
"GL_IBM_texture_mirrored_repeat",
"GL_ARB_framebuffer_sRGB",
"GL_NV_blend_square",
"GL_ARB_fragment_program_shadow",
"GL_ARB_texture_compression",
"GL_ARB_half_float_vertex",
"GL_EXT_draw_buffers2",
"GL_AMD_vertex_shader_layer",
"GL_ARB_shader_storage_buffer_object",
"GL_EXT_framebuffer_multisample",
"GL_KHR_blend_equation_advanced",
"GL_ARB_seamless_cube_map",
"GL_INTEL_performance_query",
"GL_ARB_vertex_shader",
"GL_ARB_texture_buffer_object_rgb32",
"GL_KHR_context_flush_control",
"GL_EXT_texture_shared_exponent",
"GL_ARB_texture_cube_map_array",
"GL_NV_primitive_restart",
"GL_ARB_texture_mirror_clamp_to_edge",
"GL_ARB_texture_float",
"GL_ARB_timer_query",
"GL_EXT_depth_bounds_test",
"GL_ARB_depth_buffer_float",
"GL_3DFX_texture_compression_FXT1",
"GL_EXT_texture_integer",
"GL_KHR_texture_compression_astc_ldr",
"GL_ARB_sampler_objects",
"GL_ARB_transform_feedback2",
"GL_ARB_shading_language_100",
"GL_ARB_draw_indirect",
"GL_ARB_shader_image_size",
"GL_ARB_indirect_parameters",
"GL_ARB_texture_storage",
"GL_ARB_post_depth_coverage",
"GL_ARB_transform_feedback_instanced",
"GL_ARB_vertex_program",
"GL_EXT_blend_func_separate",
"GL_EXT_multi_draw_arrays",
"GL_ARB_spirv_extensions",
"GL_EXT_texture_sRGB_decode",
"GL_INTEL_multi_rate_fragment_shader",
"GL_ARB_shader_texture_lod",
"GL_EXT_shader_integer_mix",
"GL_WIN_swap_hint",
"GL_EXT_texture_storage",
"GL_ARB_stencil_texturing",
"GL_ARB_texture_non_power_of_two",
"GL_ATI_meminfo",
"GL_ARB_point_parameters",
"GL_EXT_framebuffer_object",
"GL_ARB_draw_buffers",
"GL_ARB_texture_gather",
"GL_ARB_compatibility",
"GL_ARB_vertex_buffer_object",
"GL_ARB_shader_stencil_export",
"GL_EXT_transform_feedback",
"GL_EXT_texture_lod_bias",
"GL_ARB_copy_image",
"GL_KHR_shader_subgroup",
"GL_KHR_shader_subgroup_arithmetic",
"GL_ARB_ES3_compatibility",
"GL_EXT_memory_object_win32",
"GL_EXT_polygon_offset_clamp",
"GL_ARB_texture_compression_rgtc",
"GL_ARB_occlusion_query2",
}
[2026-06-09T07:39:17Z INFO wgpu_core::instance] Adapter Vulkan AdapterInfo { name: "Intel(R) UHD Graphics", vendor: 32902, device: 42785, device_type: IntegratedGpu, driver: "Intel Corporation", driver_info: "101.7084", backend: Vulkan }
[2026-06-09T07:39:17Z DEBUG wgpu_hal::vulkan::adapter] Supported extensions: ["VK_KHR_swapchain", "VK_KHR_swapchain_mutable_format", "VK_EXT_robustness2"]
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Inserted device layer "VK_LAYER_OBS_HOOK" (C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
vkCreateDevice layer callstack setup to:
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Application>
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Loader>
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
VK_LAYER_OBS_HOOK
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Type: Implicit
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Enabled By: Implicit Layer
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Disable Env Var: DISABLE_VULKAN_OBS_CAPTURE
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Manifest: C:\ProgramData\obs-studio-hook\obs-vulkan64.json
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Library: C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Device>
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Using "Intel(R) UHD Graphics" with driver: "C:\WINDOWS\System32\DriverStore\FileRepository\iigd_dch.inf_amd64_0eac281dc2d07a5f\.\igvk64.dll"
[2026-06-09T07:39:17Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x1faeef2cf80, name: ?)
[2026-06-09T07:39:17Z DEBUG wgpu_core::device::global] configuring surface with SurfaceConfiguration { usage: TextureUsages(RENDER_ATTACHMENT), format: Bgra8Unorm, width: 1800, height: 1200, present_mode: AutoVsync, desired_maximum_frame_latency: 2, alpha_mode: Opaque, view_formats: [] }
[2026-06-09T07:39:17Z INFO wgpu_core::device::resource] Device::maintain: waiting for submission index 0
[2026-06-09T07:39:17Z DEBUG wgpu_core::device::resource] Create view for Texture with 'glyph_atlas' label filters usages to TextureUses(RESOURCE)
[2026-06-09T07:39:17Z DEBUG wgpu_core::resource] Buffer with 'uniforms' label map state -> Idle
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [0] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [1] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [2] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [3] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [4] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [5] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [6] = Literal(AbstractInt(1)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [7] = Literal(I32(1)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [8] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [9] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [10] = Literal(AbstractInt(2)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [11] = Literal(I32(2)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [12] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [13] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [14] = Literal(AbstractInt(1)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [15] = Literal(I32(1)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [16] = Literal(AbstractInt(2)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [17] = Literal(I32(2)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [18] = Literal(AbstractInt(3)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [19] = Literal(I32(3)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [20] = Literal(AbstractInt(4)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [21] = Literal(I32(4)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [22] = Literal(AbstractInt(5)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [23] = Literal(I32(5)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [24] = Literal(AbstractInt(6)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [25] = Literal(I32(6)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [26] = Literal(AbstractInt(7)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [27] = Literal(I32(7)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [28] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [29] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [30] = Literal(AbstractInt(1)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [31] = Literal(I32(1)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [32] = Literal(AbstractInt(2)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [33] = Literal(I32(2)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [34] = Literal(AbstractInt(3)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [35] = Literal(I32(3)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [36] = Literal(AbstractInt(4)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [0] = FunctionArgument(0) : Handle([4])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [1] = FunctionArgument(1) : Handle([7])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [2] = LocalVariable([0]) : Value(Pointer { base: [8], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [3] = AccessIndex { base: [1], index: 0 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [4] = AccessIndex { base: [0], index: 0 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [5] = AccessIndex { base: [1], index: 1 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["vec2<f32>", "vec2<f32>"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [6] = Binary { op: Multiply, left: [4], right: [5] } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["vec2<f32>", "vec2<f32>"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [7] = Binary { op: Add, left: [3], right: [6] } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [8] = AccessIndex { base: [7], index: 0 } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [9] = GlobalVariable([0]) : Value(Pointer { base: [1], space: Uniform })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [10] = AccessIndex { base: [9], index: 0 } : Value(Pointer { base: [0], space: Uniform })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [11] = AccessIndex { base: [10], index: 0 } : Value(ValuePointer { size: None, scalar: Scalar { kind: Float, width: 4 }, space: Uniform })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [12] = Load { pointer: [11] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "f32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [13] = Binary { op: Divide, left: [8], right: [12] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [14] = Literal(AbstractFloat(2.0)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [15] = Literal(F32(2.0)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [16] = Binary { op: Multiply, left: [13], right: [15] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [17] = Literal(AbstractFloat(1.0)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [18] = Literal(F32(1.0)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [19] = Binary { op: Subtract, left: [16], right: [18] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [20] = AccessIndex { base: [7], index: 1 } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [21] = GlobalVariable([0]) : Value(Pointer { base: [1], space: Uniform })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [22] = AccessIndex { base: [21], index: 0 } : Value(Pointer { base: [0], space: Uniform })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [23] = AccessIndex { base: [22], index: 1 } : Value(ValuePointer { size: None, scalar: Scalar { kind: Float, width: 4 }, space: Uniform })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [24] = Load { pointer: [23] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "f32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [25] = Binary { op: Divide, left: [20], right: [24] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [26] = Unary { op: Negate, expr: [25] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [27] = Literal(AbstractFloat(2.0)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [28] = Literal(F32(2.0)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [29] = Binary { op: Multiply, left: [26], right: [28] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [30] = Literal(AbstractFloat(1.0)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [31] = Literal(F32(1.0)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [32] = Binary { op: Add, left: [29], right: [31] } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [33] = AccessIndex { base: [2], index: 0 } : Value(Pointer { base: [5], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [34] = Literal(AbstractFloat(0.0)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [35] = Literal(AbstractFloat(1.0)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [36] = Literal(F32(0.0)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [37] = Literal(F32(1.0)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [38] = Compose { ty: [5], components: [[19], [32], [36], [37]] } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [39] = AccessIndex { base: [2], index: 1 } : Value(Pointer { base: [0], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [40] = AccessIndex { base: [1], index: 2 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [41] = AccessIndex { base: [0], index: 0 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [42] = AccessIndex { base: [1], index: 3 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["vec2<f32>", "vec2<f32>"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [43] = Binary { op: Multiply, left: [41], right: [42] } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["vec2<f32>", "vec2<f32>"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [44] = Binary { op: Add, left: [40], right: [43] } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [45] = AccessIndex { base: [2], index: 2 } : Value(Pointer { base: [5], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [46] = AccessIndex { base: [1], index: 4 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [47] = AccessIndex { base: [2], index: 3 } : Value(Pointer { base: [5], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [48] = AccessIndex { base: [1], index: 5 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [49] = AccessIndex { base: [2], index: 4 } : Value(Pointer { base: [0], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [50] = AccessIndex { base: [0], index: 0 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [51] = AccessIndex { base: [2], index: 5 } : Value(Pointer { base: [6], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [52] = AccessIndex { base: [1], index: 6 } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [53] = Load { pointer: [2] } : Handle([8])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [37] = Literal(I32(4)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [38] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [0] = FunctionArgument(0) : Handle([8])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [1] = GlobalVariable([1]) : Handle([2])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [2] = GlobalVariable([2]) : Handle([3])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [3] = AccessIndex { base: [0], index: 1 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [4] = ImageSample { image: [1], sampler: [2], gather: None, coordinate: [3], array_index: None, offset: None, level: Auto, depth_ref: None } : Value(Vector { size: Quad, scalar: Scalar { kind: Float, width: 4 } })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [5] = AccessIndex { base: [4], index: 0 } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [6] = AccessIndex { base: [0], index: 3 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [7] = AccessIndex { base: [0], index: 2 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [8] = Math { fun: Mix, arg: [6], arg1: Some([7]), arg2: Some([5]), arg3: None } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [9] = LocalVariable([0]) : Value(Pointer { base: [5], space: Function })
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [10] = AccessIndex { base: [0], index: 4 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [11] = AccessIndex { base: [10], index: 1 } : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [12] = AccessIndex { base: [0], index: 5 } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [13] = Literal(U32(8)) : Value(Scalar(Scalar { kind: Uint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["u32", "u32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "u32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [14] = Binary { op: And, left: [12], right: [13] } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [15] = Literal(U32(0)) : Value(Scalar(Scalar { kind: Uint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["u32", "u32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "u32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [16] = Binary { op: NotEqual, left: [14], right: [15] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [17] = Literal(AbstractFloat(0.84)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [18] = Literal(F32(0.84)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [19] = Binary { op: Greater, left: [11], right: [18] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [20] = Binary { op: LogicalAnd, left: [16], right: [19] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [21] = AccessIndex { base: [0], index: 2 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [22] = AccessIndex { base: [0], index: 5 } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [23] = Literal(U32(16)) : Value(Scalar(Scalar { kind: Uint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["u32", "u32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "u32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [24] = Binary { op: And, left: [22], right: [23] } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [25] = Literal(U32(0)) : Value(Scalar(Scalar { kind: Uint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["u32", "u32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "u32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [26] = Binary { op: NotEqual, left: [24], right: [25] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [27] = Literal(AbstractFloat(0.74)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [28] = Literal(F32(0.74)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [29] = Binary { op: Greater, left: [11], right: [28] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [30] = Literal(AbstractFloat(0.8)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [31] = Literal(F32(0.8)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [32] = Binary { op: Less, left: [11], right: [31] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [33] = Binary { op: LogicalAnd, left: [29], right: [32] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [34] = Literal(AbstractFloat(0.88)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [35] = Literal(F32(0.88)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [36] = Binary { op: Greater, left: [11], right: [35] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [37] = Literal(AbstractFloat(0.94)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [38] = Literal(F32(0.94)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [39] = Binary { op: Less, left: [11], right: [38] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [40] = Binary { op: LogicalAnd, left: [36], right: [39] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [41] = Binary { op: LogicalOr, left: [33], right: [40] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [42] = Binary { op: LogicalAnd, left: [26], right: [41] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [43] = AccessIndex { base: [0], index: 2 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [44] = AccessIndex { base: [0], index: 5 } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [45] = Literal(U32(256)) : Value(Scalar(Scalar { kind: Uint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["u32", "u32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "u32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [46] = Binary { op: And, left: [44], right: [45] } : Handle([6])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [47] = Literal(U32(0)) : Value(Scalar(Scalar { kind: Uint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["u32", "u32"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "u32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [48] = Binary { op: NotEqual, left: [46], right: [47] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [49] = Literal(AbstractFloat(0.44)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [50] = Literal(F32(0.44)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [51] = Binary { op: Greater, left: [11], right: [50] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [52] = Binary { op: LogicalAnd, left: [48], right: [51] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [53] = Literal(AbstractFloat(0.54)) : Value(Scalar(Scalar { kind: AbstractFloat, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["f32", "{AbstractFloat}"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "f32"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [54] = Literal(F32(0.54)) : Value(Scalar(Scalar { kind: Float, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [55] = Binary { op: Less, left: [11], right: [54] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] wgsl automatic_conversion_consensus: ["bool", "bool"]
[2026-06-09T07:39:17Z DEBUG naga::front::wgsl::lower::conversion] consensus: "bool"
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [56] = Binary { op: LogicalAnd, left: [52], right: [55] } : Value(Scalar(Scalar { kind: Bool, width: 1 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [57] = AccessIndex { base: [0], index: 2 } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [58] = Load { pointer: [9] } : Handle([5])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [39] = Literal(I32(0)) : Value(Scalar(Scalar { kind: Sint, width: 4 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [40] = Literal(AbstractInt(0)) : Value(Scalar(Scalar { kind: AbstractInt, width: 8 }))
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [0] = FunctionArgument(0) : Handle([8])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [1] = GlobalVariable([1]) : Handle([2])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [2] = GlobalVariable([2]) : Handle([3])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [3] = AccessIndex { base: [0], index: 1 } : Handle([0])
[2026-06-09T07:39:17Z DEBUG naga::front] Resolving [4] = ImageSample { image: [1], sampler: [2], gather: None, coordinate: [3], array_index: None, offset: None, level: Auto, depth_ref: None } : Value(Vector { size: Quad, scalar: Scalar { kind: Float, width: 4 } })
[2026-06-09T07:39:17Z DEBUG naga::valid::interface] var GlobalVariable { name: Some("uniforms"), space: Uniform, binding: Some(ResourceBinding { group: 0, binding: 0 }), ty: [1], init: None }
[2026-06-09T07:39:17Z DEBUG naga::valid::interface] var GlobalVariable { name: Some("atlas_tex"), space: Handle, binding: Some(ResourceBinding { group: 0, binding: 1 }), ty: [2], init: None }
[2026-06-09T07:39:17Z DEBUG naga::valid::interface] var GlobalVariable { name: Some("atlas_smp"), space: Handle, binding: Some(ResourceBinding { group: 0, binding: 2 }), ty: [3], init: None }
[2026-06-09T07:39:17Z DEBUG naga::valid::function] var LocalVariable { name: Some("out"), ty: [8], init: None }
[2026-06-09T07:39:17Z DEBUG naga::valid::function] var LocalVariable { name: Some("color"), ty: [5], init: None }
[2026-06-09T07:39:17Z ERROR wgpu_core::device::global] Device::create_render_pipeline error: Error matching ShaderStages(VERTEX) shader requirements against the pipeline
[2026-06-09T07:39:17Z ERROR wgpu::backend::wgpu_core] Handling wgpu errors as fatal by default
thread 'main' (24148) panicked at C:\Users\DG2210\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-22.1.0\src\backend\wgpu_core.rs:3411:5:
wgpu error: Validation Error
Caused by:
In Device::create_render_pipeline, label = 'kitty_pipeline'
Error matching ShaderStages(VERTEX) shader requirements against the pipeline
Location[7] Uint32 interpolated as Some(Flat) with sampling None is not provided by the previous stage outputs
Input is not provided by the earlier stage in the pipeline
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
+184
View File
@@ -0,0 +1,184 @@
[2026-06-09T07:43:00Z WARN wgpu_hal::vulkan::instance] InstanceFlags::VALIDATION requested, but unable to find layer: VK_LAYER_KHRONOS_validation
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
No valid vk_loader_settings.json file found, no loader settings will be active
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_app_package_manifest_path: Failed to find mapping layers packages by family name
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Checking for Layer Manifest files in Registry at HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: opening device PCI\VEN_8086&DEV_A721&SUBSYS_8B7C103C&REV_04\3&11583659&0&10
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(1) Does not contain a value for "VulkanImplicitLayers"
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 2 - SWD\DRIVERENUM\IGS&4&168EC99&0
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(2) Does not contain a value for "VulkanImplicitLayers"
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 3 - DISPLAY\LGD071E\4&168EC99&0&UID8388688
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 4 - DISPLAY\PHL095C\4&168EC99&0&UID8261
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: found no registry files
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Located json file "C:\ProgramData\obs-studio-hook\obs-vulkan64.json" from registry "HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers"
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found manifest file C:\ProgramData\obs-studio-hook\obs-vulkan64.json (file version 1.1.2)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Checking for Layer Manifest files in Registry at HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ExplicitLayers
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: opening device PCI\VEN_8086&DEV_A721&SUBSYS_8B7C103C&REV_04\3&11583659&0&10
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(1) Does not contain a value for "VulkanExplicitLayers"
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 2 - SWD\DRIVERENUM\IGS&4&168EC99&0
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_entry: Device ID(2) Does not contain a value for "VulkanExplicitLayers"
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 3 - DISPLAY\LGD071E\4&168EC99&0&UID8388688
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: Opening child device 4 - DISPLAY\PHL095C\4&168EC99&0&UID8261
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_device_registry_files: found no registry files
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found no registry files in HKEY_CURRENT_USER\SOFTWARE\Khronos\Vulkan\ExplicitLayers
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z WARN wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_read_data_files_in_registry: Registry lookup failed to get layer manifest files.
[2026-06-09T07:43:00Z WARN wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_get_app_package_manifest_path: Failed to find mapping layers packages by family name
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Checking for Driver Manifest files in Registry at HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
windows_add_json_entry: Located json file "C:\WINDOWS\System32\DriverStore\FileRepository\iigd_dch.inf_amd64_0eac281dc2d07a5f\igvk64.json" from PnP registry: E
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found no registry files in HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Found ICD manifest file C:\WINDOWS\System32\DriverStore\FileRepository\iigd_dch.inf_amd64_0eac281dc2d07a5f\igvk64.json, version 1.0.0
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Insert instance layer "VK_LAYER_OBS_HOOK" (C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
vkCreateInstance layer callstack setup to:
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Application>
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Loader>
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
VK_LAYER_OBS_HOOK
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Type: Implicit
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Enabled By: Implicit Layer
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Disable Env Var: DISABLE_VULKAN_OBS_CAPTURE
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Manifest: C:\ProgramData\obs-studio-hook\obs-vulkan64.json
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Library: C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Drivers>
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] Enabling debug utils
[2026-06-09T07:43:00Z INFO wgpu_core::instance] Adapter Vulkan AdapterInfo { name: "Intel(R) UHD Graphics", vendor: 32902, device: 42785, device_type: IntegratedGpu, driver: "Intel Corporation", driver_info: "101.7084", backend: Vulkan }
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Inserted device layer "VK_LAYER_OBS_HOOK" (C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
vkCreateDevice layer callstack setup to:
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Application>
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Loader>
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
VK_LAYER_OBS_HOOK
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Type: Implicit
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Enabled By: Implicit Layer
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Disable Env Var: DISABLE_VULKAN_OBS_CAPTURE
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Manifest: C:\ProgramData\obs-studio-hook\obs-vulkan64.json
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Library: C:\ProgramData\obs-studio-hook\.\graphics-hook64.dll
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
||
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
<Device>
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] GENERAL [Loader Message (0x0)]
Using "Intel(R) UHD Graphics" with driver: "C:\WINDOWS\System32\DriverStore\FileRepository\iigd_dch.inf_amd64_0eac281dc2d07a5f\.\igvk64.dll"
[2026-06-09T07:43:00Z INFO wgpu_hal::vulkan::instance] objects: (type: INSTANCE, hndl: 0x21acf720e00, name: ?)
[2026-06-09T07:43:00Z INFO wgpu_core::device::resource] Device::maintain: waiting for submission index 0
[2026-06-09T07:43:01Z INFO winiterm::pty::windows] spawning shell: "cmd.exe" cols=120 rows=41
[2026-06-09T07:43:01Z INFO winiterm::workspace] initial shell spawned (pid=24452)
[2026-06-09T07:43:01Z INFO winiterm::workspace] workspace created, pane count = 1
[2026-06-09T07:43:01Z INFO wgpu_core::device::resource] Device::maintain: waiting for submission index 0
[2026-06-09T07:43:01Z INFO wgpu_core::device::resource] Device::maintain: waiting for submission index 0
[2026-06-09T07:43:01Z INFO wgpu_core::device::resource] Device::maintain: waiting for submission index 0
[2026-06-09T07:43:01Z INFO winiterm::pty::windows] child_exited: exit_code=3221225794
[2026-06-09T07:43:01Z INFO winiterm::pane] pane 0 is_dead=true (pty_present=true)
[2026-06-09T07:43:01Z INFO winiterm::workspace] removing dead pane 0
[2026-06-09T07:43:01Z INFO winiterm::app] all panes dead — exiting
[2026-06-09T07:43:01Z INFO winiterm::app] all panes dead — exiting
[2026-06-09T07:43:02Z INFO wgpu_core::device::resource] Device::maintain: waiting for submission index 0
View File