9 Commits

10 changed files with 3876 additions and 24 deletions

23
.gitignore vendored
View File

@@ -1,22 +1 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/target

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

2875
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "simplicitty"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.44", features = ["full"] }
winit = { version = "0.30", features = ["wayland", "x11"] }
wgpu = "25"
glyphon = "0.9"
termion = "4"
env_logger = "0.10"
log = "0.4"

View File

@@ -1,3 +1,11 @@
# simplicitty
# Simplicitty
A simple terminal emulator
## 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

70
src/app/mod.rs Normal file
View File

@@ -0,0 +1,70 @@
use std::sync::Arc;
use tokio::runtime::Runtime;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::window::Window;
mod rendering;
pub struct Application {
window_state: Option<rendering::State>,
runtime: Runtime,
}
impl Application {
pub fn new() -> Self {
Self {
window_state: None,
runtime: Runtime::new().expect("Failed to create tokio runtime"),
}
}
}
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)));
}
fn window_event(
&mut self,
event_loop: &winit::event_loop::ActiveEventLoop,
_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(),
_ => {}
}
}
}

393
src/app/rendering/mod.rs Normal file
View File

@@ -0,0 +1,393 @@
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<u32>,
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);
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<Window>) -> 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<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

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

18
src/main.rs Normal file
View File

@@ -0,0 +1,18 @@
use log::info;
use winit::event_loop::EventLoop;
mod app;
fn main() {
// Initialize logger
env_logger::init();
// Log platform information
info!("Starting simplicitty terminal emulator");
// Create event loop with explicit backend preference
let event_loop = EventLoop::new().unwrap();
// Run the application
event_loop.run_app(&mut app::Application::new()).unwrap();
}