diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d683ecc --- /dev/null +++ b/AGENTS.md @@ -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 ` + +### 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. diff --git a/AmazonQ.md b/AmazonQ.md deleted file mode 100644 index 10fdb1b..0000000 --- a/AmazonQ.md +++ /dev/null @@ -1,41 +0,0 @@ -# Text Scrolling Implementation in Simplicitty - -This document outlines the implementation of text scrolling in the Simplicitty terminal emulator project. - -## Overview - -The implementation adds the ability to scroll through text content when it doesn't fit completely in the window. The scrolling is implemented on a line-by-line basis, allowing users to navigate through the content using keyboard shortcuts. - -## Key Components - -1. **Scroll State Management**: - - Added `scroll_offset` to track the current scroll position (in visual lines) - - Added `max_scroll_offset` to limit scrolling to available content - -2. **Viewport Calculation**: - - Modified `get_visible_line_range()` to consider the scroll offset when determining visible lines - - Added `update_max_scroll_offset()` to recalculate the maximum scroll position based on content and viewport size - -3. **Scrolling Methods**: - - `scroll_up(lines)`: Move viewport up by specified number of lines - - `scroll_down(lines)`: Move viewport down by specified number of lines - - `page_up()`: Scroll up by one page (viewport height) - - `page_down()`: Scroll down by one page (viewport height) - -4. **Keyboard Controls**: - - Ctrl+Up/Down: Scroll one line up/down - - Page Up/Down: Scroll one page up/down - - Ctrl+Home/End: Scroll to top/bottom of content - -## Implementation Details - -The scrolling implementation works by adjusting the `scroll_offset` value, which determines which visual lines are rendered in the viewport. The `get_visible_line_range()` function uses this offset to calculate which logical lines should be rendered. - -When the window is resized or content changes, the `max_scroll_offset` is recalculated to ensure scrolling remains within valid bounds. - -## Future Improvements - -1. Add mouse wheel support for scrolling -2. Implement smooth scrolling animations -3. Add a visual scrollbar indicator -4. Preserve horizontal scroll position when navigating vertically diff --git a/Cargo.lock b/Cargo.lock index 3487bf4..5c19934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a3ac796..24ffc7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app/mod.rs b/src/app/mod.rs index 223c032..4789848 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,36 +4,32 @@ use winit::dpi::LogicalSize; use winit::event::WindowEvent; use winit::window::Window; -mod rendering; +mod pty; +mod renderer; +mod terminal; pub struct Application { - window_state: Option, + // window_state: Option, runtime: Runtime, + output_receiver: crossbeam_channel::Receiver, + pty: pty::Pty, } impl Application { pub fn new() -> Self { + let (send, recv) = crossbeam_channel::unbounded(); Self { - window_state: None, + // window_state: None, runtime: Runtime::new().expect("Failed to create tokio runtime"), + output_receiver: recv, + pty: pty::Pty::new(send), } } } impl winit::application::ApplicationHandler for Application { fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { - if self.window_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"); - let window = Arc::new(event_loop.create_window(window_attributes).unwrap()); - - self.window_state = Some(self.runtime.block_on(rendering::State::new(window))); + todo!() } fn window_event( @@ -42,29 +38,6 @@ impl winit::application::ApplicationHandler for Application { _window_id: winit::window::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: _, - } => { - state.handle_keyboard_input(&event); - } - WindowEvent::ModifiersChanged(modifiers) => { - state.update_modifiers(modifiers.state()); - } - WindowEvent::CloseRequested => event_loop.exit(), - _ => {} - } + todo!() } } diff --git a/src/app/pty/mod.rs b/src/app/pty/mod.rs new file mode 100644 index 0000000..6979f32 --- /dev/null +++ b/src/app/pty/mod.rs @@ -0,0 +1,79 @@ +use crossbeam_channel::Sender; +use portable_pty::{Child, CommandBuilder, PtySize, PtySystem, native_pty_system}; +use std::io::{Read, Write}; +use std::thread; + +pub struct Pty { + writer: Box, + child: Box, +} + +impl Pty { + pub fn new(output_sender: crossbeam_channel::Sender) -> 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(&self, cmd: String) { + // self.writer.write_all(cmd.as_bytes()); + todo!() + } +} + +fn start_reader_thread(sender: Sender, mut reader: Box) { + 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; + } + } + } + }); +} diff --git a/src/app/renderer/mod.rs b/src/app/renderer/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/rendering/mod.rs b/src/app/rendering/mod.rs deleted file mode 100644 index 39a83fe..0000000 --- a/src/app/rendering/mod.rs +++ /dev/null @@ -1,393 +0,0 @@ -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::event::{ElementState, KeyEvent}; -use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey}; -use winit::window::Window; - -mod terminal_text; - -pub struct State { - surface: Surface<'static>, - device: Device, - queue: Queue, - config: SurfaceConfiguration, - - size: PhysicalSize, - - text: terminal_text::TerminalText, - - window: Arc, - - // Keyboard state tracking - modifiers: ModifiersState, - dead_key_state: Option, - caps_lock_enabled: bool, -} - -impl State { - pub fn resize(&mut self, new_size: PhysicalSize) { - trace!("Resizing window"); - 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.text.resize(new_size.width, new_size.height); - self.window.request_redraw(); - } - } - - pub fn render(&mut self) -> Result<(), SurfaceError> { - trace!("Rendering requested"); - // 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, - }); - - trace!("Rendering..."); - self.text.render(&mut _render_pass); - } - - self.queue.submit(Some(encoder.finish())); - output.present(); - - Ok(()) - } - - 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); - - // Set up text renderer - let text = terminal_text::TerminalText::new(&device, &queue, surface_format); - - Self { - surface, - device, - queue, - config, - - size, - - text, - - window, - - // Initialize keyboard state - modifiers: ModifiersState::empty(), - dead_key_state: None, - caps_lock_enabled: false, - } - } - - pub fn update_modifiers(&mut self, state: ModifiersState) { - self.modifiers = state; - debug!("Modifiers updated: {:?}", self.modifiers); - } - - pub fn handle_keyboard_input(&mut self, event: &KeyEvent) { - // Only process key presses, not releases (except for tracking caps lock) - if event.state == ElementState::Released { - // Special case for caps lock which toggles on release - if let Key::Named(NamedKey::CapsLock) = event.logical_key { - self.caps_lock_enabled = !self.caps_lock_enabled; - debug!("Caps lock toggled: {}", self.caps_lock_enabled); - } - return; - } - - match &event.logical_key { - Key::Named(k) => { - match k { - NamedKey::Enter => { - debug!("Receiver enter"); - self.text.handle_enter(); - } - NamedKey::Backspace => { - // Clear dead key state if backspace is pressed - if self.dead_key_state.is_some() { - self.dead_key_state = None; - debug!("Dead key state cleared"); - } else if self.text.backspace() { - debug!("Backspace processed"); - } - } - NamedKey::Tab => { - // Insert tab character - self.text.insert_char('\t'); - } - NamedKey::Escape => { - // Clear any pending dead key state - if self.dead_key_state.is_some() { - self.dead_key_state = None; - debug!("Dead key state cleared by Escape"); - } - } - NamedKey::ArrowLeft => { - if self.text.move_cursor(terminal_text::Motion::Left) { - debug!("Cursor moved left"); - } - } - NamedKey::ArrowRight => { - if self.text.move_cursor(terminal_text::Motion::Right) { - debug!("Cursor moved right"); - } - } - NamedKey::ArrowUp => { - if self.text.move_cursor(terminal_text::Motion::Up) { - debug!("Cursor moved left"); - } - } - NamedKey::ArrowDown => { - if self.text.move_cursor(terminal_text::Motion::Down) { - debug!("Cursor moved right"); - } - } - _ => { - debug!("Unhandled named key: {:?}", k); - } - } - } - Key::Character(s) => { - // Handle character input, considering dead keys - if let Some(dead_char) = self.dead_key_state.take() { - // Combine dead key with the current character if possible - if let Some(combined) = self.combine_with_dead_key(dead_char, s) { - for c in combined.chars() { - debug!("Inserting character {c}"); - self.text.insert_char(c); - } - } else { - // If combination not possible, insert both separately - self.text.insert_char(dead_char); - for c in s.chars() { - debug!("Inserting character {c}"); - self.text.insert_char(c); - } - } - } else { - // Normal character input - for c in s.chars() { - debug!("Inserting character {c}"); - self.text.insert_char(c); - } - } - } - Key::Dead(dead_key) => { - // Store dead key for later combination - if let Some(c) = dead_key { - self.dead_key_state = Some(*c); - debug!("Dead key registered: {:?}", c); - } - } - Key::Unidentified(unidentified) => { - debug!( - "Unidentified key: {:?}, location: {:?}", - unidentified, event.physical_key - ); - } - } - - self.window.request_redraw(); - } - - /// Attempts to combine a dead key with a character - fn combine_with_dead_key(&self, dead_key: char, input: &str) -> Option { - // This is a simplified implementation - for a complete solution, - // you would need a more comprehensive mapping of dead key combinations - - if input.chars().count() != 1 { - return None; // Can only combine with single characters - } - - let base_char = input.chars().next().unwrap(); - - // Common dead key combinations - match (dead_key, base_char) { - // Acute accent (´) - ('´', 'a') => Some("á".to_string()), - ('´', 'e') => Some("é".to_string()), - ('´', 'i') => Some("í".to_string()), - ('´', 'o') => Some("ó".to_string()), - ('´', 'u') => Some("ú".to_string()), - ('´', 'A') => Some("Á".to_string()), - ('´', 'E') => Some("É".to_string()), - ('´', 'I') => Some("Í".to_string()), - ('´', 'O') => Some("Ó".to_string()), - ('´', 'U') => Some("Ú".to_string()), - - // Grave accent (`) - ('`', 'a') => Some("à".to_string()), - ('`', 'e') => Some("è".to_string()), - ('`', 'i') => Some("ì".to_string()), - ('`', 'o') => Some("ò".to_string()), - ('`', 'u') => Some("ù".to_string()), - ('`', 'A') => Some("À".to_string()), - ('`', 'E') => Some("È".to_string()), - ('`', 'I') => Some("Ì".to_string()), - ('`', 'O') => Some("Ò".to_string()), - ('`', 'U') => Some("Ù".to_string()), - - // Circumflex (^) - ('^', 'a') => Some("â".to_string()), - ('^', 'e') => Some("ê".to_string()), - ('^', 'i') => Some("î".to_string()), - ('^', 'o') => Some("ô".to_string()), - ('^', 'u') => Some("û".to_string()), - ('^', 'A') => Some("Â".to_string()), - ('^', 'E') => Some("Ê".to_string()), - ('^', 'I') => Some("Î".to_string()), - ('^', 'O') => Some("Ô".to_string()), - ('^', 'U') => Some("Û".to_string()), - - // Diaeresis/umlaut (¨) - ('¨', 'a') => Some("ä".to_string()), - ('¨', 'e') => Some("ë".to_string()), - ('¨', 'i') => Some("ï".to_string()), - ('¨', 'o') => Some("ö".to_string()), - ('¨', 'u') => Some("ü".to_string()), - ('¨', 'A') => Some("Ä".to_string()), - ('¨', 'E') => Some("Ë".to_string()), - ('¨', 'I') => Some("Ï".to_string()), - ('¨', 'O') => Some("Ö".to_string()), - ('¨', 'U') => Some("Ü".to_string()), - - // Tilde (~) - ('~', 'a') => Some("ã".to_string()), - ('~', 'n') => Some("ñ".to_string()), - ('~', 'o') => Some("õ".to_string()), - ('~', 'A') => Some("Ã".to_string()), - ('~', 'N') => Some("Ñ".to_string()), - ('~', 'O') => Some("Õ".to_string()), - - // Cedilla (¸) - ('¸', 'c') => Some("ç".to_string()), - ('¸', 'C') => Some("Ç".to_string()), - - // If no combination is defined, return None - _ => None, - } - } -} diff --git a/src/app/rendering/terminal_text/mod.rs b/src/app/rendering/terminal_text/mod.rs deleted file mode 100644 index 89bdf5d..0000000 --- a/src/app/rendering/terminal_text/mod.rs +++ /dev/null @@ -1,383 +0,0 @@ -use glyphon::{ - Buffer, Cache, Color, Cursor, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas, - TextBounds, TextRenderer, Viewport, Wrap, -}; - -pub use glyphon::cosmic_text::Motion; - -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, - cursor: Cursor, - max_scroll_lines: usize, -} - -// TODO -// - need to update scroll when -// - inserting into a line and it causes line wrap, creating a new visual line (currently the -// scroll is only updated when creating a logical line or removing the last logical line) -// -// - need to keep track of the scroll vertical offset in two ways: -// - first logical line displayed -// - extra offset that covers possible wrapped lines before the first logical line -// -// - need to remove lines from the top of the buffer when the first logical line displayed is -// bigger than max_scroll_offset - -mod safe_casts; - -use log::{debug, error, trace}; - -impl TerminalText { - pub fn new(device: &Device, queue: &Queue, surface_format: TextureFormat) -> TerminalText { - debug!("Creating a new TerminalText object"); - 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); - - trace!("Loading system fonts"); - // 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, - }, - max_scroll_lines: 100, - } - } - - // Calculate the bounds delimited by the `start` and `end` lines - fn get_text_bounds(&self, start: usize, end: usize) -> TextBounds { - if start >= self.buffer.lines.len() || end > self.buffer.lines.len() { - // Return default bounds for invalid line - return TextBounds { - left: 0, - top: 0, - right: 0, - bottom: 0, - }; - } - - let line_height = self.buffer.metrics().line_height; - let viewport_width = self.viewport.resolution().width; - - let layout_iter = self.buffer.layout_runs(); - let mut top_line = 0; - let mut bottom_line = 0; - - let mut found_start_line = false; - let mut found_end_line = false; - - for (visual_line_count, run) in layout_iter.enumerate() { - if run.line_i == start && !found_start_line { - top_line = visual_line_count; - found_start_line = true; - } - if run.line_i == end { - if !found_end_line { - found_end_line = true; - } - bottom_line = visual_line_count + 1; - } else if found_end_line { - break; - } - } - - // Calculate bounds based on visual line positions - let top_f32 = (safe_casts::usize_to_f32_or_max(top_line) * line_height).floor(); - let bottom_f32 = (safe_casts::usize_to_f32_or_max(bottom_line) * line_height).ceil(); - - // Safe conversions with overflow checks - let top = safe_casts::f32_to_i32_or_bound(top_f32); - let bottom = safe_casts::f32_to_i32_or_bound(bottom_f32); - - // Ensure viewport width doesn't exceed i32::MAX - let right = safe_casts::u32_to_i32_or_max(viewport_width.min(i32::MAX as u32)); - - TextBounds { - left: 0, - top, - right, - bottom, - } - } - - fn get_visible_line_range(&self) -> (usize, usize) { - let viewport_height = safe_casts::u32_to_f32_or_max(self.viewport.resolution().height); - let line_height = self.buffer.metrics().line_height; - - let start_line = self.buffer.scroll().line; - - trace!("Current start line: {start_line}"); - - // Calculate visible lines based on wrapped text - let layout_iter = self.buffer.layout_runs(); - let mut last_logical_line = 0; - - // Count how many visual lines we have and map to logical lines - for (visual_line_count, run) in layout_iter.enumerate() { - last_logical_line = run.line_i; - - // If we've exceeded the viewport height, we can stop counting - if (safe_casts::usize_to_f32_or_max(visual_line_count) * line_height) > viewport_height - { - break; - } - } - - // Add 1 to include partially visible lines at the bottom - let end_line = last_logical_line; - - trace!("visible line range goes from {start_line} to {end_line}"); - - (start_line, end_line) - } - - fn ensure_visible_text_rendered(&mut self) { - let (start_line, end_line) = self.get_visible_line_range(); - self.dirty_regions - .push(self.get_text_bounds(start_line, end_line)); - } - - fn merge_dirty_regions(&self) -> TextBounds { - if self.dirty_regions.is_empty() { - return TextBounds::default(); - } - - self.dirty_regions.iter().fold( - TextBounds { - left: 0, - top: 0, - right: 0, - bottom: 0, - }, - |a, b| TextBounds { - left: a.left.min(b.left), - top: a.top.min(b.top), - right: a.right.max(b.right), - bottom: a.bottom.max(b.bottom), - }, - ) - } - - 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, - }, - ); - - let region = self.merge_dirty_regions(); - trace!("Preparing region {:?}", region); - 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(); - - self.dirty_regions.clear(); - } - - pub fn render(&mut self, render_pass: &mut RenderPass) { - self.renderer - .render(&self.atlas, &self.viewport, render_pass) - .unwrap(); - self.atlas.trim() - } - - fn with_update(&mut self, operation: F) -> R - where - F: FnOnce(&mut Self) -> R, - { - let result = operation(self); - - let mut scroll = self.buffer.scroll(); - if self.buffer.lines.len() > self.max_scroll_lines { - self.buffer.lines.remove(0); - self.cursor.line -= 1; - } - scroll.line = self.buffer.lines.len(); - self.buffer.set_scroll(scroll); - self.buffer.shape_until_scroll(&mut self.font_system, false); - self.ensure_visible_text_rendered(); - result - } - - pub fn resize(&mut self, width: u32, height: u32) { - trace!("Resizing window - Width: {width} Height: {height}"); - - self.with_update(|this| { - this.buffer.set_size( - &mut this.font_system, - Some(safe_casts::u32_to_f32_or_max(width)), - Some(safe_casts::u32_to_f32_or_max(height)), - ); - // Update the buffer's wrapping based on the new width - this.buffer.set_wrap(&mut this.font_system, Wrap::Glyph) - }); - } - - pub fn insert_char(&mut self, c: char) { - self.with_update(|this| { - let line = this.cursor.line; - - if line >= this.buffer.lines.len() { - error!( - "insert_char called when cursor is out of bounds! lines: {}, cursor: {}", - this.buffer.lines.len(), - line - ); - return; - } - - let mut text = this.buffer.lines[line].text().to_string().clone(); - let pos = this.cursor.index.min(text.len()); - trace!("Inserting char {c} in line {line}, position {pos}"); - text.insert(pos, c); - - let ending = this.buffer.lines[line].ending(); - let attrs = this.buffer.lines[line].attrs_list().clone(); - - // Update the buffer with the new text - this.buffer.lines[line].set_text(&text, ending, attrs); - // Update cursor position - this.cursor.index = pos + 1; - }) - } - - pub fn handle_enter(&mut self) { - self.with_update(|this| { - let line = this.cursor.line; - - // Ensure line is within bounds - if line >= this.buffer.lines.len() { - error!( - "handle_enter called when cursor is out of bounds! lines: {}, cursor: {}", - this.buffer.lines.len(), - line - ); - return; - } - - let pos = this.cursor.index.min(this.buffer.lines[line].text().len()); - trace!("Inserting newline in line {line}, position {pos}"); - let new_line = this.buffer.lines[line].split_off(pos); - - // Update cursor position to beginning of next line - this.cursor.line = line + 1; - this.cursor.index = 0; - // Create a new line with text after cursor - this.buffer.lines.insert(this.cursor.line, new_line); - }) - } - - pub fn backspace(&mut self) -> bool { - self.with_update(|this| { - let line = this.cursor.line; - - // Ensure line is within bounds - if line >= this.buffer.lines.len() { - error!( - "handle_enter called when cursor is out of bounds! lines: {}, cursor: {}", - this.buffer.lines.len(), - line - ); - return false; - } - - let current_line = this.buffer.lines[line].text().to_string(); - let pos = this.cursor.index; - - let mut ret = false; - - if pos > 0 { - // Delete character before cursor in current line - let mut new_text = current_line.clone(); - new_text.remove(pos - 1); - - // Update the line - let ending = this.buffer.lines[line].ending(); - let attrs = this.buffer.lines[line].attrs_list().clone(); - this.buffer.lines[line].set_text(&new_text, ending, attrs); - - // Move cursor back - this.cursor.index = pos - 1; - - ret = true; - } else if line > 0 { - // Move cursor to join point - this.cursor.line = line - 1; - this.cursor.index = this.buffer.lines[this.cursor.line].text().len(); - let current_line = this.buffer.lines[line].clone(); - // At beginning of line, join with previous line - this.buffer.lines[this.cursor.line].append(current_line); - // Remove current line - this.buffer.lines.remove(line); - - ret = true; - } - - ret - }) - } - - // Cursor movement methods - pub fn move_cursor(&mut self, motion: Motion) -> bool { - self.with_update(|this| { - match this.buffer.cursor_motion( - &mut this.font_system, - this.cursor, - Some(safe_casts::usize_to_i32_or_max(this.cursor.index)), - motion, - ) { - None => false, - Some((cursor, _cursor_x_opt)) => { - this.cursor = cursor; - true - } - } - }) - } -} diff --git a/src/app/rendering/terminal_text/safe_casts.rs b/src/app/rendering/terminal_text/safe_casts.rs deleted file mode 100644 index 18c8739..0000000 --- a/src/app/rendering/terminal_text/safe_casts.rs +++ /dev/null @@ -1,69 +0,0 @@ -use log::trace; - -// Safe conversions with overflow checks - -pub fn u32_to_i32_or_max(n: u32) -> i32 { - if n > i32::MAX as u32 { - trace!( - "Overflow casting {n}::u32 as i32, defaulting to {}", - i32::MAX - ); - i32::MAX - } else { - n as i32 - } -} - -pub fn u32_to_f32_or_max(n: u32) -> f32 { - if n > f32::MAX as u32 { - trace!( - "Overflow casting {n}::u32 as f32, defaulting to {}", - f32::MAX - ); - f32::MAX - } else { - n as f32 - } -} - -pub fn usize_to_f32_or_max(n: usize) -> f32 { - if n > f32::MAX as usize { - trace!( - "Overflow casting {n}::usize as f32, defaulting to {}", - f32::MAX - ); - f32::MAX - } else { - n as f32 - } -} - -pub fn usize_to_i32_or_max(n: usize) -> i32 { - if n > i32::MAX as usize { - trace!( - "Overflow casting {n}::usize as i32, defaulting to {}", - i32::MAX - ); - i32::MAX - } else { - n as i32 - } -} - -pub fn f32_to_i32_or_bound(n: f32) -> i32 { - if n > i32::MAX as f32 { - trace!( - "Overflow casting {n}::f32 as i32, defaulting to {}", - i32::MAX - ); - i32::MAX - } else if n < i32::MIN as f32 { - trace!( - "Underflow casting {n}::f32 as i32, defaulting to {}", - i32::MIN - ); - i32::MIN - } else { - n as i32 - } -} diff --git a/src/app/terminal/mod.rs b/src/app/terminal/mod.rs new file mode 100644 index 0000000..e69de29