Compare commits

...

5 Commits

Author SHA1 Message Date
5ba5a52c14 fix scroll 2025-04-30 18:16:18 +02:00
e5ce246760 very basic scroll 2025-04-27 22:57:44 +02:00
a3fa7eb9d2 better cursor management 2025-04-27 13:38:11 +02:00
fdc923621e update readme with roadmap 2025-04-27 11:31:19 +02:00
ebd3d4008b safe conversions 2025-04-27 11:26:12 +02:00
6 changed files with 581 additions and 244 deletions

41
AmazonQ.md Normal file
View File

@@ -0,0 +1,41 @@
# 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

11
README.md Normal file
View File

@@ -0,0 +1,11 @@
# Simplicitty
## Roadmap
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

View File

@@ -58,9 +58,10 @@ impl winit::application::ApplicationHandler for Application {
event, event,
is_synthetic: _, 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(), WindowEvent::CloseRequested => event_loop.exit(),
_ => {} _ => {}

View File

@@ -1,3 +1,4 @@
use log::{debug, trace};
use std::sync::Arc; use std::sync::Arc;
use wgpu::{ use wgpu::{
CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance, CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance,
@@ -6,8 +7,8 @@ use wgpu::{
TextureViewDescriptor, TextureViewDescriptor,
}; };
use winit::dpi::PhysicalSize; use winit::dpi::PhysicalSize;
use winit::event::KeyEvent; use winit::event::{ElementState, KeyEvent};
use winit::keyboard::{Key, NamedKey}; use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey};
use winit::window::Window; use winit::window::Window;
mod terminal_text; mod terminal_text;
@@ -23,10 +24,16 @@ pub struct State {
text: terminal_text::TerminalText, text: terminal_text::TerminalText,
window: Arc<Window>, window: Arc<Window>,
// Keyboard state tracking
modifiers: ModifiersState,
dead_key_state: Option<char>,
caps_lock_enabled: bool,
} }
impl State { impl State {
pub fn resize(&mut self, new_size: PhysicalSize<u32>) { pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
trace!("Resizing window");
if new_size.width > 0 && new_size.height > 0 { if new_size.width > 0 && new_size.height > 0 {
self.size = new_size; self.size = new_size;
self.config.width = new_size.width; self.config.width = new_size.width;
@@ -38,6 +45,7 @@ impl State {
} }
pub fn render(&mut self) -> Result<(), SurfaceError> { pub fn render(&mut self) -> Result<(), SurfaceError> {
trace!("Rendering requested");
// Prepare with current configuration (which includes the current window size) // Prepare with current configuration (which includes the current window size)
self.text.prepare(&self.device, &self.queue, &self.config); self.text.prepare(&self.device, &self.queue, &self.config);
@@ -67,14 +75,13 @@ impl State {
occlusion_query_set: None, occlusion_query_set: None,
}); });
trace!("Rendering...");
self.text.render(&mut _render_pass); self.text.render(&mut _render_pass);
} }
self.queue.submit(Some(encoder.finish())); self.queue.submit(Some(encoder.finish()));
output.present(); output.present();
self.text.trim();
Ok(()) Ok(())
} }
@@ -187,29 +194,200 @@ impl State {
text, text,
window, 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) { 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 { match &event.logical_key {
Key::Named(k) => match k { Key::Named(k) => {
NamedKey::Enter => { match k {
self.text.handle_enter(); 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);
}
} }
_ => todo!(), }
},
Key::Character(s) => { Key::Character(s) => {
for c in s.chars() { // Handle character input, considering dead keys
self.text.insert_char(c); 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(_) => { Key::Dead(dead_key) => {
todo!() // 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(_) => { Key::Unidentified(unidentified) => {
todo!() debug!(
"Unidentified key: {:?}, location: {:?}",
unidentified, event.physical_key
);
} }
} }
self.window.request_redraw(); 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<String> {
// 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,
}
}
} }

View File

@@ -1,8 +1,10 @@
use glyphon::{ use glyphon::{
Buffer, BufferLine, Cache, Color, Cursor, FontSystem, Metrics, Resolution, Shaping, SwashCache, Buffer, Cache, Color, Cursor, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap, TextBounds, TextRenderer, Viewport, Wrap,
}; };
pub use glyphon::cosmic_text::Motion;
use wgpu::{Device, MultisampleState, Queue, RenderPass, SurfaceConfiguration, TextureFormat}; use wgpu::{Device, MultisampleState, Queue, RenderPass, SurfaceConfiguration, TextureFormat};
pub struct TerminalText { pub struct TerminalText {
@@ -14,9 +16,24 @@ pub struct TerminalText {
renderer: TextRenderer, renderer: TextRenderer,
dirty_regions: Vec<TextBounds>, dirty_regions: Vec<TextBounds>,
cursor: Cursor, cursor: Cursor,
max_scroll_lines: usize,
} }
use log::{debug, trace}; // 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 { impl TerminalText {
pub fn new(device: &Device, queue: &Queue, surface_format: TextureFormat) -> TerminalText { pub fn new(device: &Device, queue: &Queue, surface_format: TextureFormat) -> TerminalText {
@@ -50,155 +67,13 @@ impl TerminalText {
index: 0, index: 0,
affinity: glyphon::Affinity::After, affinity: glyphon::Affinity::After,
}, },
max_scroll_lines: 100,
} }
} }
pub fn resize(&mut self, width: u32, height: u32) { // Calculate the bounds delimited by the `start` and `end` lines
trace!("Resizing window - Width: {width} Height: {height}"); fn get_text_bounds(&self, start: usize, end: usize) -> TextBounds {
self.buffer.set_size( if start >= self.buffer.lines.len() || end > self.buffer.lines.len() {
&mut self.font_system,
Some(width as f32),
Some(height as f32),
);
// 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 {
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,
},
);
if !self.dirty_regions.is_empty() {
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 = &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);
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() {
// Return default bounds for invalid line // Return default bounds for invalid line
return TextBounds { return TextBounds {
left: 0, left: 0,
@@ -211,52 +86,38 @@ impl TerminalText {
let line_height = self.buffer.metrics().line_height; let line_height = self.buffer.metrics().line_height;
let viewport_width = self.viewport.resolution().width; let viewport_width = self.viewport.resolution().width;
// Use layout_runs to get information about wrapped lines
let layout_iter = self.buffer.layout_runs(); let layout_iter = self.buffer.layout_runs();
let mut top_line = 0; let mut top_line = 0;
let mut bottom_line = 0; let mut bottom_line = 0;
let mut found_line = false;
// Iterate through layout runs to find our target line let mut found_start_line = false;
let mut found_end_line = false;
for (visual_line_count, run) in layout_iter.enumerate() { for (visual_line_count, run) in layout_iter.enumerate() {
if run.line_i == line { if run.line_i == start && !found_start_line {
// This run belongs to our target line top_line = visual_line_count;
if !found_line { found_start_line = true;
// First run of our target line }
top_line = visual_line_count; if run.line_i == end {
found_line = true; if !found_end_line {
found_end_line = true;
} }
// Update bottom line for each run we find for this line
bottom_line = visual_line_count + 1; bottom_line = visual_line_count + 1;
} else if found_line { } else if found_end_line {
// We've processed all runs for our target line
break; break;
} }
} }
// Calculate bounds based on visual line positions // Calculate bounds based on visual line positions
let top_f32 = (top_line as f32 * line_height).floor(); let top_f32 = (safe_casts::usize_to_f32_or_max(top_line) * line_height).floor();
let bottom_f32 = (bottom_line as f32 * line_height).ceil(); let bottom_f32 = (safe_casts::usize_to_f32_or_max(bottom_line) * line_height).ceil();
// Safe conversions with overflow checks // Safe conversions with overflow checks
let top = if top_f32 > i32::MAX as f32 { let top = safe_casts::f32_to_i32_or_bound(top_f32);
i32::MAX let bottom = safe_casts::f32_to_i32_or_bound(bottom_f32);
} else if top_f32 < i32::MIN as f32 {
i32::MIN
} else {
top_f32 as i32
};
let bottom = if bottom_f32 > i32::MAX as f32 {
i32::MAX
} else if bottom_f32 < i32::MIN as f32 {
i32::MIN
} else {
bottom_f32 as i32
};
// Ensure viewport width doesn't exceed i32::MAX // Ensure viewport width doesn't exceed i32::MAX
let right = viewport_width.min(i32::MAX as u32) as i32; let right = safe_casts::u32_to_i32_or_max(viewport_width.min(i32::MAX as u32));
TextBounds { TextBounds {
left: 0, left: 0,
@@ -267,11 +128,12 @@ impl TerminalText {
} }
fn get_visible_line_range(&self) -> (usize, usize) { fn get_visible_line_range(&self) -> (usize, usize) {
let viewport_height = self.viewport.resolution().height as f32; let viewport_height = safe_casts::u32_to_f32_or_max(self.viewport.resolution().height);
let line_height = self.buffer.metrics().line_height; let line_height = self.buffer.metrics().line_height;
// Start from line 0 (no scrolling yet) let start_line = self.buffer.scroll().line;
let start_line = 0;
trace!("Current start line: {start_line}");
// Calculate visible lines based on wrapped text // Calculate visible lines based on wrapped text
let layout_iter = self.buffer.layout_runs(); let layout_iter = self.buffer.layout_runs();
@@ -282,65 +144,240 @@ impl TerminalText {
last_logical_line = run.line_i; last_logical_line = run.line_i;
// If we've exceeded the viewport height, we can stop counting // If we've exceeded the viewport height, we can stop counting
if ((start_line + visual_line_count) as f32 * line_height) > viewport_height { if (safe_casts::usize_to_f32_or_max(visual_line_count) * line_height) > viewport_height
{
break; break;
} }
} }
// Add 1 to include partially visible lines at the bottom // Add 1 to include partially visible lines at the bottom
let end_line = if self.buffer.lines.is_empty() { let end_line = last_logical_line;
0
} else {
// Make sure we include at least the last logical line that has content in the viewport
(last_logical_line + 1).min(self.buffer.lines.len())
};
trace!("visible line range goes from {start_line} to {end_line}"); trace!("visible line range goes from {start_line} to {end_line}");
(start_line, end_line) (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(); let (start_line, end_line) = self.get_visible_line_range();
for i in start_line..end_line { self.dirty_regions
self.dirty_regions.push(self.get_line_bounds(i)); .push(self.get_text_bounds(start_line, end_line));
}
} }
// pub fn mark_all_dirty(&mut self) { fn merge_dirty_regions(&self) -> TextBounds {
// trace!("Marking all regions as dirty"); if self.dirty_regions.is_empty() {
// // Create a single region covering the entire viewport return TextBounds::default();
// 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) { self.dirty_regions.iter().fold(
// if self.cursor.index > 0 { TextBounds {
// self.cursor.index -= 1; left: 0,
// } else if self.cursor.line > 0 { top: 0,
// // Move to end of previous line right: 0,
// self.cursor.line -= 1; bottom: 0,
// let prev_line_len = self.buffer.lines[self.cursor.line].text().len(); },
// self.cursor.index = prev_line_len; |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) { pub fn prepare(&mut self, device: &Device, queue: &Queue, config: &SurfaceConfiguration) {
// if self.cursor.line < self.buffer.lines.len() { // Update viewport with new dimensions
// let current_line_len = self.buffer.lines[self.cursor.line].text().len(); self.viewport.update(
queue,
Resolution {
width: config.width,
height: config.height,
},
);
// if self.cursor.index < current_line_len { let region = self.merge_dirty_regions();
// self.cursor.index += 1; trace!("Preparing region {:?}", region);
// } else if self.cursor.line < self.buffer.lines.len() - 1 { self.renderer
// // Move to beginning of next line .prepare(
// self.cursor.line += 1; device,
// self.cursor.index = 0; 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<F, R>(&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
}
}
})
}
} }

View File

@@ -0,0 +1,69 @@
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
}
}