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:
parent
fd93c27264
commit
74dcb9f19b
|
|
@ -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
|
|
||||||
157
src/ui/event.rs
157
src/ui/event.rs
|
|
@ -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::*;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
622
src/ui/wgpu.rs
622
src/ui/wgpu.rs
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue