Compare commits

...

7 Commits

6 changed files with 618 additions and 563 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

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

View File

@@ -2,7 +2,6 @@ use std::sync::Arc;
use tokio::runtime::Runtime;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::keyboard::{Key, NamedKey};
use winit::window::Window;
mod rendering;
@@ -59,25 +58,10 @@ impl winit::application::ApplicationHandler for Application {
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!()
}
}
}
state.handle_keyboard_input(&event);
}
WindowEvent::ModifiersChanged(modifiers) => {
state.update_modifiers(modifiers.state());
}
WindowEvent::CloseRequested => event_loop.exit(),
_ => {}

View File

@@ -1,3 +1,4 @@
use log::{debug, trace};
use std::sync::Arc;
use wgpu::{
CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Instance,
@@ -6,6 +7,8 @@ use wgpu::{
TextureViewDescriptor,
};
use winit::dpi::PhysicalSize;
use winit::event::{ElementState, KeyEvent};
use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey};
use winit::window::Window;
mod terminal_text;
@@ -21,20 +24,28 @@ pub struct State {
text: terminal_text::TerminalText,
window: Arc<Window>,
// Keyboard state tracking
modifiers: ModifiersState,
dead_key_state: Option<char>,
caps_lock_enabled: bool,
}
impl State {
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
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);
@@ -64,14 +75,13 @@ impl State {
occlusion_query_set: None,
});
trace!("Rendering...");
self.text.render(&mut _render_pass);
}
self.queue.submit(Some(encoder.finish()));
output.present();
self.text.trim();
Ok(())
}
@@ -184,18 +194,200 @@ impl State {
text,
window,
// Initialize keyboard state
modifiers: ModifiersState::empty(),
dead_key_state: None,
caps_lock_enabled: false,
}
}
pub fn add_text(&mut self, text: &str) {
for c in text.chars() {
self.text.insert_char(c);
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();
}
pub fn handle_enter(&mut self) {
self.text.handle_enter();
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::{
Attrs, Buffer, BufferLine, Cache, Color, Cursor, Family, FontSystem, Metrics, Resolution,
Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap,
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 {
@@ -14,12 +16,28 @@ pub struct TerminalText {
renderer: TextRenderer,
dirty_regions: Vec<TextBounds>,
cursor: Cursor,
max_scroll_lines: usize,
}
use log::info;
// 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();
@@ -32,6 +50,7 @@ impl TerminalText {
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();
@@ -48,30 +67,122 @@ impl TerminalText {
index: 0,
affinity: glyphon::Affinity::After,
},
max_scroll_lines: 100,
}
}
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);
// 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,
}
}
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);
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) {
@@ -84,192 +195,189 @@ impl TerminalText {
},
);
// 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();
}
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) {
pub fn render(&mut 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;
fn with_update<F, R>(&mut self, operation: F) -> R
where
F: FnOnce(&mut Self) -> R,
{
let result = operation(self);
if line >= self.buffer.lines.len() {
// TODO
return;
let mut scroll = self.buffer.scroll();
if self.buffer.lines.len() > self.max_scroll_lines {
self.buffer.lines.remove(0);
self.cursor.line -= 1;
}
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;
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
}
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,
}
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)
});
}
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 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) {
let line = self.cursor.line;
self.with_update(|this| {
let line = this.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;
// 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
}
}