4 Commits

8 changed files with 3540 additions and 25 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

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 +0,0 @@
# simplicitty
A simple terminal emulator

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

@@ -0,0 +1,69 @@
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: _,
} => {
if event.state.is_pressed() {
state.handle_keyboard_input(&event);
}
}
WindowEvent::CloseRequested => event_loop.exit(),
_ => {}
}
}
}

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

@@ -0,0 +1,215 @@
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::KeyEvent;
use winit::keyboard::{Key, 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>,
}
impl State {
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
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> {
// 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,
});
self.text.render(&mut _render_pass);
}
self.queue.submit(Some(encoder.finish()));
output.present();
self.text.trim();
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,
}
}
pub fn handle_keyboard_input(&mut self, event: &KeyEvent) {
match &event.logical_key {
Key::Named(k) => match k {
NamedKey::Enter => {
self.text.handle_enter();
}
_ => todo!(),
},
Key::Character(s) => {
for c in s.chars() {
self.text.insert_char(c);
}
}
Key::Dead(_) => {
todo!()
}
Key::Unidentified(_) => {
todo!()
}
}
self.window.request_redraw();
}
}

View File

@@ -0,0 +1,346 @@
use glyphon::{
Buffer, BufferLine, Cache, Color, Cursor, FontSystem, Metrics, Resolution, Shaping, SwashCache,
TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Wrap,
};
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,
}
use log::{debug, 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,
},
}
}
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(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 TextBounds {
left: 0,
top: 0,
right: 0,
bottom: 0,
};
}
let line_height = self.buffer.metrics().line_height;
let viewport_width = self.viewport.resolution().width;
// Use layout_runs to get information about wrapped lines
let layout_iter = self.buffer.layout_runs();
let mut top_line = 0;
let mut bottom_line = 0;
let mut found_line = false;
// Iterate through layout runs to find our target line
for (visual_line_count, run) in layout_iter.enumerate() {
if run.line_i == line {
// This run belongs to our target line
if !found_line {
// First run of our target line
top_line = visual_line_count;
found_line = true;
}
// Update bottom line for each run we find for this line
bottom_line = visual_line_count + 1;
} else if found_line {
// We've processed all runs for our target line
break;
}
}
// Calculate bounds based on visual line positions
let top_f32 = (top_line as f32 * line_height).floor();
let bottom_f32 = (bottom_line as f32 * line_height).ceil();
// Safe conversions with overflow checks
let top = if top_f32 > i32::MAX as f32 {
i32::MAX
} 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
let right = viewport_width.min(i32::MAX as u32) as i32;
TextBounds {
left: 0,
top,
right,
bottom,
}
}
fn get_visible_line_range(&self) -> (usize, usize) {
let viewport_height = self.viewport.resolution().height as f32;
let line_height = self.buffer.metrics().line_height;
// Start from line 0 (no scrolling yet)
let start_line = 0;
// 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 ((start_line + visual_line_count) as f32 * line_height) > viewport_height {
break;
}
}
// Add 1 to include partially visible lines at the bottom
let end_line = if self.buffer.lines.is_empty() {
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}");
(start_line, end_line)
}
pub 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));
}
}
// 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];
// }
// 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;
// }
// }
// }
}

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();
}