preparing the window rendering

This commit is contained in:
2025-07-18 20:14:08 +02:00
parent aed22a3dbc
commit 17794aba0b
7 changed files with 455 additions and 25 deletions

84
project.md Normal file
View File

@@ -0,0 +1,84 @@
# Module Breakdown and Responsibilities
## 1. src/pty.rs (The Shell Interface)
Responsibilities:
- Use portable-pty to create a new PTY.
- Spawn a default shell process (e.g., bash) connected to the PTY.
- Provide a way to write user input (from winit) to the PTY.
- Provide a way to read shell output from the PTY.
Key Implementation Detail: Reading from the PTY is a blocking operation. You
should do this on a separate thread. This thread will read data and send it back
to the main UI thread using a channel (like crossbeam-channel). This prevents
the UI from freezing while waiting for the shell to output something.
## 2. src/terminal.rs (The Brains / State Model)
Responsibilities:
- Hold the state of the terminal grid: a 2D array of cells, where each cell
contains a character, foreground color, background color, and style flags
(bold, italic, etc.).
- Contain a vte::Parser.
- Implement the vte::Perform trait. This is the core of this module. The vte
parser will call methods on your implementation as it processes the byte
stream from the PTY. For example:
- print(char): You update the character at the current cursor position in your
grid and advance the cursor.
- execute(byte): You handle control characters like newline (\n) or backspace
(\b).
- csi_dispatch(...): You handle complex ANSI escape sequences for changing
colors, moving the cursor, clearing the screen, etc.
- Keep track of the cursor's position and the current color/style settings.
## 3. src/renderer.rs (The Painter)
Responsibilities:
- Initialize wgpu: get an adapter and device, configure a surface on the winit
window.
- Initialize glyphon: create a FontSystem, Cache, Atlas, and TextRenderer.
- Contain the main render() function. This function will be called on every
frame.
- Inside render():
- Get the current terminal state.
- Iterate through the terminal grid.
- For each line of text, create a glyphon::Buffer. Set its text content,
colors, and styles based on the data in your grid.
- Use the glyphon::TextRenderer to prepare and draw all the buffers to the
wgpu surface.
- Draw the cursor as a solid block or underline.
## 4. src/main.rs (The Conductor)
Responsibilities:
- The main function.
- Initialize logging (env_logger).
- Create the winit event loop and window.
- Initialize your Pty, Terminal, and Renderer modules.
- Start the PTY reader thread.
- Run the main event loop.
### The Main Event Loop (winit::event_loop::run)
This is where everything comes together. You'll have a match statement to handle
different events:
- Event::WindowEvent::Resized: Tell the renderer about the new size. You'll also
need to inform the PTY of the new dimensions so that applications running in
the shell (like vim) can reflow correctly.
- Event::WindowEvent::KeyboardInput: Translate the keyboard event into
characters or escape sequences and write them to the pty module.
- Event::UserEvent: This is how your PTY thread will communicate with the main
thread. When the PTY thread reads new data, it sends it through the channel,
and you receive it here. You then feed this data into the terminal.vte_parser.
- Event::WindowEvent::RedrawRequested: This is the signal to render a new frame.
You call your renderer.render() function here. After processing PTY data, you
should always request a redraw.
- Event::WindowEvent::CloseRequested: Terminate the application.

View File

@@ -1,43 +1,111 @@
use crossbeam_channel::Receiver;
use log::{debug, error, info, trace, warn};
use std::sync::Arc;
use tokio::runtime::Runtime;
use vte::Parser;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::window::Window;
use winit::{
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoopProxy},
window::{Window, WindowId},
};
mod pty;
mod renderer;
mod terminal;
pub struct Application {
// window_state: Option<rendering::State>,
runtime: Runtime,
output_receiver: crossbeam_channel::Receiver<String>,
#[derive(Debug)]
pub enum UserEvent {
PtyOutput(String),
}
struct TermState {
pty: pty::Pty,
terminal: terminal::Terminal,
renderer: renderer::Renderer,
vte_parser: vte::Parser,
}
pub struct Application {
runtime: Runtime,
event_loop_proxy: EventLoopProxy<UserEvent>,
term_state: Option<TermState>,
}
impl Application {
pub fn new() -> Self {
let (send, recv) = crossbeam_channel::unbounded();
pub fn new(event_loop_proxy: EventLoopProxy<UserEvent>) -> Self {
trace!("Creating new Application");
Self {
// window_state: None,
runtime: Runtime::new().expect("Failed to create tokio runtime"),
output_receiver: recv,
pty: pty::Pty::new(send),
event_loop_proxy,
term_state: None,
}
}
fn start_pty_output_recv_thread(&self, recv: Receiver<String>) {
let proxy = self.event_loop_proxy.clone();
std::thread::spawn(move || {
while let Ok(output) = recv.recv() {
trace!("Read from the PTY output: {}", output);
proxy.send_event(UserEvent::PtyOutput(output)).ok();
}
});
}
}
impl winit::application::ApplicationHandler for Application {
fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
todo!()
impl winit::application::ApplicationHandler<UserEvent> for Application {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.term_state.is_some() {
return;
}
// Set up window
let (width, height) = (800, 600);
let window_attributes = Window::default_attributes()
.with_inner_size(LogicalSize::new(width as f64, height as f64))
.with_title("Terminal emulator");
debug!("Creating a 800x600 window");
let window = Arc::new(event_loop.create_window(window_attributes).unwrap());
trace!("Creating crossbeam channel for PTY output rendering");
let (send, recv) = crossbeam_channel::unbounded();
self.term_state = Some(TermState {
pty: pty::Pty::new(send),
terminal: terminal::Terminal::new(),
renderer: self.runtime.block_on(renderer::Renderer::new(window)),
vte_parser: vte::Parser::new(),
});
self.start_pty_output_recv_thread(recv);
}
fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) {
if let Some(state) = &mut self.term_state {
match event {
UserEvent::PtyOutput(output) => {
for byte in output.bytes() {
state.vte_parser.advance(&mut state.terminal, &[byte]);
}
state.renderer.request_redraw();
}
}
}
}
fn window_event(
&mut self,
event_loop: &winit::event_loop::ActiveEventLoop,
_window_id: winit::window::WindowId,
_event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
todo!()
if let Some(state) = &mut self.term_state {
match event {
WindowEvent::RedrawRequested => {
state.renderer.render(&state.terminal);
}
_ => (),
}
}
}
}

View File

@@ -1,5 +1,5 @@
use crossbeam_channel::Sender;
use portable_pty::{Child, CommandBuilder, PtySize, PtySystem, native_pty_system};
use portable_pty::{Child, CommandBuilder, PtySize, native_pty_system};
use std::io::{Read, Write};
use std::thread;
@@ -8,6 +8,17 @@ pub struct Pty {
child: Box<dyn Child>,
}
pub enum PtyError {
Io(std::io::Error),
}
//
// You need this `From` implementation!
impl From<std::io::Error> for PtyError {
fn from(err: std::io::Error) -> Self {
PtyError::Io(err)
}
}
impl Pty {
pub fn new(output_sender: crossbeam_channel::Sender<String>) -> Self {
let pty_system = native_pty_system();
@@ -46,9 +57,9 @@ impl Pty {
Self { writer, child }
}
pub fn send_command(&self, cmd: String) {
// self.writer.write_all(cmd.as_bytes());
todo!()
pub fn send_command(&mut self, cmd: String) -> Result<(), PtyError> {
self.writer.write_all(cmd.as_bytes())?;
Ok(())
}
}

181
src/app/renderer.rs Normal file
View File

@@ -0,0 +1,181 @@
use log::{debug, trace};
use std::sync::Arc;
use wgpu::{
CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance,
InstanceDescriptor, Queue, RenderPassColorAttachment, RenderPassDescriptor,
RequestAdapterOptions, Surface, SurfaceConfiguration, SurfaceError, TextureFormat,
TextureViewDescriptor,
};
use winit::dpi::PhysicalSize;
use winit::window::Window;
pub struct Renderer {
surface: Surface<'static>,
device: Device,
queue: Queue,
config: SurfaceConfiguration,
size: PhysicalSize<u32>,
/// text: terminal_text::TerminalText,
window: Arc<Window>,
}
impl Renderer {
pub async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
// The instance is needed to create an `Adapter` and a `Surface`
// Backends::all => Vulkan + Metal + DX12 + Browser WebGPU
let instance = Instance::new(&InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
// This is the part of the window where we draw
// The window needs to implement the `HasRawWindowHandle` trait
// We also need the surface to request the `Adapter`
let surface = instance
.create_surface(window.clone())
.expect("Failed to create surface");
// The adapter is the handle to our graphics card. We can get information about the device
// with this. We will use it to create the `Device` and `Queue` later
// We could enumerate the adapters and find the one that suits us:
//
// let adapter = instance
// .enumerate_adapters(wgpu::Backends::all())
// .filter(|adapter| {
// // Check if this adapter supports our surface
// adapter.is_surface_supported(&surface)
// })
// .next()
// .unwrap()
//
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: wgpu::PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.unwrap();
let (device, queue) = adapter
.request_device(&DeviceDescriptor {
// The features field allows to specify the extra features we want
// The graphics card you have limits the features you can use. If you want to use certain
// features, you may need to limit what devices you support or provide workarounds.
// You can get a list of features supported by your device using `adapter.features()` or
// `device.features()`.
// See: https://docs.rs/wgpu/latest/wgpu/struct.Features.html
required_features: wgpu::Features::empty(),
// The limits field describes the limits of certain types of resources we can
// create. See: https://docs.rs/wgpu/latest/wgpu/struct.Limits.html
required_limits: wgpu::Limits::default(),
label: None,
// Provides the adapter with the preferred memory allocation strategy.
// See: https://wgpu.rs/doc/wgpu/enum.MemoryHints.html
memory_hints: Default::default(),
trace: wgpu::Trace::Off,
})
.await
.unwrap();
// let surface_caps = surface.get_capabilities(&adapter);
// // This will define how the surface creates its underlying `SurfaceTexture`
// // This assumes an sRGB surface texture. Using a different one will result in all the colors
// // coming out darker. If we want to support non sRGB surfaces, we'll need to account for that
// // when drawing to the frame.
// let surface_format = surface_caps
// .formats
// .iter()
// .find(|f| f.is_srgb())
// .copied()
// .unwrap_or(surface_caps.formats[0]); // We get the first sRGB format
let surface_format = TextureFormat::Bgra8UnormSrgb;
let config = SurfaceConfiguration {
// This field describes how SurfaceTextures will be used. RENDER_ATTACHMENT
// specifies that the textures will be used to write to the screen.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
// The format defines how SurfaceTextures will be stored on the GPU.
// We can get a supported format from the SurfaceCapabilities
format: surface_format,
width: size.width, // Must be > 0
height: size.height, // Must be > 0
// Takes a `wgpu::PresentMode` option, which determines how to sync the surface with
// the display. `PresentMode::Fifo` is VSync.
// A list of available modes can be obtained with `&surface_caps.present_modes`
// See: https://docs.rs/wgpu/latest/wgpu/enum.PresentMode.html
present_mode: wgpu::PresentMode::Fifo,
// ???
alpha_mode: CompositeAlphaMode::Opaque,
//
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
Self {
surface,
device,
queue,
config,
size,
window,
}
}
pub fn request_redraw(&self) {
self.window.request_redraw()
}
// The renderer takes the terminal state as an argument to its render method.
// It does not store a copy of the grid itself.
pub fn render(&mut self, terminal: &super::terminal::Terminal) -> Result<(), SurfaceError> {
let grid = terminal.get_grid();
// 1. Iterate through the `grid`.
// 2. For each row, create a glyphon::Buffer.
// 3. Set the text, colors, and styles for that buffer from the row's `Cell` data.
// 4. Draw all the buffers to the screen.
// 5. Draw the cursor at terminal.get_cursor_pos().
trace!("Rendering requested");
// Prepare with current configuration (which includes the current window size)
let output = self.surface.get_current_texture()?;
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render Encoder"),
});
{
let mut _render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
trace!("Rendering...");
}
self.queue.submit(Some(encoder.finish()));
output.present();
Ok(())
}
}

View File

@@ -0,0 +1,78 @@
use vte::{Parser, Perform};
// A struct to represent a single cell on the screen
#[derive(Clone, Debug, Default)]
pub struct Cell {
pub char: char,
// pub fg_color: Color,
// pub bg_color: Color,
// pub flags: StyleFlags,
}
pub struct CellGrid(Vec<Vec<Cell>>);
impl CellGrid {
pub fn new(width: usize, height: usize) -> Self {
CellGrid(vec![vec![Cell::default(); width]; height])
}
}
pub struct Terminal {
grid: CellGrid,
cursor_x: usize,
cursor_y: usize,
}
impl Terminal {
pub fn new() -> Self {
let width = 80;
let height = 24;
Terminal {
grid: CellGrid::new(width, height),
cursor_x: 0,
cursor_y: 0,
}
}
pub fn get_grid(&self) -> &CellGrid {
&self.grid
}
}
impl Perform for Terminal {
// this method will update the CellGrid and the cursor position
fn print(&mut self, c: char) {
// todo!()
()
}
/// This method is called when an "execute" control character is seen.
/// These are single-byte control codes like newline, backspace, etc.
fn execute(&mut self, byte: u8) {
match byte {
b'\n' => {
// Newline
println!("Execute: Newline");
self.cursor_y += 1;
self.cursor_x = 0; // Carriage return is implicit with newline
}
_ => {
println!("Execute: Unhandled control character: 0x{:02x}", byte);
}
}
}
/// This method is called for "Control Sequence Introducer" (CSI) sequences.
/// These are the complex `ESC [ ...` sequences for moving the cursor,
/// changing colors, clearing the screen, etc.
fn csi_dispatch(
&mut self,
params: &vte::Params,
_intermediates: &[u8],
_ignore: bool,
action: char,
) {
//todo!()
()
}
}

View File

@@ -1,4 +1,4 @@
use log::info;
use log::{debug, info};
use winit::event_loop::EventLoop;
mod app;
@@ -10,9 +10,17 @@ fn main() {
// Log platform information
info!("Starting simplicitty terminal emulator");
// Create event loop with explicit backend preference
let event_loop = EventLoop::new().unwrap();
debug!("Creating winit event loop");
let event_loop = EventLoop::<app::UserEvent>::with_user_event()
.build()
.unwrap();
// Create event loop proxy to send pty output events to the window
let event_loop_proxy = event_loop.create_proxy();
debug!("Starting winit event loop");
// Run the application
event_loop.run_app(&mut app::Application::new()).unwrap();
event_loop
.run_app(&mut app::Application::new(event_loop_proxy))
.unwrap();
}