9 Commits

11 changed files with 576 additions and 779 deletions

17
AGENTS.md Normal file
View File

@@ -0,0 +1,17 @@
## Agent Instructions
### Build, Lint, and Test
- Build: `cargo build`
- Lint: `cargo clippy`
- Test: `cargo test`
- Run a single test: `cargo test --test <test_name>`
### Code Style
- **Formatting**: Use `cargo fmt` to format the code.
- **Imports**: Group imports by standard library, external crates, and project modules.
- **Types**: Use explicit types.
- **Naming Conventions**: Follow Rust's naming conventions (e.g., `snake_case` for variables and functions, `PascalCase` for types).
- **Error Handling**: Use `Result` and `?` for error propagation. Use `panic!` only for unrecoverable errors.
- **Logging**: Use the `log` crate for logging.

184
Cargo.lock generated
View File

@@ -91,6 +91,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -271,6 +277,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -370,6 +382,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -472,6 +493,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -802,6 +834,12 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.172"
@@ -937,7 +975,7 @@ dependencies = [
"arrayvec",
"bit-set",
"bitflags 2.9.0",
"cfg_aliases",
"cfg_aliases 0.2.1",
"codespan-reporting",
"half",
"hashbrown",
@@ -992,6 +1030,18 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.9.0",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1023,12 +1073,6 @@ dependencies = [
"syn",
]
[[package]]
name = "numtoa"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
[[package]]
name = "objc"
version = "0.2.7"
@@ -1365,12 +1409,39 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "pollster"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
[[package]]
name = "portable-atomic"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -1471,12 +1542,6 @@ dependencies = [
"bitflags 2.9.0",
]
[[package]]
name = "redox_termios"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb"
[[package]]
name = "regex"
version = "1.11.1"
@@ -1632,6 +1697,33 @@ dependencies = [
"syn",
]
[[package]]
name = "serial2"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d1d08630509d69f90eff4afcd02c3bd974d979225cbd815ff5942351b14375"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1651,11 +1743,14 @@ dependencies = [
name = "simplicitty"
version = "0.1.0"
dependencies = [
"crossbeam-channel",
"env_logger",
"glyphon",
"log",
"termion",
"pollster",
"portable-pty",
"tokio",
"vte",
"wgpu",
"winit",
]
@@ -1827,18 +1922,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "termion"
version = "4.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3669a69de26799d6321a5aa713f55f7e2cd37bd47be044b50f2acafc42c122bb"
dependencies = [
"libc",
"libredox",
"numtoa",
"redox_termios",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2059,6 +2142,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -2292,7 +2385,7 @@ checksum = "ca6049eb2014a0e0d8689f9b787605dd71d5bbfdc74095ead499f3cff705c229"
dependencies = [
"arrayvec",
"bitflags 2.9.0",
"cfg_aliases",
"cfg_aliases 0.2.1",
"document-features",
"hashbrown",
"js-sys",
@@ -2322,7 +2415,7 @@ dependencies = [
"bit-set",
"bit-vec",
"bitflags 2.9.0",
"cfg_aliases",
"cfg_aliases 0.2.1",
"document-features",
"hashbrown",
"indexmap",
@@ -2384,7 +2477,7 @@ dependencies = [
"block",
"bytemuck",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.2.1",
"core-graphics-types",
"glow",
"glutin_wgl_sys",
@@ -2431,6 +2524,22 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.9"
@@ -2440,6 +2549,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
@@ -2722,7 +2837,7 @@ dependencies = [
"block2",
"bytemuck",
"calloop",
"cfg_aliases",
"cfg_aliases 0.2.1",
"concurrent-queue",
"core-foundation",
"core-graphics",
@@ -2770,6 +2885,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"

View File

@@ -9,8 +9,13 @@ tokio = { version = "1.44", features = ["full"] }
winit = { version = "0.30", features = ["wayland", "x11"] }
wgpu = "25"
glyphon = "0.9"
portable-pty = "0.9"
vte = "0.15"
termion = "4"
# For reading from the PTY in a non-blocking way
crossbeam-channel = "0.5"
# For logging and running the event loop
env_logger = "0.10"
log = "0.4"
pollster = "0.3"

357
README.md
View File

@@ -1,350 +1,11 @@
# simplicitty
# Simplicitty
A simple terminal emulator
## Roadmap
## 1. Implement Incremental Rendering
Currently, your renderer appears to redraw the entire terminal content on each
frame. For a terminal emulator, this is inefficient since only small portions
typically change at once.
rust // Add to TerminalText struct dirty_regions: Vec<Rectangle>, // Store
regions that need updating last_frame_content: String, // Store previous frame's
content
Only redraw areas that have changed since the last frame by tracking dirty
regions.
## 2. Implement Text Caching
The current implementation reshapes all text on every frame. Instead:
rust // Add to TerminalText cached_lines: HashMap<String, CachedTextLine>,
Cache shaped text lines and only reshape when content changes or window resizes.
## 3. Use Double Buffering for Text
Maintain two buffers - one for the current frame and one for the next:
rust // In TerminalText front_buffer: Buffer, back_buffer: Buffer,
Prepare the back buffer while rendering the front buffer, then swap them.
## 4. Optimize Atlas Trimming
The trim() call after every frame can be expensive. Consider:
rust // In TerminalText frames_since_trim: u32,
pub fn trim(&mut self) { self.frames_since_trim += 1; if
self.frames_since_trim > 30 { // Only trim every 30 frames self.atlas.trim();
self.frames_since_trim = 0; } }
## 5. Implement View Culling
Only render text that's actually visible:
rust // In prepare() method let visible_area = TextBounds { left: 0, top:
scroll_position, right: config.width as i32, bottom: scroll_position +
config.height as i32, };
## 6. Use Asynchronous Text Preparation
Move text shaping to a background thread:
rust // In Application struct text_preparation_pool: ThreadPool,
// Then queue text preparation tasks self.text_preparation_pool.spawn(move || {
// Shape text here // Signal completion via channel });
## 7. Optimize GPU Memory Usage
The current implementation might be creating new GPU resources frequently.
Implement resource pooling:
rust // In TerminalText texture_pool: Vec<wgpu::Texture>, // Reuse textures
instead of creating new ones
## 8. Implement Partial Updates for the Text Atlas
Instead of rebuilding the entire text atlas, update only changed glyphs:
rust // Add method to TextAtlas pub fn update_region(&mut self, device: &Device,
queue: &Queue, region: Rectangle) { // Update only the specified region }
## 9. Use Compute Shaders for Text Processing
For complex text operations, consider using compute shaders:
rust // Create a compute pipeline for text processing let compute_pipeline =
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { // Configure
for text processing });
## 10. Implement Frame Skipping
Skip rendering frames when nothing has changed:
rust pub fn render(&mut self) -> Result<(), SurfaceError> { if !self.has_changes
{ return Ok(()); } // Render as normal }
## 11. Profile and Optimize Hot Paths
Use a profiler to identify bottlenecks in your rendering pipeline. Common issues
might be: • Excessive GPU state changes • Inefficient text shaping • Unnecessary
buffer updates
## 12. Implement Proper Vsync Control
Your current implementation uses PresentMode::Fifo (VSync), which is good for
power efficiency but might not be optimal for all cases:
rust // Allow configurable present mode based on user preference present_mode:
if vsync_enabled { wgpu::PresentMode::Fifo } else { wgpu::PresentMode::Immediate
},
Based on your project structure and the README.md optimization suggestions,
here's how you can implement incremental text buffer updates:
## 1. Track Changes with Dirty Regions
First, modify your TerminalText struct to track which parts of the text have
changed:
rust pub struct TerminalText { font_system: FontSystem, cache: SwashCache,
viewport: Viewport, atlas: TextAtlas, buffer: Buffer, renderer: TextRenderer,
// Add these fields to track changes
last_content: String,
dirty_regions: Vec<TextBounds>,
cursor_position: usize,
}
## 2. Implement Incremental Text Updates
Instead of replacing the entire text on each update, implement methods to
insert, delete, or modify text at specific positions:
```rust
impl TerminalText { // Add a character at the cursor position pub fn
insert_char(&mut self, c: char) { let current_text =
self.buffer.text().to_string(); let pos =
self.cursor_position.min(current_text.len());
// Create new text with the character inserted
let mut new_text = current_text.clone();
new_text.insert(pos, c);
// Calculate the affected region
let affected_line = self.calculate_line_for_position(pos);
let line_bounds = self.get_line_bounds(affected_line);
self.dirty_regions.push(line_bounds);
// Update the buffer with the new text
self.buffer.set_text(
&mut self.font_system,
&new_text,
&Attrs::new().family(Family::Monospace),
Shaping::Advanced,
);
// Update cursor position
self.cursor_position = pos + 1;
self.last_content = new_text;
}
// Delete a character before the cursor
pub fn delete_char(&mut self) {
let current_text = self.buffer.text().to_string();
if self.cursor_position > 0 && !current_text.is_empty() {
let pos = self.cursor_position.min(current_text.len());
// Create new text with the character deleted
let mut new_text = current_text.clone();
new_text.remove(pos - 1);
// Calculate the affected region
let affected_line = self.calculate_line_for_position(pos - 1);
let line_bounds = self.get_line_bounds(affected_line);
self.dirty_regions.push(line_bounds);
// Update the buffer with the new text
self.buffer.set_text(
&mut self.font_system,
&new_text,
&Attrs::new().family(Family::Monospace),
Shaping::Advanced,
);
// Update cursor position
self.cursor_position = pos - 1;
self.last_content = new_text;
}
}
// Helper methods to calculate affected regions
fn calculate_line_for_position(&self, pos: usize) -> usize {
// Count newlines up to the position
self.buffer.text()[..pos].matches('\n').count()
}
fn get_line_bounds(&self, line: usize) -> TextBounds {
// Calculate the bounds for a specific line
// This is a simplified version - you'll need to implement this based on your text layout
TextBounds {
left: 0,
top: (line * self.get_line_height()) as i32,
right: self.viewport.width() as i32,
bottom: ((line + 1) * self.get_line_height()) as i32,
}
}
fn get_line_height(&self) -> usize {
// Get the line height based on your font metrics
self.buffer.metrics().line_height.ceil() as usize
}
}
```
## 3. Optimize the Rendering Process
Modify your prepare and render methods to only update the dirty regions:
```rust
pub fn prepare(&mut self, device: &Device, queue: &Queue, config:
&SurfaceConfiguration) { // Update viewport with new dimensions
self.viewport.update( queue, Resolution { width: config.width, height:
config.height, }, );
// Make sure text is properly wrapped for the current width
self.resize(config.width, config.height);
// Only reshape if there are dirty regions
if !self.dirty_regions.is_empty() {
// Prepare only the dirty regions
for region in &self.dirty_regions {
self.renderer
.prepare(
device,
queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
[TextArea {
buffer: &self.buffer,
left: 0.0,
top: 0.0,
scale: 1.0,
bounds: *region,
default_color: Color::rgb(255, 255, 255),
custom_glyphs: &[],
}],
&mut self.cache,
)
.unwrap();
}
// Clear dirty regions after preparing
self.dirty_regions.clear();
}
}
```
## 4. Implement a Double Buffer Approach
To further optimize, implement a double buffer approach:
```rust
pub struct TerminalText { // ... existing fields front_buffer: Buffer,
back_buffer: Buffer, buffer_swap_needed: bool, }
impl TerminalText { // ... existing methods
pub fn swap_buffers(&mut self) {
if self.buffer_swap_needed {
std::mem::swap(&mut self.front_buffer, &mut self.back_buffer);
self.buffer_swap_needed = false;
}
}
pub fn prepare_back_buffer(&mut self, device: &Device, queue: &Queue) {
// Prepare the back buffer while the front buffer is being rendered
if !self.dirty_regions.is_empty() {
// Apply changes to the back buffer
// ...
self.buffer_swap_needed = true;
}
}
}
```
## 5. Implement Cursor Rendering
Add cursor rendering to provide visual feedback:
rust pub fn render_cursor(&mut self, render_pass: &mut RenderPass) { //
Calculate cursor position in screen coordinates let (x, y) =
self.get_cursor_screen_position();
// Render cursor using a simple rectangle
// You'll need to implement this based on your rendering system
}
fn get_cursor_screen_position(&self) -> (f32, f32) { // Calculate screen
position based on cursor_position // This will depend on your text layout system
// ... }
## 6. Handle Special Keys
In your keyboard event handler, handle special keys like backspace, enter, and
arrow keys:
rust fn handle*key_event(&mut self, key_event: KeyEvent) { match key_event.key {
Key::Character(c) => { if key_event.state == ElementState::Pressed {
self.text.insert_char(c.chars().next().unwrap_or(' '));
self.window.request_redraw(); } }, Key::Named(NamedKey::Backspace) => { if
key_event.state == ElementState::Pressed { self.text.delete_char();
self.window.request_redraw(); } }, Key::Named(NamedKey::Enter) => { if
key_event.state == ElementState::Pressed { self.text.insert_char('\n');
self.window.request_redraw(); } }, // Handle arrow keys for cursor movement
Key::Named(NamedKey::ArrowLeft) => { if key_event.state == ElementState::Pressed
{ self.text.move_cursor_left(); self.window.request_redraw(); } },
Key::Named(NamedKey::ArrowRight) => { if key_event.state ==
ElementState::Pressed { self.text.move_cursor_right();
self.window.request_redraw(); } }, * => {} } }
## 7. Implement Frame Skipping
Skip rendering frames when nothing has changed:
rust pub fn render(&mut self) -> Result<(), SurfaceError> { // Skip rendering if
nothing has changed if self.dirty_regions.is_empty() && !self.buffer_swap_needed
{ return Ok(()); }
// Swap buffers if needed
self.text.swap_buffers();
// Render as normal
// ...
Ok(())
}
These changes will significantly improve the efficiency of your text rendering
by:
1. Only updating parts of the text that have changed
2. Using double buffering to prepare updates while rendering
3. Skipping frames when nothing has changed
4. Optimizing the text atlas updates
This approach aligns with the optimization suggestions in your README.md and
should provide a much more efficient text rendering system for your terminal
emulator.
1. [ ] Complete keyboard input handling (arrow keys, backspace, etc.)
2. [ ] Implement a PTY interface to spawn and communicate with processes
3. [ ] Add support for terminal protocols (ANSI escape sequences)
4. [ ] Implement scrollback buffer and viewport scrolling
5. [ ] Add color and text styling support
6. [ ] Implement clipboard integration
7. [ ] Add configuration options

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,29 +1,61 @@
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::keyboard::{Key, NamedKey};
use winit::window::Window;
use winit::{
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoopProxy},
window::{Window, WindowId},
};
mod rendering;
mod pty;
mod renderer;
mod terminal;
#[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 {
window_state: Option<rendering::State>,
runtime: Runtime,
event_loop_proxy: EventLoopProxy<UserEvent>,
term_state: Option<TermState>,
}
impl Application {
pub fn new() -> Self {
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"),
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) {
if self.window_state.is_some() {
impl winit::application::ApplicationHandler<UserEvent> for Application {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.term_state.is_some() {
return;
}
@@ -32,55 +64,48 @@ impl winit::application::ApplicationHandler for Application {
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());
self.window_state = Some(self.runtime.block_on(rendering::State::new(window)));
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,
) {
let Some(state) = &mut self.window_state else {
return;
};
match event {
WindowEvent::Resized(size) => {
state.resize(size);
}
WindowEvent::RedrawRequested => {
state.render().unwrap();
}
WindowEvent::KeyboardInput {
device_id: _,
event,
is_synthetic: _,
} => {
if event.state.is_pressed() {
match event.logical_key {
Key::Named(k) => match k {
NamedKey::Enter => {
state.handle_enter();
}
_ => todo!(),
},
Key::Character(c) => {
state.add_text(&event.text.unwrap());
}
Key::Dead(_) => {
todo!()
}
Key::Unidentified(_) => {
todo!()
}
}
if let Some(state) = &mut self.term_state {
match event {
WindowEvent::RedrawRequested => {
state.renderer.render(&state.terminal);
}
_ => (),
}
WindowEvent::CloseRequested => event_loop.exit(),
_ => {}
}
}
}

90
src/app/pty/mod.rs Normal file
View File

@@ -0,0 +1,90 @@
use crossbeam_channel::Sender;
use portable_pty::{Child, CommandBuilder, PtySize, native_pty_system};
use std::io::{Read, Write};
use std::thread;
pub struct Pty {
writer: Box<dyn Write + Send + 'static>,
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();
// Create a new pty
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
// Not all systems support pixel_width, pixel_height,
// but it is good practice to set it to something
// that matches the size of the selected font. That
// is more complex than can be shown here in this
// brief example though!
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// Spawn a shell into the pty
let cmd = CommandBuilder::new_default_prog();
// The `slave` end of the PTY is given to the shell process.
// The shell thinks it's talking to a real terminal.
let mut child = pair.slave.spawn_command(cmd).unwrap();
// We can now drop the slave, as it's been consumed by the child process.
drop(pair.slave);
// Our application will use the `master` end to communicate with the shell.
// We are cloning the master so we can use it in a separate thread for reading.
let writer = pair.master.take_writer().unwrap();
let reader = pair.master.try_clone_reader().unwrap();
start_reader_thread(output_sender.clone(), reader);
Self { writer, child }
}
pub fn send_command(&mut self, cmd: String) -> Result<(), PtyError> {
self.writer.write_all(cmd.as_bytes())?;
Ok(())
}
}
fn start_reader_thread(sender: Sender<String>, mut reader: Box<dyn Read + Send + 'static>) {
thread::spawn(move || {
let mut buffer = [0u8; 1024];
loop {
match reader.read(&mut buffer) {
Ok(0) => {
// EOF, the child process has exited.
break;
}
Ok(n) => {
// Append the read data to our output string.
// We use from_utf8_lossy to handle potential invalid UTF-8 sequences.
sender
.send(String::from_utf8_lossy(&buffer[..n]).to_string())
.unwrap();
}
Err(e) => {
// An error occurred.
eprintln!("Error reading from PTY: {}", e);
break;
}
}
}
});
}

View File

@@ -1,3 +1,4 @@
use log::{debug, trace};
use std::sync::Arc;
use wgpu::{
CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance,
@@ -8,9 +9,7 @@ use wgpu::{
use winit::dpi::PhysicalSize;
use winit::window::Window;
mod terminal_text;
pub struct State {
pub struct Renderer {
surface: Surface<'static>,
device: Device,
queue: Queue,
@@ -18,63 +17,11 @@ pub struct State {
size: PhysicalSize<u32>,
text: terminal_text::TerminalText,
/// text: terminal_text::TerminalText,
window: Arc<Window>,
}
impl State {
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
self.surface.configure(&self.device, &self.config);
self.window.request_redraw();
}
}
pub fn render(&mut self) -> Result<(), SurfaceError> {
// Prepare with current configuration (which includes the current window size)
self.text.prepare(&self.device, &self.queue, &self.config);
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,
});
self.text.render(&mut _render_pass);
}
self.queue.submit(Some(encoder.finish()));
output.present();
self.text.trim();
Ok(())
}
impl Renderer {
pub async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
@@ -170,32 +117,65 @@ impl State {
};
surface.configure(&device, &config);
// Set up text renderer
let text = terminal_text::TerminalText::new(&device, &queue, surface_format);
Self {
surface,
device,
queue,
config,
size,
text,
window,
}
}
pub fn add_text(&mut self, text: &str) {
for c in text.chars() {
self.text.insert_char(c);
}
self.window.request_redraw();
pub fn request_redraw(&self) {
self.window.request_redraw()
}
pub fn handle_enter(&mut self) {
self.text.handle_enter();
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

@@ -1,275 +0,0 @@
use glyphon::{
Attrs, Buffer, BufferLine, Cache, Color, Cursor, Family, FontSystem, Metrics, Resolution,
Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap,
};
use wgpu::{Device, MultisampleState, Queue, RenderPass, SurfaceConfiguration, TextureFormat};
pub struct TerminalText {
font_system: FontSystem,
cache: SwashCache,
viewport: Viewport,
atlas: TextAtlas,
buffer: Buffer,
renderer: TextRenderer,
dirty_regions: Vec<TextBounds>,
cursor: Cursor,
}
use log::info;
impl TerminalText {
pub fn new(device: &Device, queue: &Queue, surface_format: TextureFormat) -> TerminalText {
let mut font_system = FontSystem::new();
let swash_cache = SwashCache::new();
let cache = Cache::new(device);
let viewport = Viewport::new(device, &cache);
let mut atlas = TextAtlas::new(device, queue, &cache, surface_format);
let renderer = TextRenderer::new(&mut atlas, device, MultisampleState::default(), None);
let mut buffer = Buffer::new(&mut font_system, Metrics::relative(13.0, 1.25));
buffer.set_wrap(&mut font_system, Wrap::Glyph);
// Add a default font
font_system.db_mut().load_system_fonts();
Self {
font_system,
cache: swash_cache,
viewport,
atlas,
buffer,
renderer,
dirty_regions: vec![],
cursor: Cursor {
line: 0,
index: 0,
affinity: glyphon::Affinity::After,
},
}
}
pub fn set_text(&mut self, text: &str) {
// Clear the buffer and set new text
self.buffer.set_text(
&mut self.font_system,
text,
&Attrs::new().family(Family::Monospace),
Shaping::Advanced,
);
self.buffer.set_wrap(&mut self.font_system, Wrap::Glyph);
}
pub fn resize(&mut self, width: u32, height: u32) {
// Update the buffer's wrapping based on the new width
self.buffer.set_wrap(&mut self.font_system, Wrap::Glyph);
self.buffer.set_size(
&mut self.font_system,
Some(width as f32),
Some(height as f32),
);
// Reshape the text with the new dimensions
self.buffer.shape_until_scroll(&mut self.font_system, false);
}
pub fn prepare(&mut self, device: &Device, queue: &Queue, config: &SurfaceConfiguration) {
// Update viewport with new dimensions
self.viewport.update(
queue,
Resolution {
width: config.width,
height: config.height,
},
);
// Make sure text is properly wrapped for the current width
self.resize(config.width, config.height);
if !self.dirty_regions.is_empty() {
for region in &self.dirty_regions {
self.renderer
.prepare(
device,
queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
[TextArea {
buffer: &self.buffer,
left: 0.0,
top: 0.0,
scale: 1.0,
bounds: *region,
default_color: Color::rgb(255, 255, 255),
custom_glyphs: &[],
}],
&mut self.cache,
)
.unwrap();
}
} else {
// If no dirty regions, prepare the entire buffer (first render or after resize)
self.renderer
.prepare(
device,
queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
[TextArea {
buffer: &self.buffer,
left: 0.0,
top: 0.0,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
right: config.width as i32,
bottom: config.height as i32,
},
default_color: Color::rgb(255, 255, 255),
custom_glyphs: &[],
}],
&mut self.cache,
)
.unwrap();
}
self.dirty_regions.clear();
}
pub fn render(&self, render_pass: &mut RenderPass) {
self.renderer
.render(&self.atlas, &self.viewport, render_pass)
.unwrap();
}
pub fn trim(&mut self) {
self.atlas.trim()
}
pub fn insert_char(&mut self, c: char) {
let line = self.cursor.line;
if line >= self.buffer.lines.len() {
// TODO
return;
}
info!("line nr: {line}");
let current_line = self.buffer.lines[line].text().to_string();
let pos = self.cursor.index.min(current_line.len());
info!("cursor position: {pos}");
info!("Line text: {current_line}");
let mut new_text = current_line.clone();
new_text.insert(pos, c);
let line_bounds = self.get_line_bounds(line);
self.dirty_regions.push(line_bounds);
let ending = self.buffer.lines[line].ending();
let attrs = self.buffer.lines[line].attrs_list().clone();
// Update the buffer with the new text
self.buffer.lines[line].set_text(&new_text, ending, attrs);
// Update cursor position
self.cursor.index = pos + 1;
self.buffer.shape_until_scroll(&mut self.font_system, false);
}
fn get_line_bounds(&self, line: usize) -> TextBounds {
// Calculate the bounds for a specific line
// This is a simplified version - you'll need to implement this based on your text layout
TextBounds {
left: 0,
top: (line * self.get_line_height()) as i32,
right: self.viewport.resolution().width as i32,
bottom: ((line + 1) * self.get_line_height()) as i32,
}
}
fn get_line_height(&self) -> usize {
// Get the line height based on your font metrics
self.buffer.metrics().line_height.ceil() as usize
}
pub fn handle_enter(&mut self) {
let line = self.cursor.line;
// Ensure line is within bounds
if line >= self.buffer.lines.len() {
return;
}
// Get the current line text
let current_line = self.buffer.lines[line].text().to_string();
let pos = self.cursor.index.min(current_line.len());
// Split the line at cursor position
let text_before = &current_line[..pos];
let text_after = &current_line[pos..];
// Update the current line with text before cursor
let ending = self.buffer.lines[line].ending();
let attrs = self.buffer.lines[line].attrs_list().clone();
self.buffer.lines[line].set_text(text_before, ending, attrs.clone());
// Create a new line with text after cursor
let new_line = BufferLine::new(text_after, ending, attrs, Shaping::Advanced);
// Insert the new line after the current one
self.buffer.lines.insert(line + 1, new_line);
self.mark_all_dirty();
// Update cursor position to beginning of next line
self.cursor.line = line + 1;
self.cursor.index = 0;
// Reshape the buffer
self.buffer.shape_until_scroll(&mut self.font_system, false);
}
pub fn mark_all_dirty(&mut self) {
// Create a single region covering the entire viewport
let full_bounds = TextBounds {
left: 0,
top: 0,
right: self.viewport.resolution().width as i32,
bottom: self.viewport.resolution().height as i32,
};
self.dirty_regions = vec![full_bounds];
}
pub fn move_cursor_left(&mut self) {
if self.cursor.index > 0 {
self.cursor.index -= 1;
} else if self.cursor.line > 0 {
// Move to end of previous line
self.cursor.line -= 1;
let prev_line_len = self.buffer.lines[self.cursor.line].text().len();
self.cursor.index = prev_line_len;
}
}
pub fn move_cursor_right(&mut self) {
if self.cursor.line < self.buffer.lines.len() {
let current_line_len = self.buffer.lines[self.cursor.line].text().len();
if self.cursor.index < current_line_len {
self.cursor.index += 1;
} else if self.cursor.line < self.buffer.lines.len() - 1 {
// Move to beginning of next line
self.cursor.line += 1;
self.cursor.index = 0;
}
}
}
}

78
src/app/terminal/mod.rs Normal file
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();
}