diff --git a/src/app/mod.rs b/src/app/mod.rs index 87a9e7b..223c032 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -58,9 +58,10 @@ impl winit::application::ApplicationHandler for Application { event, is_synthetic: _, } => { - if event.state.is_pressed() { - state.handle_keyboard_input(&event); - } + state.handle_keyboard_input(&event); + } + WindowEvent::ModifiersChanged(modifiers) => { + state.update_modifiers(modifiers.state()); } WindowEvent::CloseRequested => event_loop.exit(), _ => {} diff --git a/src/app/rendering/mod.rs b/src/app/rendering/mod.rs index a65f9e9..6cf8e99 100644 --- a/src/app/rendering/mod.rs +++ b/src/app/rendering/mod.rs @@ -1,3 +1,4 @@ +use log::{debug, trace}; use std::sync::Arc; use wgpu::{ CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance, @@ -6,8 +7,8 @@ use wgpu::{ TextureViewDescriptor, }; use winit::dpi::PhysicalSize; -use winit::event::KeyEvent; -use winit::keyboard::{Key, NamedKey}; +use winit::event::{ElementState, KeyEvent}; +use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey}; use winit::window::Window; mod terminal_text; @@ -23,6 +24,11 @@ pub struct State { text: terminal_text::TerminalText, window: Arc, + + // Keyboard state tracking + modifiers: ModifiersState, + dead_key_state: Option, + caps_lock_enabled: bool, } impl State { @@ -73,8 +79,6 @@ impl State { self.queue.submit(Some(encoder.finish())); output.present(); - self.text.trim(); - Ok(()) } @@ -187,29 +191,199 @@ impl State { 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 => { - self.text.handle_enter(); + Key::Named(k) => { + match k { + NamedKey::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 { + // Implement backspace functionality + 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); + } } - _ => todo!(), - }, + } Key::Character(s) => { - for c in s.chars() { - self.text.insert_char(c); + // 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() { + self.text.insert_char(c); + } + } else { + // If combination not possible, insert both separately + self.text.insert_char(dead_char); + for c in s.chars() { + self.text.insert_char(c); + } + } + } else { + // Normal character input + for c in s.chars() { + self.text.insert_char(c); + } } } - Key::Dead(_) => { - todo!() + 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(_) => { - todo!() + 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 index 04b8027..49104f1 100644 --- a/src/app/rendering/terminal_text/mod.rs +++ b/src/app/rendering/terminal_text/mod.rs @@ -1,8 +1,10 @@ use glyphon::{ - Buffer, BufferLine, Cache, Color, Cursor, FontSystem, Metrics, Resolution, Shaping, SwashCache, - TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap, + Buffer, BufferLine, Cache, Color, Cursor, FontSystem, LayoutCursor, Metrics, Resolution, + Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap, }; +pub use glyphon::cosmic_text::Motion; + use wgpu::{Device, MultisampleState, Queue, RenderPass, SurfaceConfiguration, TextureFormat}; pub struct TerminalText { @@ -18,7 +20,7 @@ pub struct TerminalText { mod safe_casts; -use log::{debug, trace}; +use log::{debug, error, trace}; impl TerminalText { pub fn new(device: &Device, queue: &Queue, surface_format: TextureFormat) -> TerminalText { @@ -55,156 +57,6 @@ impl TerminalText { } } - pub fn resize(&mut self, width: u32, height: u32) { - trace!("Resizing window - Width: {width} Height: {height}"); - - self.buffer.set_size( - &mut self.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 - self.buffer.set_wrap(&mut self.font_system, Wrap::Glyph); - // Reshape the text with the new dimensions - self.buffer.shape_until_scroll(&mut self.font_system, false); - self.ensure_visible_text_rendered(); - } - - fn merge_dirty_regions(&self) -> TextBounds { - if self.dirty_regions.is_empty() { - return TextBounds { - left: 0, - top: 0, - right: 0, - bottom: 0, - }; - } - TextBounds { - left: self - .dirty_regions - .iter() - .min_by_key(|b| b.left) - .unwrap() - .left, - top: self.dirty_regions.iter().min_by_key(|b| b.top).unwrap().top, - right: self - .dirty_regions - .iter() - .max_by_key(|b| b.right) - .unwrap() - .right, - bottom: self - .dirty_regions - .iter() - .max_by_key(|b| b.bottom) - .unwrap() - .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(&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; - } - - let mut text = self.buffer.lines[line].text().to_string().clone(); - let pos = self.cursor.index.min(text.len()); - text.insert(pos, c); - - 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(&text, ending, attrs); - // Update cursor position - self.cursor.index = pos + 1; - - self.buffer.shape_until_scroll(&mut self.font_system, false); - self.ensure_visible_text_rendered(); - } - - 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 = ¤t_line[..pos]; - let text_after = ¤t_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); - self.buffer.lines.insert(line + 1, new_line); - - // 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); - self.ensure_visible_text_rendered(); - } - fn get_line_bounds(&self, line: usize) -> TextBounds { // Calculate the bounds for a specific line if line >= self.buffer.lines.len() { @@ -298,47 +150,217 @@ impl TerminalText { (start_line, end_line) } - pub fn ensure_visible_text_rendered(&mut self) { + fn ensure_visible_text_rendered(&mut self) { let (start_line, end_line) = self.get_visible_line_range(); for i in start_line..end_line { self.dirty_regions.push(self.get_line_bounds(i)); } + + // Also mark the cursor line as dirty to ensure it's rendered + if self.cursor.line < self.buffer.lines.len() { + self.dirty_regions + .push(self.get_line_bounds(self.cursor.line)); + } } - // pub fn mark_all_dirty(&mut self) { - // trace!("Marking all regions as dirty"); - // // 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]; - // } + fn merge_dirty_regions(&self) -> TextBounds { + if self.dirty_regions.is_empty() { + return TextBounds::default(); // Assuming TextBounds implements Default + } - // 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; - // } - // } + self.dirty_regions + .iter() + .fold(TextBounds::default(), |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 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(); + 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, + }, + ); - // 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; - // } - // } - // } + 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); + 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()); + 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()); + let new_line = this.buffer.lines[line].split_off(pos); + + // Create a new line with text after cursor + this.buffer.lines.insert(line + 1, new_line); + + // Update cursor position to beginning of next line + this.cursor.line = line + 1; + this.cursor.index = 0; + }) + } + + 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 index 424d396..18c8739 100644 --- a/src/app/rendering/terminal_text/safe_casts.rs +++ b/src/app/rendering/terminal_text/safe_casts.rs @@ -38,6 +38,18 @@ pub fn usize_to_f32_or_max(n: usize) -> 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!(