From 17794aba0b6e861afe8e2ab75fedbea90a76c319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki=20Dom=C3=ADnguez=20Ochoa?= Date: Fri, 18 Jul 2025 20:14:08 +0200 Subject: [PATCH] preparing the window rendering --- project.md | 84 +++++++++++++++++++ src/app/mod.rs | 102 ++++++++++++++++++---- src/app/pty/mod.rs | 19 ++++- src/app/renderer.rs | 181 ++++++++++++++++++++++++++++++++++++++++ src/app/renderer/mod.rs | 0 src/app/terminal/mod.rs | 78 +++++++++++++++++ src/main.rs | 16 +++- 7 files changed, 455 insertions(+), 25 deletions(-) create mode 100644 project.md create mode 100644 src/app/renderer.rs delete mode 100644 src/app/renderer/mod.rs diff --git a/project.md b/project.md new file mode 100644 index 0000000..b21e194 --- /dev/null +++ b/project.md @@ -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. diff --git a/src/app/mod.rs b/src/app/mod.rs index 4789848..36817ff 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, - runtime: Runtime, - output_receiver: crossbeam_channel::Receiver, +#[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, + term_state: Option, } impl Application { - pub fn new() -> Self { - let (send, recv) = crossbeam_channel::unbounded(); + pub fn new(event_loop_proxy: EventLoopProxy) -> 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) { + 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 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); + } + _ => (), + } + } } } diff --git a/src/app/pty/mod.rs b/src/app/pty/mod.rs index 6979f32..0d9166d 100644 --- a/src/app/pty/mod.rs +++ b/src/app/pty/mod.rs @@ -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, } +pub enum PtyError { + Io(std::io::Error), +} +// +// You need this `From` implementation! +impl From for PtyError { + fn from(err: std::io::Error) -> Self { + PtyError::Io(err) + } +} + impl Pty { pub fn new(output_sender: crossbeam_channel::Sender) -> 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(()) } } diff --git a/src/app/renderer.rs b/src/app/renderer.rs new file mode 100644 index 0000000..adc48b0 --- /dev/null +++ b/src/app/renderer.rs @@ -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, + + /// text: terminal_text::TerminalText, + window: Arc, +} + +impl Renderer { + pub async fn new(window: Arc) -> 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(()) + } +} diff --git a/src/app/renderer/mod.rs b/src/app/renderer/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/terminal/mod.rs b/src/app/terminal/mod.rs index e69de29..d31266b 100644 --- a/src/app/terminal/mod.rs +++ b/src/app/terminal/mod.rs @@ -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>); + +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!() + () + } +} diff --git a/src/main.rs b/src/main.rs index 6f5310a..de5fa41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::::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(); }