Merge pull request #40 from levineuwirth/worktree-pmacs-gpu-hello-world

Session 2: pmacs-gpu workspace + wgpu/winit/glyphon hello-world
This commit is contained in:
Levi Neuwirth 2026-05-20 15:48:42 +00:00 committed by GitHub
commit 6a9aa57f69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 2687 additions and 23 deletions

2191
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
[workspace]
# Session 1 of the pmacs-gpu arc: `pmacs-protocol` is the wire-types
# crate the post-v1.0 frontend (`pmacs-gpu`) will consume directly. The
# root `pmacs` package stays a workspace member (no file moves); the
# new crate lives under `pmacs-protocol/`. See
# `docs/pmacs-gpu-design.md`.
members = [".", "pmacs-protocol"]
# Session 1 of the pmacs-gpu arc landed `pmacs-protocol` as a workspace
# member (the wire-types crate). Session 2 (this) adds `pmacs-gpu` as
# the GPU/GUI frontend binary — wgpu/winit/cosmic-text/glyphon — with
# hello-world rendering. Protocol consumption arrives in session 3.
# See `docs/pmacs-gpu-design.md`.
members = [".", "pmacs-protocol", "pmacs-gpu"]
[workspace.dependencies]
# Shared between `pmacs` and `pmacs-protocol`. Pinned here so both

View File

@ -273,8 +273,15 @@ Out of scope:
Stance (γ): **bundle JetBrains Mono as default; expose Lua hook for
override.**
- Bundled font: JetBrains Mono (Apache 2.0; broad glyph coverage;
designed for code; ~1.5MB; dwarfed by `wgpu`'s footprint).
- Bundled font: JetBrains Mono (SIL Open Font License 1.1; broad
glyph coverage; designed for code; ~270 KB for the Regular weight,
dwarfed by `wgpu`'s footprint). **Correction:** the framing pass
recorded the license as Apache 2.0, but the actual license has
been OFL since the family's open-source release. Session 2
surfaced this when fetching the real asset for the bundle —
classified as a small finding under rule (iii) and absorbed; the
bundled `fonts/OFL.txt` is shipped alongside the TTF as required
by the OFL.
- Lua override: `pmacs.gpu.set_font(path)` or similar (precise binding
shape decided session 2).
- Missing-glyph fallback: tofu (replacement character `U+FFFD`).

53
pmacs-gpu/Cargo.toml Normal file
View File

@ -0,0 +1,53 @@
[package]
name = "pmacs-gpu"
version = "0.0.1"
edition = "2024"
rust-version = "1.95"
description = "GPU/GUI frontend for pmacs (session 2: hello-world). See docs/pmacs-gpu-design.md."
license = "MIT OR Apache-2.0"
authors = ["Pmacs contributors"]
readme = "../README.md"
repository = "https://git.levineuwirth.org/neuwirth/pmacs"
homepage = "https://levineuwirth.org/essays/pmacs"
keywords = ["editor", "emacs", "gpu", "gui"]
categories = ["text-editors"]
publish = false
[[bin]]
name = "pmacs-gpu"
path = "src/main.rs"
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
# Allows: same set as the root `pmacs` crate, mirrored so the GPU
# code doesn't trip lints the rest of the project doesn't either.
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
cast_possible_truncation = "allow"
cast_sign_loss = "allow"
cast_precision_loss = "allow"
similar_names = "allow"
multiple_crate_versions = "allow"
[dependencies]
env_logger = "0.11.10"
# Text shaping + GPU rendering. `glyphon` re-exports the `cosmic-text`
# types it pins (`Buffer`, `Attrs`, `Family`, `FontSystem`,
# `Metrics`, `Shaping`, `SwashCache`, ...); we use those re-exports
# rather than depending on `cosmic-text` directly so the build never
# ends up with two cosmic-text versions resolving to the same name.
glyphon = "0.11.0"
# Session 1's wire-types crate. Pulled in now so the dep graph is
# settled from session 2 forward; protocol consumption itself lands
# in session 3.
pmacs-protocol = { version = "1.0.0", path = "../pmacs-protocol" }
pollster = "0.4.0"
wgpu = "29.0.3"
winit = "0.30.13"

Binary file not shown.

93
pmacs-gpu/fonts/OFL.txt Normal file
View File

@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

350
pmacs-gpu/src/main.rs Normal file
View File

@ -0,0 +1,350 @@
//! pmacs-gpu — GPU/GUI frontend for pmacs.
//!
//! Session 2 of the pmacs-gpu arc (`docs/pmacs-gpu-design.md`):
//! **hello-world binary**. Opens a window via `winit`, initializes
//! `wgpu` against its surface, sets up `glyphon` text rendering with
//! the bundled `JetBrains` Mono font, and renders "hello, pmacs" once
//! per frame. No protocol consumption yet — that arrives in session
//! 3 (the attach loop). No editor state, no input handling beyond
//! close + Escape.
//!
//! The bundled font is `JetBrains` Mono Regular, distributed under
//! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). The design
//! note incorrectly recorded the license as Apache 2.0; the actual
//! license has been OFL since the family's open-source release. The
//! design-doc note will be corrected as part of this session.
use std::sync::Arc;
use glyphon::{
Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache,
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
};
use wgpu::MultisampleState;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, KeyEvent, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::{Window, WindowId};
/// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`).
const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf");
/// Initial window size in logical pixels. Session 2 is fixed-size for
/// simplicity; resizes still work, this is just the boot dimension.
const INITIAL_WIDTH: u32 = 800;
const INITIAL_HEIGHT: u32 = 200;
/// Color the surface clears to before text renders.
const BG: wgpu::Color = wgpu::Color {
r: 0.05,
g: 0.05,
b: 0.07,
a: 1.0,
};
/// Hello-world payload. Stays inert here — session 3 wires this to
/// the daemon's `BufferSnapshot` instead.
const HELLO_TEXT: &str = "hello, pmacs";
fn main() {
// wgpu emits useful trace output on adapter selection / surface
// configuration. The default `RUST_LOG=info` is fine for
// development.
env_logger::init();
let event_loop = EventLoop::new().expect("create winit event loop");
let mut app = App { state: None };
event_loop
.run_app(&mut app)
.expect("winit event loop run_app");
}
/// Top-level application handler. Holds an `Option<State>` because
/// winit 0.30 requires the window + GPU resources to be created
/// *after* `resumed()` fires, not at `main()` start.
struct App {
state: Option<State>,
}
/// All resources owned by a single running pmacs-gpu instance: the
/// window, the wgpu device/queue/surface, the glyphon stack, and the
/// shaped text buffer.
struct State {
window: Arc<Window>,
// wgpu plumbing.
device: wgpu::Device,
queue: wgpu::Queue,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
// glyphon plumbing.
font_system: FontSystem,
swash_cache: SwashCache,
viewport: Viewport,
atlas: TextAtlas,
text_renderer: TextRenderer,
buffer: Buffer,
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
// `resumed` can fire more than once on platforms that
// suspend/restore (e.g. mobile). The hello-world doesn't
// reinitialize on resume; first call wins.
return;
}
self.state = Some(State::new(event_loop));
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested
| WindowEvent::KeyboardInput {
event:
KeyEvent {
logical_key: Key::Named(NamedKey::Escape),
state: ElementState::Pressed,
..
},
..
} => event_loop.exit(),
WindowEvent::Resized(size) => state.resize(size.width.max(1), size.height.max(1)),
WindowEvent::RedrawRequested => state.render(),
_ => {}
}
}
}
impl State {
fn new(event_loop: &ActiveEventLoop) -> Self {
let window = Arc::new(
event_loop
.create_window(
Window::default_attributes()
.with_title("pmacs-gpu hello-world")
.with_inner_size(winit::dpi::LogicalSize::new(
f64::from(INITIAL_WIDTH),
f64::from(INITIAL_HEIGHT),
)),
)
.expect("create window"),
);
// wgpu instance + surface.
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let surface = instance
.create_surface(window.clone())
.expect("create surface");
// Pick an adapter that supports our surface. Power preference
// = LowPower because the hello-world has no GPU appetite;
// saves laptop battery during development.
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}))
.expect("request_adapter");
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("pmacs-gpu device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..wgpu::DeviceDescriptor::default()
}))
.expect("request_device");
// Configure the surface. Pick the first format the surface
// and adapter both like; glyphon handles colorspace conversion
// internally, so sRGB vs UNORM is the renderer's concern, not
// ours at this layer.
let inner_size = window.inner_size();
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(wgpu::TextureFormat::is_srgb)
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: inner_size.width.max(1),
height: inner_size.height.max(1),
present_mode: wgpu::PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
};
surface.configure(&device, &config);
// glyphon plumbing — `FontSystem` owns the font database and
// shaper state; we register the bundled JetBrains Mono before
// anything tries to shape with it.
let mut font_system = FontSystem::new();
font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec());
let swash_cache = SwashCache::new();
let cache = Cache::new(&device);
let mut viewport = Viewport::new(&device, &cache);
viewport.update(
&queue,
Resolution {
width: config.width,
height: config.height,
},
);
let mut atlas = TextAtlas::new(&device, &queue, &cache, surface_format);
let text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
// Shape "hello, pmacs" with JetBrains Mono at a comfortable
// hello-world size. cosmic-text 0.18 (glyphon's pinned
// version) threads `&mut FontSystem` through every Buffer
// mutator that needs to re-shape; the 5th `None` on `set_text`
// is the optional `Align` (we let cosmic-text default it).
let mut buffer = Buffer::new(&mut font_system, Metrics::new(48.0, 56.0));
buffer.set_size(
&mut font_system,
Some(config.width as f32),
Some(config.height as f32),
);
buffer.set_text(
&mut font_system,
HELLO_TEXT,
&Attrs::new().family(Family::Name("JetBrains Mono")),
Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut font_system, false);
Self {
window,
device,
queue,
surface,
config,
font_system,
swash_cache,
viewport,
atlas,
text_renderer,
buffer,
}
}
/// Reconfigure surface + glyphon viewport on window-size change.
/// The shaped text buffer also gets a new max-size so wrap and
/// scroll align with the new viewport.
fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.viewport
.update(&self.queue, Resolution { width, height });
self.buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(height as f32),
);
self.window.request_redraw();
}
/// One frame: clear the surface to `BG`, render the text buffer,
/// present. Acquisition failures cause a re-configure and skip
/// the frame (a typical recovery for transient surface losses).
fn render(&mut self) {
// wgpu 29 collapses success/error into a single enum
// (`CurrentSurfaceTexture`), not `Result<SurfaceTexture, SurfaceError>`
// as earlier versions did. Lost / Outdated trigger a
// re-configure and skip the frame; Suboptimal is rendered
// through but flagged for the next configure cycle (we don't
// act on it in the hello-world).
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(frame)
| wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => {
self.surface.configure(&self.device, &self.config);
return;
}
wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
// `Timeout`: transient acquisition stall — drop this
// frame, try again next redraw.
// `Occluded`: window minimized / behind another
// window — skip the frame, save the GPU work.
return;
}
wgpu::CurrentSurfaceTexture::Validation => {
eprintln!("surface acquisition raised a validation error");
return;
}
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
self.text_renderer
.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
[TextArea {
buffer: &self.buffer,
left: 24.0,
top: 60.0,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
// Surface dimensions are u32 but `TextBounds`
// is i32; `cast_signed` keeps the bit pattern
// and is correct for typical window sizes well
// below 2^31.
right: self.config.width.cast_signed(),
bottom: self.config.height.cast_signed(),
},
default_color: Color::rgb(230, 230, 235),
custom_glyphs: &[],
}],
&mut self.swash_cache,
)
.expect("text_renderer prepare");
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("pmacs-gpu frame encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("pmacs-gpu hello-world pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(BG),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: None,
multiview_mask: None,
});
self.text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
.expect("text_renderer render");
}
self.queue.submit(std::iter::once(encoder.finish()));
frame.present();
self.atlas.trim();
}
}