rs-mrxvt is a modernized, distro-agnostic terminal emulator inspired by the classic mrxvt. It is written in Rust and pairs 2008-era "tabbed power" with 2020s reliability.

This commit is contained in:
Jeremy Anderson 2026-07-24 20:54:28 -04:00
parent fd93c27264
commit 74dcb9f19b
6 changed files with 880 additions and 174 deletions

26
rs-mrxvt/.gitignore vendored
View File

@ -1,26 +0,0 @@
# Build artifacts
/target
**/*.rs.bk
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Distribution artifacts
*.deb
*.rpm
*.tar.gz
*.tar.xz
*.dsc
*.changes
*.buildinfo
# Logs
*.log

View File

@ -208,6 +208,163 @@ impl From<CrosstermKey> for AppEvent {
} }
} }
// ─── winit translations (gpu feature: wgpu + softbuffer backends) ───────────
#[cfg(feature = "gpu")]
pub mod winit_translate {
use winit::event::{ElementState, Event, KeyEvent, WindowEvent};
use winit::event_loop::EventLoopWindowTarget;
use winit::keyboard::{Key, ModifiersState, NamedKey};
use super::{AppEvent, AppKey, AppKeyEvent, AppModifiers};
/// Tracks the current keyboard modifier state.
///
/// Updated by `WindowEvent::ModifiersChanged` events, read when
/// translating `WindowEvent::KeyboardInput` events.
#[derive(Clone, Copy, Default)]
pub struct ModifierTracker {
mods: ModifiersState,
}
impl ModifierTracker {
pub fn update(&mut self, mods: ModifiersState) {
self.mods = mods;
}
pub fn to_app_modifiers(&self) -> AppModifiers {
AppModifiers {
shift: self.mods.shift_key(),
ctrl: self.mods.control_key(),
alt: self.mods.alt_key(),
super_key: self.mods.super_key(),
}
}
}
/// Translate a winit `KeyEvent` into an `AppKeyEvent`.
///
/// Returns `None` for keys we don't recognise (rare hardware keys, dead
/// keys, etc.) — the caller silently drops them.
pub fn winit_key_to_app_key(ev: &KeyEvent, mods: AppModifiers) -> Option<AppKeyEvent> {
let key = match &ev.logical_key {
Key::Character(s) => {
// Take the first character. For most keypresses this is a
// single-char string; for edge cases (e.g. dead-key sequences
// producing multi-char strings) we take the first.
AppKey::Char(s.chars().next().unwrap_or(' '))
}
Key::Named(n) => named_to_app_key(n)?,
_ => return None,
};
Some(AppKeyEvent {
mods,
key,
released: ev.state == ElementState::Released,
})
}
fn named_to_app_key(n: &NamedKey) -> Option<AppKey> {
Some(match n {
NamedKey::Enter => AppKey::Enter,
NamedKey::Tab => AppKey::Tab,
NamedKey::Space => AppKey::Space,
NamedKey::Backspace => AppKey::Backspace,
NamedKey::Escape => AppKey::Esc,
NamedKey::ArrowUp => AppKey::Up,
NamedKey::ArrowDown => AppKey::Down,
NamedKey::ArrowLeft => AppKey::Left,
NamedKey::ArrowRight => AppKey::Right,
NamedKey::Home => AppKey::Home,
NamedKey::End => AppKey::End,
NamedKey::PageUp => AppKey::PageUp,
NamedKey::PageDown => AppKey::PageDown,
NamedKey::Insert => AppKey::Insert,
NamedKey::Delete => AppKey::Delete,
NamedKey::F1 => AppKey::F(1),
NamedKey::F2 => AppKey::F(2),
NamedKey::F3 => AppKey::F(3),
NamedKey::F4 => AppKey::F(4),
NamedKey::F5 => AppKey::F(5),
NamedKey::F6 => AppKey::F(6),
NamedKey::F7 => AppKey::F(7),
NamedKey::F8 => AppKey::F(8),
NamedKey::F9 => AppKey::F(9),
NamedKey::F10 => AppKey::F(10),
NamedKey::F11 => AppKey::F(11),
NamedKey::F12 => AppKey::F(12),
_ => return None,
})
}
/// Translate a top-level winit `Event` into an `AppEvent`.
///
/// `tracker` is updated in place when modifier-state events arrive.
/// Returns `None` for events that don't map to an `AppEvent` (most
/// events — winit emits a lot of internal events we don't care about).
pub fn translate_event(
event: &Event<()>,
tracker: &mut ModifierTracker,
_elwt: &EventLoopWindowTarget<()>,
) -> Option<AppEvent> {
match event {
Event::WindowEvent { event: win_ev, .. } => {
translate_window_event(win_ev, tracker)
}
_ => None,
}
}
fn translate_window_event(
win_ev: &WindowEvent,
tracker: &mut ModifierTracker,
) -> Option<AppEvent> {
match win_ev {
WindowEvent::CloseRequested => Some(AppEvent::Quit),
WindowEvent::Resized(size) => {
Some(AppEvent::Resize(size.width as u16, size.height as u16))
}
WindowEvent::Focused(focused) => {
if *focused {
Some(AppEvent::FocusGained)
} else {
Some(AppEvent::FocusLost)
}
}
WindowEvent::ModifiersChanged(mods) => {
tracker.update(mods.state());
None // modifier changes are state, not actions
}
WindowEvent::KeyboardInput { event, .. } => {
let mut mods = tracker.to_app_modifiers();
// Fallback: if the tracker hasn't seen a ModifiersChanged
// event yet (which can happen on the very first key press
// on some X11/Wayland compositors), infer shift from the
// character's case. winit sends `Key::Character("T")` when
// shift is held, so an uppercase letter implies shift.
// This catches the common Ctrl+Shift+<letter> hotkeys even
// when modifier tracking hasn't warmed up.
if !mods.shift {
if let winit::keyboard::Key::Character(s) = &event.logical_key {
if s.chars().next().map(|c| c.is_ascii_uppercase()).unwrap_or(false) {
mods.shift = true;
}
}
}
winit_key_to_app_key(event, mods).map(AppEvent::Key)
}
_ => None,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -180,6 +180,15 @@ impl GlyphCache {
pub fn pixel_size(&self) -> f32 { pub fn pixel_size(&self) -> f32 {
self.scale.y self.scale.y
} }
/// Iterate over all cached glyphs: `(char, bold, italic, &CachedGlyph)`.
///
/// Used by the wgpu renderer to rebuild the atlas texture when new glyphs
/// are rasterized. Without this, the atlas would only cover ASCII and
/// every non-ASCII character would render as an empty cell.
pub fn iter(&self) -> impl Iterator<Item = ((char, bool, bool), &CachedGlyph)> {
self.cache.iter().map(|(&(c, b, i), g)| ((c, b, i), g))
}
} }
/// Return a bundled monospace font. We use DejaVu Sans Mono, which is a /// Return a bundled monospace font. We use DejaVu Sans Mono, which is a

View File

@ -25,30 +25,23 @@
//! //!
//! ## Shader design //! ## Shader design
//! //!
//! Two pipelines: //! One instanced quad pipeline. Each instance = one terminal cell. The quad
//! 1. **Background pipeline**: fills each cell with its bg color. Simple //! covers the full cell (for the background fill). The glyph is sampled from
//! instanced quad shader. //! a sub-rect of the atlas that corresponds to the glyph's actual rasterized
//! 2. **Glyph pipeline**: samples the glyph atlas texture at the right UV //! pixels — NOT stretched to fill the cell. This keeps baselines aligned and
//! coordinates and tints with the cell's fg color. //! prevents the jagged/mirrored look that stretching caused.
//! //!
//! Both pipelines share the same instance buffer layout for efficiency. //! ## Instance layout (must match `CellInstance` in wgpu.rs)
//!
//! For each instance:
//! - `position`: vec2<f32> — cell top-left in pixels
//! - `size`: vec2<f32> — cell size in pixels (CELL_WIDTH × CELL_HEIGHT)
//! - `uv_offset`: vec2<f32> — top-left of glyph rect in atlas (0..1 UV)
//! - `uv_size`: vec2<f32> — size of glyph rect in atlas (0..1 UV)
//! - `bg_color`: vec4<f32> — background color (linear RGBA)
//! - `fg_color`: vec4<f32> — foreground color (linear RGBA)
//! - `flags`: u32 — bit 0: has_glyph, bit 1: bold, bit 2: italic
/// Shader source for the glyph + background rendering pipeline.
///
/// # Uniforms (group 0):
/// - binding 0: `uniforms` — global uniforms (resolution, time, etc.)
/// - binding 1: `glyph_atlas` — texture_2d<f32> with all rasterized glyphs
/// - binding 2: `glyph_sampler` — sampler (linear filter)
///
/// # Vertex format
/// For each instance:
/// - `position`: vec2<f32> — cell position in pixels (top-left corner)
/// - `size`: vec2<f32> — cell size in pixels
/// - `uv_offset`: vec2<f32> — offset into the glyph atlas (in texels)
/// - `uv_size`: vec2<f32> — size of the glyph in the atlas (in texels)
/// - `bg_color`: vec4<f32> — background color (linear RGBA)
/// - `fg_color`: vec4<f32> — foreground color (linear RGBA)
/// - `flags`: u32 — bit 0: has_glyph, bit 1: bold, bit 2: italic
pub const SHADER_SOURCE: &str = r#" pub const SHADER_SOURCE: &str = r#"
struct Uniforms { struct Uniforms {
resolution: vec2<f32>, resolution: vec2<f32>,
@ -63,19 +56,28 @@ struct Uniforms {
struct VertexInput { struct VertexInput {
@location(0) position: vec2<f32>, @location(0) position: vec2<f32>,
@location(1) size: vec2<f32>, @location(1) size: vec2<f32>,
@location(2) uv_offset: vec2<f32>, @location(2) glyph_offset: vec2<f32>,
@location(3) uv_size: vec2<f32>, @location(3) glyph_size: vec2<f32>,
@location(4) bg_color: vec4<f32>, @location(4) uv_offset: vec2<f32>,
@location(5) fg_color: vec4<f32>, @location(5) uv_size: vec2<f32>,
@location(6) flags: u32, @location(6) bg_color: vec4<f32>,
@location(7) fg_color: vec4<f32>,
@location(8) flags: u32,
}; };
struct VertexOutput { struct VertexOutput {
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>, /// Pixel coordinate within the cell, (0,0) = top-left of cell.
@location(1) fg_color: vec4<f32>, /// Used by the fragment shader to determine if this fragment is inside
@location(2) bg_color: vec4<f32>, /// the glyph rect.
@location(3) flags: u32, @location(0) cell_pixel: vec2<f32>,
@location(1) uv_offset: vec2<f32>,
@location(2) uv_size: vec2<f32>,
@location(3) glyph_offset: vec2<f32>,
@location(4) glyph_size: vec2<f32>,
@location(5) fg_color: vec4<f32>,
@location(6) bg_color: vec4<f32>,
@location(7) flags: u32,
}; };
// Convert screen-space pixels to NDC. // Convert screen-space pixels to NDC.
@ -90,25 +92,35 @@ fn screen_to_ndc(p: vec2<f32>) -> vec2<f32> {
@vertex @vertex
fn vs_main(in: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutput { fn vs_main(in: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutput {
// Generate a unit quad (0,0)-(1,1) from vertex_index. // Generate a unit quad (0,0)-(1,1) from vertex_index.
let corners = array<vec2<f32>, 4>( //
vec2<f32>(0.0, 0.0), // We compute the corner position arithmetically from the two low bits of
vec2<f32>(1.0, 0.0), // `vertex_index` instead of building a corner table and indexing it with
vec2<f32>(0.0, 1.0), // `vid`. naga (wgpu's shader validator) rejects dynamic indexing into
vec2<f32>(1.0, 1.0), // non-`const` array values on several backends (GL/Vulkan) with
); // "Expression may only be indexed by a constant". The bit pattern maps
let corner = corners[vid]; // identically to the original corner table:
// vid=0 → (0,0) vid=1 → (1,0) vid=2 → (0,1) vid=3 → (1,1)
// and works on every wgpu backend because it's pure arithmetic.
let corner = vec2<f32>(f32(vid & 1u), f32((vid >> 1u) & 1u));
// Cell rect in screen-space pixels. // Cell rect in screen-space pixels. The quad always covers the full cell
// so the background fills correctly.
let cell_min = in.position; let cell_min = in.position;
let cell_max = in.position + in.size; let cell_max = in.position + in.size;
let p = mix(cell_min, cell_max, corner); let p = mix(cell_min, cell_max, corner);
// UV into the glyph atlas (in 0..1 range). // Pixel coordinate within this cell (0,0 = top-left). Passed to the
let atlas_uv = in.uv_offset + corner * in.uv_size; // fragment shader so it can test whether this fragment is inside the
// glyph rect and compute the correct atlas UV.
let cell_pixel = corner * in.size;
var out: VertexOutput; var out: VertexOutput;
out.clip_position = vec4<f32>(screen_to_ndc(p), 0.0, 1.0); out.clip_position = vec4<f32>(screen_to_ndc(p), 0.0, 1.0);
out.uv = atlas_uv; out.cell_pixel = cell_pixel;
out.uv_offset = in.uv_offset;
out.uv_size = in.uv_size;
out.glyph_offset = in.glyph_offset;
out.glyph_size = in.glyph_size;
out.fg_color = in.fg_color; out.fg_color = in.fg_color;
out.bg_color = in.bg_color; out.bg_color = in.bg_color;
out.flags = in.flags; out.flags = in.flags;
@ -120,13 +132,99 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Always draw the background color first. // Always draw the background color first.
var color = in.bg_color; var color = in.bg_color;
// If the cell has a glyph, sample the atlas and blend over the bg. // If the cell has a glyph, check whether this fragment falls inside the
// glyph rect. If it does, compute the atlas UV and sample; if not, leave
// the background. This prevents the glyph from being stretched to fill
// the entire cell — instead it renders at its natural size, positioned
// at glyph_offset within the cell.
let has_glyph = (in.flags & 1u) != 0u; let has_glyph = (in.flags & 1u) != 0u;
if has_glyph { if has_glyph {
let glyph_alpha = textureSample(glyph_atlas, glyph_sampler, in.uv).a; let glyph_min = in.glyph_offset;
color = mix(color, in.fg_color, glyph_alpha); let glyph_max = in.glyph_offset + in.glyph_size;
if all(in.cell_pixel >= glyph_min) && all(in.cell_pixel < glyph_max) {
// Fragment is inside the glyph rect. Map cell-pixel to atlas UV.
let local = (in.cell_pixel - in.glyph_offset) / in.glyph_size;
let atlas_uv = in.uv_offset + local * in.uv_size;
// The atlas is an R8Unorm texture (single-channel alpha mask).
// Sampling it yields vec4(r, 0, 0, 1), so we read the R channel
// (not the alpha channel, which is always 1.0 for R8 textures)
// to get the glyph coverage.
let glyph_alpha = textureSample(glyph_atlas, glyph_sampler, atlas_uv).r;
color = mix(color, in.fg_color, glyph_alpha);
}
} }
return color; return color;
} }
"#; "#;
#[cfg(test)]
mod tests {
use super::*;
/// Regression test for the "naga rejects dynamic array indexing" crash.
///
/// The old vertex shader built a `corners` array inline and indexed it
/// with the vertex_index builtin. naga rejected this on GL/Vulkan
/// backends with "Expression may only be indexed by a constant", crashing
/// the app at shader-module creation. The fix replaced the array+index
/// with bit arithmetic on `vid`.
///
/// This test guards against any future regression that reintroduces
/// dynamic indexing into a non-`const` array.
#[test]
fn vertex_shader_does_not_use_dynamic_array_indexing() {
// The rejected pattern: indexing a non-const array with vertex_index.
assert!(
!SHADER_SOURCE.contains("corners[vid]"),
"shader must not index a non-const array with vertex_index — \
naga rejects this on GL/Vulkan. Use bit arithmetic on vid instead."
);
// The corner array literal itself is a smell — if it's back, the
// fix has been reverted.
assert!(
!SHADER_SOURCE.contains("array<vec2<f32>, 4>"),
"shader should compute corners arithmetically, not via an array literal"
);
}
/// The vertex shader must produce the correct corner mapping for
/// TriangleStrip order: vid 0..3 → (0,0), (1,0), (0,1), (1,1).
#[test]
fn vertex_shader_corner_mapping_is_present() {
assert!(
SHADER_SOURCE.contains("vid & 1u") && SHADER_SOURCE.contains("(vid >> 1u) & 1u"),
"vertex shader should compute corner via `vec2<f32>(f32(vid & 1u), f32((vid >> 1u) & 1u))`"
);
}
/// Regression test for the "blank window with no text" bug.
///
/// The atlas texture is R8Unorm — a single-channel format. Sampling it
/// returns vec4(r, 0, 0, 1), so reading the alpha channel always returns
/// 1.0. The fix reads the R channel instead.
#[test]
fn fragment_shader_reads_r_channel_not_alpha() {
// The shader must sample the atlas's R channel, not the alpha channel.
// We check that every textureSample call on glyph_atlas reads .r
// and never .a. (The exact UV expression has changed over time —
// `in.uv` became `atlas_uv` — so we match on the call pattern, not
// the full expression.)
assert!(
SHADER_SOURCE.contains("textureSample(glyph_atlas, glyph_sampler,") ,
"fragment shader must call textureSample on glyph_atlas"
);
assert!(
SHADER_SOURCE.contains("textureSample(glyph_atlas, glyph_sampler, atlas_uv).r"),
"fragment shader must sample the atlas's .r channel (R8Unorm texture, \
not .a which is always 1.0 for single-channel textures)"
);
assert!(
!SHADER_SOURCE.contains(").a;\n"),
"fragment shader must not read .a from the R8 atlas — it's always 1.0"
);
}
}

View File

@ -45,6 +45,7 @@ use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use winit::event_loop::EventLoop; use winit::event_loop::EventLoop;
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
use winit::window::WindowBuilder; use winit::window::WindowBuilder;
use alacritty_terminal::grid::Dimensions; use alacritty_terminal::grid::Dimensions;
@ -66,6 +67,13 @@ const FONT_PIXEL_SIZE: f32 = 14.0;
/// CPU-rasterized renderer. /// CPU-rasterized renderer.
pub struct SoftRenderer { pub struct SoftRenderer {
window: Arc<winit::window::Window>, window: Arc<winit::window::Window>,
/// The winit event loop. Must be kept alive and pumped every tick —
/// without pumping, the window never receives configure/expose events
/// and appears as a blank form. winit's `pump_events` lets us drive it
/// from our own main loop instead of ceding control to `EventLoop::run`.
event_loop: EventLoop<()>,
/// Tracks keyboard modifier state (updated by ModifiersChanged events).
mod_tracker: crate::ui::event::winit_translate::ModifierTracker,
#[allow(dead_code)] #[allow(dead_code)]
context: softbuffer::Context<Arc<winit::window::Window>>, context: softbuffer::Context<Arc<winit::window::Window>>,
surface: softbuffer::Surface<Arc<winit::window::Window>, Arc<winit::window::Window>>, surface: softbuffer::Surface<Arc<winit::window::Window>, Arc<winit::window::Window>>,
@ -80,6 +88,11 @@ impl SoftRenderer {
let window = Arc::new( let window = Arc::new(
WindowBuilder::new() WindowBuilder::new()
.with_title("rs-mrxvt (softbuffer)") .with_title("rs-mrxvt (softbuffer)")
// Give the window a real initial size. Without this, the WM
// may pick a tiny default (sometimes 1×1 or just title-bar-
// tall), which leaves the surface too small to render any
// cells and the user sees a blank form.
.with_inner_size(winit::dpi::LogicalSize::<u32>::new(1024, 768))
.build(&event_loop) .build(&event_loop)
.map_err(|e| anyhow::anyhow!("creating winit window: {e}"))?, .map_err(|e| anyhow::anyhow!("creating winit window: {e}"))?,
); );
@ -101,6 +114,8 @@ impl SoftRenderer {
Ok(Self { Ok(Self {
window, window,
event_loop,
mod_tracker: Default::default(),
context, context,
surface, surface,
glyph_cache, glyph_cache,
@ -118,12 +133,41 @@ impl Renderer for SoftRenderer {
Ok(()) Ok(())
} }
fn poll_event(&mut self, _timeout_ms: u64) -> Result<Option<crate::ui::event::AppEvent>> { fn poll_event(&mut self, timeout_ms: u64) -> Result<Option<crate::ui::event::AppEvent>> {
// Same caveat as WgpuRenderer: winit event loop integration is deferred. // If we have buffered events from a previous pump, return the oldest.
if self.pending_events.is_empty() { if !self.pending_events.is_empty() {
return Ok(self.pending_events.drain(..).next());
}
// Pump the winit event loop. This processes window events (resize,
// keyboard, mouse, close) for up to `timeout_ms`, translating each
// into an `AppEvent` via the winit_translate module. Without this
// pump call, the window never gets its initial configure event and
// appears as a blank form.
let mut collected: Vec<crate::ui::event::AppEvent> = Vec::new();
let mut tracker = self.mod_tracker;
let timeout = Some(std::time::Duration::from_millis(timeout_ms));
let status = self.event_loop.pump_events(timeout, |event, elwt| {
if let Some(app_ev) = crate::ui::event::winit_translate::translate_event(
&event, &mut tracker, elwt,
) {
collected.push(app_ev);
}
});
self.mod_tracker = tracker;
if let PumpStatus::Exit(_) = status {
collected.push(crate::ui::event::AppEvent::Quit);
}
if collected.is_empty() {
Ok(None) Ok(None)
} else { } else {
Ok(self.pending_events.drain(..).next()) let first = collected.remove(0);
self.pending_events.extend(collected);
Ok(Some(first))
} }
} }

View File

@ -43,6 +43,7 @@ use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use winit::event_loop::EventLoop; use winit::event_loop::EventLoop;
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
use winit::window::WindowBuilder; use winit::window::WindowBuilder;
use alacritty_terminal::grid::Dimensions; use alacritty_terminal::grid::Dimensions;
@ -53,6 +54,7 @@ use alacritty_terminal::vte::ansi::{Color as AnsiColor, NamedColor};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::app::App; use crate::app::App;
use crate::terminal::manager::BroadcastTarget;
use crate::ui::backend::BackendFactory; use crate::ui::backend::BackendFactory;
use crate::ui::glyph::GlyphCache; use crate::ui::glyph::GlyphCache;
use crate::ui::shaders::SHADER_SOURCE; use crate::ui::shaders::SHADER_SOURCE;
@ -62,13 +64,54 @@ const CELL_WIDTH: u32 = 8;
const CELL_HEIGHT: u32 = 16; const CELL_HEIGHT: u32 = 16;
const FONT_PIXEL_SIZE: f32 = 14.0; const FONT_PIXEL_SIZE: f32 = 14.0;
const ATLAS_SIZE: u32 = 1024; const ATLAS_SIZE: u32 = 1024;
/// Padding between glyphs in the atlas (pixels). Prevents linear-filter
/// sampling from bleeding into neighbouring glyphs at the edges.
const ATLAS_PADDING: u32 = 1;
/// Height of the tab bar in pixels (one cell row).
const TAB_BAR_HEIGHT: u32 = CELL_HEIGHT;
/// Height of the status bar in pixels (one cell row). Documented here for
/// layout clarity; the status bar Y is computed from `total_rows` at render
/// time, but this constant keeps the chrome-height intent explicit.
#[allow(dead_code)]
const STATUS_BAR_HEIGHT: u32 = CELL_HEIGHT;
// ─── Modern color palette (linear RGB, 0..1) ─────────────────────────────
//
// A clean dark theme with a cyan accent — modern without being garish.
const COLOR_BG: [f32; 4] = [0.05, 0.05, 0.07, 1.0]; // window background
const COLOR_TABBAR_BG: [f32; 4] = [0.10, 0.10, 0.13, 1.0]; // tab bar bg
const COLOR_TAB_INACTIVE_BG: [f32; 4] = [0.12, 0.12, 0.16, 1.0];
const COLOR_TAB_INACTIVE_FG: [f32; 4] = [0.55, 0.55, 0.62, 1.0];
const COLOR_TAB_ACTIVE_BG: [f32; 4] = [0.15, 0.20, 0.28, 1.0]; // highlighted
#[allow(dead_code)]
const COLOR_TAB_ACTIVE_FG: [f32; 4] = [0.95, 0.95, 1.0, 1.0];
const COLOR_TAB_ACCENT: [f32; 4] = [0.20, 0.65, 0.85, 1.0]; // cyan accent (active tab indicator)
#[allow(dead_code)]
const COLOR_TAB_SEPARATOR: [f32; 4] = [0.20, 0.20, 0.25, 1.0];
const COLOR_STATUS_BG: [f32; 4] = [0.08, 0.08, 0.11, 1.0];
const COLOR_STATUS_FG: [f32; 4] = [0.60, 0.60, 0.67, 1.0];
const COLOR_BROADCAST_ALL: [f32; 4] = [0.90, 0.25, 0.25, 1.0]; // red
const COLOR_BROADCAST_GROUP: [f32; 4] = [0.85, 0.40, 0.85, 1.0]; // magenta
/// Per-cell instance data. Matches the `VertexInput` struct in the WGSL shader. /// Per-cell instance data. Matches the `VertexInput` struct in the WGSL shader.
///
/// The quad covers the full cell (for the background fill). The glyph is
/// positioned within the cell via `glyph_offset` and `glyph_size` so it
/// renders at its natural aspect ratio instead of being stretched to fill
/// the cell. The atlas UV rect (`uv_offset`, `uv_size`) maps to the glyph's
/// actual pixels in the atlas.
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct CellInstance { struct CellInstance {
position: [f32; 2], position: [f32; 2],
size: [f32; 2], size: [f32; 2],
/// Top-left of the glyph rect within the cell, in pixels relative to
/// the cell's top-left. Used to position the glyph at the right baseline.
glyph_offset: [f32; 2],
/// Size of the glyph rect in pixels. The shader uses this to determine
/// whether a fragment falls inside the glyph.
glyph_size: [f32; 2],
uv_offset: [f32; 2], uv_offset: [f32; 2],
uv_size: [f32; 2], uv_size: [f32; 2],
bg_color: [f32; 4], bg_color: [f32; 4],
@ -77,14 +120,16 @@ struct CellInstance {
} }
impl CellInstance { impl CellInstance {
const ATTRS: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![ const ATTRS: [wgpu::VertexAttribute; 9] = wgpu::vertex_attr_array![
0 => Float32x2, 0 => Float32x2, // position
1 => Float32x2, 1 => Float32x2, // size
2 => Float32x2, 2 => Float32x2, // glyph_offset
3 => Float32x2, 3 => Float32x2, // glyph_size
4 => Float32x4, 4 => Float32x2, // uv_offset
5 => Float32x4, 5 => Float32x2, // uv_size
6 => Uint32, 6 => Float32x4, // bg_color
7 => Float32x4, // fg_color
8 => Uint32, // flags
]; ];
fn desc() -> wgpu::VertexBufferLayout<'static> { fn desc() -> wgpu::VertexBufferLayout<'static> {
@ -106,6 +151,13 @@ struct Uniforms {
pub struct WgpuRenderer { pub struct WgpuRenderer {
window: Arc<winit::window::Window>, window: Arc<winit::window::Window>,
/// The winit event loop. Must be kept alive and pumped every tick —
/// without pumping, the window never receives configure/expose events
/// and appears as a blank form. winit's `pump_events` lets us drive it
/// from our own main loop instead of ceding control to `EventLoop::run`.
event_loop: EventLoop<()>,
/// Tracks keyboard modifier state (updated by ModifiersChanged events).
mod_tracker: crate::ui::event::winit_translate::ModifierTracker,
surface: wgpu::Surface<'static>, surface: wgpu::Surface<'static>,
device: wgpu::Device, device: wgpu::Device,
queue: wgpu::Queue, queue: wgpu::Queue,
@ -122,10 +174,23 @@ pub struct WgpuRenderer {
/// Tracks how many glyphs are currently in the atlas. If this changes /// Tracks how many glyphs are currently in the atlas. If this changes
/// between frames, we re-upload the texture. /// between frames, we re-upload the texture.
cached_glyph_count: usize, cached_glyph_count: usize,
/// Maps each cached glyph (char, bold, italic) to its pixel rect in the
/// atlas. Built lazily by `rebuild_atlas` when new glyphs appear.
/// Keyed the same way as `GlyphCache`'s internal map.
atlas_map: std::collections::HashMap<(char, bool, bool), AtlasRect>,
pending_events: Vec<crate::ui::event::AppEvent>, pending_events: Vec<crate::ui::event::AppEvent>,
start_time: std::time::Instant, start_time: std::time::Instant,
} }
/// A glyph's placement in the atlas texture (pixel coordinates).
#[derive(Copy, Clone, Debug)]
struct AtlasRect {
x: u32,
y: u32,
w: u32,
h: u32,
}
impl WgpuRenderer { impl WgpuRenderer {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let event_loop = EventLoop::<()>::new() let event_loop = EventLoop::<()>::new()
@ -133,6 +198,12 @@ impl WgpuRenderer {
let window = Arc::new( let window = Arc::new(
WindowBuilder::new() WindowBuilder::new()
.with_title("rs-mrxvt") .with_title("rs-mrxvt")
// Give the window a real initial size. Without this, the WM
// may pick a tiny default (sometimes 1×1 or just title-bar-
// tall), which leaves the surface too small to render any
// cells and the user sees a blank form. 1024×768 is a
// conservative default that every compositor will honour.
.with_inner_size(winit::dpi::LogicalSize::<u32>::new(1024, 768))
.build(&event_loop) .build(&event_loop)
.map_err(|e| anyhow::anyhow!("creating winit window: {e}"))?, .map_err(|e| anyhow::anyhow!("creating winit window: {e}"))?,
); );
@ -344,7 +415,15 @@ impl WgpuRenderer {
})], })],
compilation_options: Default::default(), compilation_options: Default::default(),
}), }),
primitive: wgpu::PrimitiveState::default(), primitive: wgpu::PrimitiveState {
// TriangleStrip: 4 vertices → 2 triangles forming one quad.
// The default (TriangleList) would need 6 vertices for a quad
// and would drop the 4th vertex, leaving half of every glyph
// unrendered. The vertex shader's corner mapping
// (vid 0..3 → TL,TR,BL,BR) is designed for strip order.
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None, depth_stencil: None,
multisample: wgpu::MultisampleState::default(), multisample: wgpu::MultisampleState::default(),
multiview: None, multiview: None,
@ -355,6 +434,8 @@ impl WgpuRenderer {
Ok(Self { Ok(Self {
window, window,
event_loop,
mod_tracker: Default::default(),
surface, surface,
device, device,
queue, queue,
@ -367,90 +448,328 @@ impl WgpuRenderer {
glyph_atlas_view, glyph_atlas_view,
glyph_sampler, glyph_sampler,
cached_glyph_count: 0, cached_glyph_count: 0,
atlas_map: std::collections::HashMap::new(),
pending_events: Vec::new(), pending_events: Vec::new(),
start_time: std::time::Instant::now(), start_time: std::time::Instant::now(),
}) })
} }
/// Build the instance buffer for one frame: one CellInstance per visible cell. /// Build the full instance buffer for one frame: tab bar + terminal grid
/// + status bar.
///
/// All three are rendered with the same instanced quad pipeline — each
/// character is just another cell instance.
fn build_instances(&mut self, app: &App) -> Vec<CellInstance> { fn build_instances(&mut self, app: &App) -> Vec<CellInstance> {
let manager = &app.manager; let manager = &app.manager;
let active_idx = manager.active; let active_idx = manager.active;
let tab = match manager.tabs.get(active_idx) {
Some(t) => t,
None => return Vec::new(),
};
let grid = tab.term.grid();
let cols = (self.config.width / CELL_WIDTH) as usize; let cols = (self.config.width / CELL_WIDTH) as usize;
let rows = (self.config.height / CELL_HEIGHT) as usize; let total_rows = (self.config.height / CELL_HEIGHT) as usize;
let screen_lines = grid.screen_lines(); // Terminal area = total minus tab bar (top) and status bar (bottom).
let display_offset = grid.display_offset(); let term_rows = total_rows.saturating_sub(2);
let start_line = -(display_offset as i32);
let render_rows = rows.min(screen_lines);
let mut instances = Vec::with_capacity(cols * render_rows); let mut instances = Vec::with_capacity(cols * total_rows);
for row_idx in 0..render_rows as i32 { // ── Tab bar (row 0) ──────────────────────────────────────────────
let line = ALine(start_line + row_idx); instances.extend(self.build_tab_bar_instances(app, cols));
for col_idx in 0..cols {
let point = Point { line, column: Column(col_idx) };
let cell: &Cell = &grid[point];
let bg = ansi_to_linear(cell.bg); // ── Terminal grid (rows 1..=term_rows) ───────────────────────────
let fg = ansi_to_linear(cell.fg); if let Some(tab) = manager.tabs.get(active_idx) {
let grid = tab.term.grid();
let screen_lines = grid.screen_lines();
let display_offset = grid.display_offset();
let start_line = -(display_offset as i32);
let render_rows = term_rows.min(screen_lines);
let has_glyph = cell.c != ' ' && cell.c != '\0'; for row_idx in 0..render_rows as i32 {
let line = ALine(start_line + row_idx);
for col_idx in 0..cols {
let point = Point { line, column: Column(col_idx) };
let cell: &Cell = &grid[point];
let bg = ansi_to_linear(cell.bg);
let fg = ansi_to_linear(cell.fg);
let bold = cell.flags.contains(CellFlags::BOLD);
let italic = cell.flags.contains(CellFlags::ITALIC);
let has_glyph = cell.c != ' ' && cell.c != '\0';
let mut flags = 0u32;
if has_glyph {
flags |= 1;
}
if bold {
flags |= 2;
}
if italic {
flags |= 4;
}
let (uv_offset, uv_size, glyph_offset, glyph_size) = if has_glyph {
let g = self.glyph_cache.get(cell.c, bold, italic);
match self.atlas_map.get(&(cell.c, bold, italic)) {
Some(rect) if rect.w > 0 && rect.h > 0 => {
let baseline = CELL_HEIGHT as f32 - 3.0;
let gx = g.bearing_x.max(0.0);
let gy = (baseline + g.bearing_y).max(0.0);
(
[
rect.x as f32 / ATLAS_SIZE as f32,
rect.y as f32 / ATLAS_SIZE as f32,
],
[
rect.w as f32 / ATLAS_SIZE as f32,
rect.h as f32 / ATLAS_SIZE as f32,
],
[gx, gy],
[rect.w as f32, rect.h as f32],
)
}
_ => ([0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]),
}
} else {
([0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0])
};
// Offset by TAB_BAR_HEIGHT so the grid starts below the
// tab bar.
let y = (row_idx as u32 * CELL_HEIGHT + TAB_BAR_HEIGHT) as f32;
instances.push(CellInstance {
position: [(col_idx as u32 * CELL_WIDTH) as f32, y],
size: [CELL_WIDTH as f32, CELL_HEIGHT as f32],
glyph_offset,
glyph_size,
uv_offset,
uv_size,
bg_color: bg,
fg_color: fg,
flags,
});
}
}
}
// ── Status bar (bottom row) ──────────────────────────────────────
instances.extend(self.build_status_bar_instances(app, cols, total_rows));
instances
}
/// Build the tab bar instances: one row of cells at the top of the
/// window showing each tab's index and title, with the active tab
/// highlighted. Uses the same instanced-quad pipeline as the grid.
fn build_tab_bar_instances(&mut self, app: &App, cols: usize) -> Vec<CellInstance> {
let manager = &app.manager;
let active_idx = manager.active;
let total_cols = cols;
// Start with the tab bar background — fill the entire row.
let mut instances: Vec<CellInstance> = Vec::with_capacity(total_cols);
for col_idx in 0..total_cols {
instances.push(CellInstance {
position: [(col_idx as u32 * CELL_WIDTH) as f32, 0.0],
size: [CELL_WIDTH as f32, CELL_HEIGHT as f32],
glyph_offset: [0.0, 0.0],
glyph_size: [0.0, 0.0],
uv_offset: [0.0, 0.0],
uv_size: [0.0, 0.0],
bg_color: COLOR_TABBAR_BG,
fg_color: COLOR_TAB_INACTIVE_FG,
flags: 0,
});
}
// Render each tab's title starting at column 1 (leave 1-col margin).
let mut col: usize = 1;
for (i, tab) in manager.tabs.iter().enumerate() {
let is_active = i == active_idx;
// Build the tab label: " N: title " (space, index, colon, title, space)
let tag_str = tab.tag.as_ref().map(|g| format!(" [{}]", g)).unwrap_or_default();
let label = format!(" {}: {}{} ", i + 1, tab.title, tag_str);
let bg = if is_active { COLOR_TAB_ACTIVE_BG } else { COLOR_TAB_INACTIVE_BG };
// Active tab uses the cyan accent color for its text to make it
// stand out; inactive tabs use a muted gray.
let fg = if is_active { COLOR_TAB_ACCENT } else { COLOR_TAB_INACTIVE_FG };
for ch in label.chars() {
if col >= total_cols {
break;
}
let has_glyph = ch != ' ';
let mut flags = 0u32; let mut flags = 0u32;
if has_glyph { if has_glyph {
flags |= 1; flags |= 1;
} }
if cell.flags.contains(CellFlags::BOLD) { if is_active {
flags |= 2; flags |= 2; // bold for active tab
}
if cell.flags.contains(CellFlags::ITALIC) {
flags |= 4;
} }
// For the atlas UV: we use a simple layout where each glyph let (uv_offset, uv_size, glyph_offset, glyph_size) = if has_glyph {
// occupies a fixed-size slot. This is suboptimal (wastes let bold = is_active; // active tab is bold
// space) but simple. A future version can pack more tightly. let g = self.glyph_cache.get(ch, bold, false);
let (uv_offset, uv_size) = if has_glyph { match self.atlas_map.get(&(ch, bold, false)) {
// Force-rasterize to ensure the glyph is in the cache. Some(rect) if rect.w > 0 && rect.h > 0 => {
self.glyph_cache.get(cell.c, flags & 2 != 0, flags & 4 != 0); let baseline = CELL_HEIGHT as f32 - 3.0;
// Each glyph gets a CELL_WIDTH x CELL_HEIGHT slot in the atlas. let gx = g.bearing_x.max(0.0);
// We index by the char's Unicode scalar value mod (ATLAS_SIZE / CELL_WIDTH). let gy = (baseline + g.bearing_y).max(0.0);
let slot_w = ATLAS_SIZE / CELL_WIDTH; (
let slot_h = ATLAS_SIZE / CELL_HEIGHT; [
let char_idx = cell.c as u32; rect.x as f32 / ATLAS_SIZE as f32,
let sx = (char_idx % slot_w) * CELL_WIDTH; rect.y as f32 / ATLAS_SIZE as f32,
let sy = ((char_idx / slot_w) % slot_h) * CELL_HEIGHT; ],
( [
[sx as f32 / ATLAS_SIZE as f32, sy as f32 / ATLAS_SIZE as f32], rect.w as f32 / ATLAS_SIZE as f32,
[CELL_WIDTH as f32 / ATLAS_SIZE as f32, CELL_HEIGHT as f32 / ATLAS_SIZE as f32], rect.h as f32 / ATLAS_SIZE as f32,
) ],
[gx, gy],
[rect.w as f32, rect.h as f32],
)
}
_ => ([0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]),
}
} else { } else {
([0.0, 0.0], [0.0, 0.0]) ([0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0])
}; };
instances.push(CellInstance { // Overwrite the background instance at this column.
position: [(col_idx as u32 * CELL_WIDTH) as f32, (row_idx as u32 * CELL_HEIGHT) as f32], instances[col] = CellInstance {
position: [(col as u32 * CELL_WIDTH) as f32, 0.0],
size: [CELL_WIDTH as f32, CELL_HEIGHT as f32], size: [CELL_WIDTH as f32, CELL_HEIGHT as f32],
glyph_offset,
glyph_size,
uv_offset, uv_offset,
uv_size, uv_size,
bg_color: bg, bg_color: bg,
fg_color: fg, fg_color: fg,
flags, flags,
}); };
col += 1;
}
// Separator between tabs (a thin dim column).
if col < total_cols && i < manager.tabs.len() - 1 {
instances[col] = CellInstance {
position: [(col as u32 * CELL_WIDTH) as f32, 0.0],
size: [CELL_WIDTH as f32, CELL_HEIGHT as f32],
glyph_offset: [0.0, 0.0],
glyph_size: [0.0, 0.0],
uv_offset: [0.0, 0.0],
uv_size: [0.0, 0.0],
bg_color: COLOR_TAB_SEPARATOR,
fg_color: COLOR_TAB_SEPARATOR,
flags: 0,
};
col += 1;
} }
} }
instances instances
} }
/// Build the status bar instances: broadcast indicator, tab count,
/// active tab index. Rendered as the bottom row of the window.
fn build_status_bar_instances(&mut self, app: &App, cols: usize, total_rows: usize) -> Vec<CellInstance> {
let manager = &app.manager;
let active_idx = manager.active;
let y = (total_rows.saturating_sub(1) as u32 * CELL_HEIGHT) as f32;
// Build the status text.
let (status_text, text_color) = match &manager.broadcast {
BroadcastTarget::Active => (
format!(" rs-mrxvt | tabs={} active={}/{} ", manager.tabs.len(), active_idx + 1, manager.tabs.len()),
COLOR_STATUS_FG,
),
BroadcastTarget::All => (
format!(" BROADCAST:ALL | tabs={} ", manager.tabs.len()),
COLOR_BROADCAST_ALL,
),
BroadcastTarget::Group(g) => (
format!(" BROADCAST:{} | tabs={} ", g, manager.tabs.len()),
COLOR_BROADCAST_GROUP,
),
};
let mut instances: Vec<CellInstance> = Vec::with_capacity(cols);
// Fill the status bar background.
for col_idx in 0..cols {
instances.push(CellInstance {
position: [(col_idx as u32 * CELL_WIDTH) as f32, y],
size: [CELL_WIDTH as f32, CELL_HEIGHT as f32],
glyph_offset: [0.0, 0.0],
glyph_size: [0.0, 0.0],
uv_offset: [0.0, 0.0],
uv_size: [0.0, 0.0],
bg_color: COLOR_STATUS_BG,
fg_color: text_color,
flags: 0,
});
}
// Render the status text over the background.
for (col, ch) in status_text.chars().enumerate() {
if col >= cols {
break;
}
let has_glyph = ch != ' ';
let mut flags = 0u32;
if has_glyph {
flags |= 1;
}
let (uv_offset, uv_size, glyph_offset, glyph_size) = if has_glyph {
let g = self.glyph_cache.get(ch, false, false);
match self.atlas_map.get(&(ch, false, false)) {
Some(rect) if rect.w > 0 && rect.h > 0 => {
let baseline = CELL_HEIGHT as f32 - 3.0;
let gx = g.bearing_x.max(0.0);
let gy = (baseline + g.bearing_y).max(0.0);
(
[
rect.x as f32 / ATLAS_SIZE as f32,
rect.y as f32 / ATLAS_SIZE as f32,
],
[
rect.w as f32 / ATLAS_SIZE as f32,
rect.h as f32 / ATLAS_SIZE as f32,
],
[gx, gy],
[rect.w as f32, rect.h as f32],
)
}
_ => ([0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]),
}
} else {
([0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0])
};
instances[col] = CellInstance {
position: [(col as u32 * CELL_WIDTH) as f32, y],
size: [CELL_WIDTH as f32, CELL_HEIGHT as f32],
glyph_offset,
glyph_size,
uv_offset,
uv_size,
bg_color: COLOR_STATUS_BG,
fg_color: text_color,
flags,
};
}
instances
}
/// If new glyphs have been rasterized since the last upload, re-upload /// If new glyphs have been rasterized since the last upload, re-upload
/// the atlas texture. This is O(glyph_count) per upload; we only upload /// the atlas texture with all cached glyphs packed in. Rebuilds
/// when the count changed. /// `atlas_map` so `build_instances` can find each glyph's UV rect.
///
/// Uses a simple row-based shelf packing: glyphs are placed left-to-right
/// in the current row; when a glyph won't fit, move to the next row.
/// This is O(glyph_count) and only runs when the cache grows.
fn maybe_upload_atlas(&mut self) { fn maybe_upload_atlas(&mut self) {
let count = self.glyph_cache.len(); let count = self.glyph_cache.len();
if count == self.cached_glyph_count { if count == self.cached_glyph_count {
@ -458,40 +777,72 @@ impl WgpuRenderer {
} }
self.cached_glyph_count = count; self.cached_glyph_count = count;
// Build the atlas as a single R8 buffer. For simplicity, we use the // Clear the atlas and the map — we rebuild both from scratch.
// fixed-slot layout: glyph for char C lives at
// (C % slot_w) * CELL_WIDTH, (C / slot_w) % slot_h) * CELL_HEIGHT.
let mut atlas = vec![0u8; (ATLAS_SIZE * ATLAS_SIZE) as usize]; let mut atlas = vec![0u8; (ATLAS_SIZE * ATLAS_SIZE) as usize];
self.atlas_map.clear();
// We don't have direct access to the cache's internals here, so we // Shelf-packing state.
// re-rasterize every glyph into the atlas. This is wasteful but let mut cur_x: u32 = 0;
// correct; a future version will expose an iterator over the cache. let mut cur_y: u32 = 0;
// For the MVP, this only runs when the cache size changes (rare). let mut row_height: u32 = 0;
for codepoint in 0u32..0x80 {
// Only ASCII for the MVP; full Unicode would iterate the cache. // Iterate every cached glyph and blit it into the atlas.
let c = char::from_u32(codepoint).unwrap_or('?'); for ((c, bold, italic), g) in self.glyph_cache.iter() {
if c == ' ' || c == '\0' { let gw = g.width as u32;
let gh = g.height as u32;
// Skip empty glyphs (e.g. spaces, missing outlines). We still
// record a zero-size rect so build_instances can look them up
// without re-triggering the cache check.
if gw == 0 || gh == 0 {
self.atlas_map.insert((c, bold, italic), AtlasRect { x: 0, y: 0, w: 0, h: 0 });
continue; continue;
} }
let g = self.glyph_cache.get(c, false, false);
let slot_w = ATLAS_SIZE / CELL_WIDTH;
let sx = (codepoint % slot_w) * CELL_WIDTH;
let sy = ((codepoint / slot_w) % (ATLAS_SIZE / CELL_HEIGHT)) * CELL_HEIGHT;
for gy in 0..g.height { let padded_w = gw + ATLAS_PADDING;
for gx in 0..g.width { let padded_h = gh + ATLAS_PADDING;
let px = sx as usize + gx;
let py = sy as usize + gy; // Does it fit on the current row?
if px < ATLAS_SIZE as usize && py < ATLAS_SIZE as usize { if cur_x + padded_w > ATLAS_SIZE {
let gidx = (gy * g.width + gx) * 4 + 3; // alpha channel // Move to the next row.
let alpha = g.pixels[gidx]; cur_y += row_height;
let aidx = py * ATLAS_SIZE as usize + px; cur_x = 0;
atlas[aidx] = alpha; row_height = 0;
} }
// Does it fit vertically? If not, the atlas is full. Skip the
// glyph (it won't render, but we won't crash).
if cur_y + padded_h > ATLAS_SIZE {
log::warn!("glyph atlas full ({}x{}); skipping glyph {:?}", ATLAS_SIZE, ATLAS_SIZE, c);
self.atlas_map.insert((c, bold, italic), AtlasRect { x: 0, y: 0, w: 0, h: 0 });
continue;
}
let rect = AtlasRect { x: cur_x, y: cur_y, w: gw, h: gh };
self.atlas_map.insert((c, bold, italic), rect);
// Blit the glyph's alpha channel into the atlas.
for gy in 0..gh {
for gx in 0..gw {
let px = (cur_x + gx) as usize;
let py = (cur_y + gy) as usize;
let gidx = (gy as usize * gw as usize + gx as usize) * 4 + 3;
let alpha = g.pixels[gidx];
let aidx = py * ATLAS_SIZE as usize + px;
atlas[aidx] = alpha;
} }
} }
cur_x += padded_w;
row_height = row_height.max(padded_h);
} }
log::debug!(
"atlas rebuilt: {} glyphs, packed to row y={}",
self.atlas_map.len(),
cur_y + row_height
);
self.queue.write_texture( self.queue.write_texture(
wgpu::ImageCopyTexture { wgpu::ImageCopyTexture {
texture: &self.glyph_atlas_texture, texture: &self.glyph_atlas_texture,
@ -516,6 +867,31 @@ impl WgpuRenderer {
impl Renderer for WgpuRenderer { impl Renderer for WgpuRenderer {
fn init(&mut self) -> Result<()> { fn init(&mut self) -> Result<()> {
// Pump the event loop briefly so the window receives its initial
// configure event and gets mapped by the compositor. Without this,
// the first render() call may run before the surface is ready
// (especially on Wayland), causing get_current_texture to fail.
let mut tracker = self.mod_tracker;
let _ = self.event_loop.pump_events(
Some(std::time::Duration::from_millis(50)),
|event, elwt| {
let _ = crate::ui::event::winit_translate::translate_event(
&event, &mut tracker, elwt,
);
},
);
self.mod_tracker = tracker;
// Now that the window has its initial size, configure the surface
// to match. This avoids a wasted first frame.
let size = self.window.inner_size();
if size.width > 0 && size.height > 0 {
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
log::debug!("wgpu surface configured on init: {}x{}", size.width, size.height);
}
Ok(()) Ok(())
} }
@ -523,26 +899,62 @@ impl Renderer for WgpuRenderer {
Ok(()) Ok(())
} }
fn poll_event(&mut self, _timeout_ms: u64) -> Result<Option<crate::ui::event::AppEvent>> { fn poll_event(&mut self, timeout_ms: u64) -> Result<Option<crate::ui::event::AppEvent>> {
// winit event loop integration is deferred (see soft.rs for the same // If we have buffered events from a previous pump, return the oldest.
// caveat). For now we return None; the user can close the window if !self.pending_events.is_empty() {
// via the WM. return Ok(self.pending_events.drain(..).next());
if self.pending_events.is_empty() { }
// Pump the winit event loop. This processes window events (resize,
// keyboard, mouse, close) for up to `timeout_ms`, translating each
// into an `AppEvent` via the winit_translate module. Without this
// pump call, the window never gets its initial configure event and
// appears as a blank form.
//
// We collect into a local Vec because `pump_events` borrows
// `self.event_loop` mutably, preventing the closure from capturing
// `self.pending_events` directly.
let mut collected: Vec<crate::ui::event::AppEvent> = Vec::new();
let mut tracker = self.mod_tracker;
let timeout = Some(std::time::Duration::from_millis(timeout_ms));
let status = self.event_loop.pump_events(timeout, |event, elwt| {
if let Some(app_ev) = crate::ui::event::winit_translate::translate_event(
&event, &mut tracker, elwt,
) {
collected.push(app_ev);
}
});
self.mod_tracker = tracker;
// If the event loop is exiting (window close button, etc.), signal quit.
if let PumpStatus::Exit(_) = status {
collected.push(crate::ui::event::AppEvent::Quit);
}
// Return the first event, if any. The rest stay buffered for next call.
if collected.is_empty() {
Ok(None) Ok(None)
} else { } else {
Ok(self.pending_events.drain(..).next()) let first = collected.remove(0);
self.pending_events.extend(collected);
Ok(Some(first))
} }
} }
fn render(&mut self, app: &mut App) -> Result<()> { fn render(&mut self, app: &mut App) -> Result<()> {
// Resize surface if the window changed. // Resize surface if the window changed. We also force a reconfigure
// when width/height are still 0 (first frame, or the WM hasn't sent
// the initial configure event yet — rare but possible on Wayland).
let size = self.window.inner_size(); let size = self.window.inner_size();
if size.width > 0 && size.height > 0 let needs_configure = size.width > 0 && size.height > 0
&& (size.width != self.config.width || size.height != self.config.height) && (size.width != self.config.width || size.height != self.config.height);
{ if needs_configure {
self.config.width = size.width; self.config.width = size.width;
self.config.height = size.height; self.config.height = size.height;
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
log::debug!("wgpu surface reconfigured to {}x{}", size.width, size.height);
} }
// Update uniforms. // Update uniforms.
@ -571,7 +983,12 @@ impl Renderer for WgpuRenderer {
let output = match self.surface.get_current_texture() { let output = match self.surface.get_current_texture() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
log::debug!("get_current_texture failed, skipping frame: {e}"); // This happens when the surface hasn't been configured yet
// (first frame on Wayland, before the initial configure event)
// or after the window is minimized. Re-configure on next tick
// and skip this frame. Log at info level the first time so the
// user can see what's happening if they're watching logs.
log::info!("get_current_texture failed (will retry): {e}");
return Ok(()); return Ok(());
} }
}; };
@ -590,10 +1007,10 @@ impl Renderer for WgpuRenderer {
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.02, r: COLOR_BG[0] as f64,
g: 0.04, g: COLOR_BG[1] as f64,
b: 0.02, b: COLOR_BG[2] as f64,
a: 1.0, a: COLOR_BG[3] as f64,
}), }),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
@ -618,9 +1035,16 @@ impl Renderer for WgpuRenderer {
} }
fn size(&self) -> (u16, u16) { fn size(&self) -> (u16, u16) {
// The wgpu renderer draws a tab bar at the top and a status bar at
// the bottom, each one cell row tall. The terminal grid gets the
// space between them. The PTY is told exactly how many rows fit in
// the terminal area so the shell's line wrapping matches what's
// on screen.
let cols = (self.config.width / CELL_WIDTH).max(2) as u16; let cols = (self.config.width / CELL_WIDTH).max(2) as u16;
let rows = (self.config.height / CELL_HEIGHT).saturating_sub(2).max(1) as u16; let total_rows = self.config.height / CELL_HEIGHT;
(cols, rows) // Subtract 2 rows: tab bar (top) + status bar (bottom).
let term_rows = total_rows.saturating_sub(2).max(1) as u16;
(cols, term_rows)
} }
} }