diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000000..9bc80eb14b50 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + create-release: + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + build-release: + needs: create-release + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + # Linux + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: edit + asset_name: edit-x86_64-linux.tar.gz + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + artifact_name: edit + asset_name: edit-x86_64-linux-musl.tar.gz + # macOS + - os: macos-latest + target: x86_64-apple-darwin + artifact_name: edit + asset_name: edit-x86_64-macos.tar.gz + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: edit + asset_name: edit-aarch64-macos.tar.gz + # Windows + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: edit.exe + asset_name: edit-x86_64-windows.zip + - os: windows-latest + target: aarch64-pc-windows-msvc + artifact_name: edit.exe + asset_name: edit-aarch64-windows.zip + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + override: true + + - name: Install cross-compilation tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Build + uses: actions-rs/cargo@v1 + with: + command: build + args: --release --target ${{ matrix.target }} + + - name: Create archive (Unix) + if: matrix.os != 'windows-latest' + run: | + cd target/${{ matrix.target }}/release + tar -czf ../../../${{ matrix.asset_name }} ${{ matrix.artifact_name }} + + - name: Create archive (Windows) + if: matrix.os == 'windows-latest' + run: | + cd target/${{ matrix.target }}/release + 7z a ../../../${{ matrix.asset_name }} ${{ matrix.artifact_name }} + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./${{ matrix.asset_name }} + asset_name: ${{ matrix.asset_name }} + asset_content_type: application/octet-stream diff --git a/Cargo.lock b/Cargo.lock index 986eaa59fb03..a6b5394d65bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,7 @@ version = "1.2.1" dependencies = [ "criterion", "libc", + "regex", "serde", "serde_json", "windows-sys", diff --git a/Cargo.toml b/Cargo.toml index 3775b8f22601..4d731897b7a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,13 @@ version = "1.2.1" edition = "2024" rust-version = "1.87" readme = "README.md" -repository = "https://github.com/microsoft/edit" -homepage = "https://github.com/microsoft/edit" +repository = "https://github.com/sichy/edit" +homepage = "https://github.com/sichy/edit" license = "MIT" -categories = ["text-editors"] +description = "Fast and efficient text editor CLI tool" +keywords = ["text-editor", "cli", "editor"] +categories = ["text-editors", "command-line-utilities"] +exclude = ["npm/", "Formula/", ".github/", "target/", "*.log"] [[bench]] name = "lib" @@ -34,6 +37,7 @@ codegen-units = 16 # Make compiling criterion faster (16 is the default lto = "thin" # Similarly, speed up linking by a ton [dependencies] +regex = "1.0" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/Formula/edit.rb b/Formula/edit.rb new file mode 100644 index 000000000000..bf3ed1e71c79 --- /dev/null +++ b/Formula/edit.rb @@ -0,0 +1,22 @@ +class Edit < Formula + desc "MS-DOS style modern CLI editor" + homepage "https://github.com/sichy/edit" + version "1.2.1" + + if Hardware::CPU.intel? + url "https://github.com/microsoft/edit/releases/download/v#{version}/edit-x86_64-macos.tar.gz" + sha256 "REPLACE_WITH_ACTUAL_SHA256_FOR_X86_64" # Will be calculated after first release + elsif Hardware::CPU.arm? + url "https://github.com/microsoft/edit/releases/download/v#{version}/edit-aarch64-macos.tar.gz" + sha256 "REPLACE_WITH_ACTUAL_SHA256_FOR_ARM64" # Will be calculated after first release + end + + def install + bin.install "edit" + end + + test do + # Test that the binary runs and shows help + system "#{bin}/edit", "--help" + end +end diff --git a/README.md b/README.md index 021daa584ea9..20d3f12c1dc0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![Application Icon for Edit](./assets/edit.svg) Edit -A simple editor for simple needs. +A simple editor for simple needs. With my slight changes to add syntax highlighting and multifile edit. Old school MS-DOS TUI and nice dark theme. Work in progress and good relax in Rust. This editor pays homage to the classic [MS-DOS Editor](https://en.wikipedia.org/wiki/MS-DOS_Editor), but with a modern interface and input controls similar to VS Code. The goal is to provide an accessible editor that even users largely unfamiliar with terminals can easily use. diff --git a/assets/edit_hero_image.png b/assets/edit_hero_image.png index 87bed354223d..1ed3d1b4ef84 100644 Binary files a/assets/edit_hero_image.png and b/assets/edit_hero_image.png differ diff --git a/debian/DEBIAN/control b/debian/DEBIAN/control new file mode 100644 index 000000000000..7adaffec748c --- /dev/null +++ b/debian/DEBIAN/control @@ -0,0 +1,11 @@ +Package: edit +Version: 1.2.1 +Section: editors +Priority: optional +Architecture: amd64 +Depends: libc6 (>= 2.17) +Maintainer: Microsoft +Description: Fast and efficient text editor CLI tool + A modern, fast, and efficient command-line text editor + built with Rust. Provides MS-DOS style editing capabilities + with modern performance and cross-platform support. diff --git a/src/bin/edit/documents.rs b/src/bin/edit/documents.rs index 33fc8cf5a76d..2fb1ffb9cb58 100644 --- a/src/bin/edit/documents.rs +++ b/src/bin/edit/documents.rs @@ -70,6 +70,13 @@ impl Document { fn update_file_mode(&mut self) { let mut tb = self.buffer.borrow_mut(); tb.set_ruler(if self.filename == "COMMIT_EDITMSG" { 72 } else { 0 }); + + // Set syntax highlighting based on file extension + if let Some(path) = &self.path { + if let Some(extension) = path.extension().and_then(|e| e.to_str()) { + tb.set_syntax_from_extension(extension); + } + } } } @@ -112,6 +119,46 @@ impl DocumentManager { self.list.pop_front(); } + /// Get the index of the currently active document + pub fn active_index(&self) -> usize { + 0 // The active document is always at the front (index 0) + } + + /// Set the active document by index + pub fn set_active_index(&mut self, index: usize) { + if index >= self.list.len() { + return; + } + + let mut cursor = self.list.cursor_front_mut(); + for _ in 0..index { + cursor.move_next(); + } + + if let Some(list) = cursor.remove_current_as_list() { + self.list.cursor_front_mut().splice_before(list); + } + } + + /// Remove document at specific index + pub fn remove_at_index(&mut self, index: usize) { + if index >= self.list.len() { + return; + } + + let mut cursor = self.list.cursor_front_mut(); + for _ in 0..index { + cursor.move_next(); + } + + cursor.remove_current(); + } + + /// Get an iterator over all documents + pub fn iter(&self) -> impl Iterator { + self.list.iter() + } + pub fn add_untitled(&mut self) -> apperr::Result<&mut Document> { let buffer = Self::create_buffer()?; let mut doc = Document { @@ -166,6 +213,11 @@ impl DocumentManager { let mut tb = buffer.borrow_mut(); tb.read_file(file, None)?; + // Set syntax highlighting based on file extension + if let Some(extension) = path.extension().and_then(|e| e.to_str()) { + tb.set_syntax_from_extension(extension); + } + if let Some(goto) = goto && goto != Default::default() { diff --git a/src/bin/edit/draw_ai_dock.rs b/src/bin/edit/draw_ai_dock.rs new file mode 100644 index 000000000000..721ae7bd1dbf --- /dev/null +++ b/src/bin/edit/draw_ai_dock.rs @@ -0,0 +1,187 @@ +// Copyright (c) Pavel Sich. +// Licensed under the MIT License. + +//! AI Dock interface for agentic AI commands + +use edit::helpers::*; +use edit::input::{kbmod, vk}; +use edit::tui::*; + +use crate::state::*; + +pub fn draw_ai_dock(ctx: &mut Context, state: &mut State) { + if !state.ai_dock_visible { + return; + } + + // Calculate height based on dock size + let dock_height = match state.ai_dock_size { + AiDockSize::Minimized => 3, // Just header and border + AiDockSize::Default => 8, // Normal size + AiDockSize::Expanded => ctx.size().height / 2, // 50% of screen + }; + + // Create AI dock area + ctx.block_begin("ai_dock"); + ctx.attr_intrinsic_size(Size { width: ctx.size().width, height: dock_height }); + ctx.attr_position(Position::Right); // Position it to not interfere with status bar + ctx.attr_background_rgba(0xFF2d2d2d); // Dark background + ctx.attr_foreground_rgba(0xFFe0e0e0); // Light text + { + // Header with title and resize buttons + // Draw border-integrated header + ctx.block_begin("ai_header"); + ctx.attr_padding(Rect::three(1, 0, 0)); + { + ctx.table_begin("ai_header_table"); + ctx.table_set_cell_gap(Size { width: 0, height: 0 }); + { + ctx.table_next_row(); + + // Title with dash prefix + ctx.label("title_prefix1", "──"); + ctx.label("title_prefix2", " AI Assistant "); + ctx.attr_foreground_rgba(0xFFe0e0e0); // Light text + + // Control buttons + match state.ai_dock_size { + AiDockSize::Minimized => { + if ctx.button("expand_up", "▲", ButtonStyle::default()) { + state.ai_dock_size = AiDockSize::Default; + ctx.needs_rerender(); + } + if ctx.button("close", "x", ButtonStyle::default()) { + state.ai_dock_visible = false; + state.ai_dock_focused = false; + ctx.needs_rerender(); + } + }, + AiDockSize::Default => { + if ctx.button("minimize_down", "▼", ButtonStyle::default()) { + state.ai_dock_size = AiDockSize::Minimized; + ctx.needs_rerender(); + } + if ctx.button("expand_up", "▲", ButtonStyle::default()) { + state.ai_dock_size = AiDockSize::Expanded; + ctx.needs_rerender(); + } + if ctx.button("close", "x", ButtonStyle::default()) { + state.ai_dock_visible = false; + state.ai_dock_focused = false; + ctx.needs_rerender(); + } + }, + AiDockSize::Expanded => { + if ctx.button("minimize_down", "▼", ButtonStyle::default()) { + state.ai_dock_size = AiDockSize::Default; + ctx.needs_rerender(); + } + if ctx.button("close", "x", ButtonStyle::default()) { + state.ai_dock_visible = false; + state.ai_dock_focused = false; + ctx.needs_rerender(); + } + }, + } + } + ctx.table_end(); + } + ctx.block_end(); + + // Show contents based on size state + if state.ai_dock_size != AiDockSize::Minimized { + // Prompt input area + ctx.block_begin("ai_prompt_section"); + ctx.attr_padding(Rect::three(1, 1, 0)); + { + ctx.label("prompt_label", "Prompt:"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_foreground_rgba(0xFFBBBBBB); // Gray label + + // Text input for AI prompt using editline + if ctx.editline("ai_prompt_input", &mut state.ai_prompt) { + // Handle input changes if needed + } + + if state.ai_dock_focused { + ctx.inherit_focus(); + } + } + ctx.block_end(); + + // Action buttons in a single row + ctx.block_begin("ai_buttons"); + ctx.attr_padding(Rect::three(1, 1, 0)); + { + ctx.table_begin("button_table"); + ctx.table_set_cell_gap(Size { width: 1, height: 0 }); + { + ctx.table_next_row(); + + if ctx.button("send", "Send (Ctrl+Enter)", ButtonStyle::default()) { + execute_ai_prompt(ctx, state); + } + + if ctx.button("clear", "Clear", ButtonStyle::default()) { + state.ai_prompt.clear(); + ctx.needs_rerender(); + } + } + ctx.table_end(); + } + ctx.block_end(); + + // Output area (if there's output) + if !state.ai_output.is_empty() { + ctx.block_begin("ai_output_section"); + ctx.attr_padding(Rect::three(1, 1, 0)); + { + ctx.label("output_label", "Output:"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_foreground_rgba(0xFFBBBBBB); // Gray label + + ctx.label("ai_output", &state.ai_output); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_foreground_rgba(0xFF90EE90); // Light green for output + } + ctx.block_end(); + } + } + } + ctx.block_end(); + + // Handle keyboard input when AI dock is focused + if state.ai_dock_focused { + if let Some(key) = ctx.keyboard_input() { + if key == vk::ESCAPE { + state.ai_dock_visible = false; + state.ai_dock_focused = false; + ctx.needs_rerender(); + ctx.set_input_consumed(); + } else if key == kbmod::CTRL | vk::RETURN { + execute_ai_prompt(ctx, state); + ctx.needs_rerender(); + ctx.set_input_consumed(); + } + } + } +} + +fn execute_ai_prompt(ctx: &mut Context, state: &mut State) { + if state.ai_prompt.trim().is_empty() { + return; + } + + // For now, just simulate AI output with a simple response + // TODO: Integrate with actual AI API + state.ai_output = format!("AI Response to: '{}'\n[This is a placeholder - AI integration coming soon!]", state.ai_prompt); + + // Optional: Insert output into current document + if let Some(doc) = state.documents.active() { + let mut tb = doc.buffer.borrow_mut(); + let output_text = format!("\n// AI Generated:\n// {}\n", state.ai_output); + tb.write_canon(output_text.as_bytes()); + } + + ctx.needs_rerender(); +} diff --git a/src/bin/edit/draw_editor.rs b/src/bin/edit/draw_editor.rs index 94f7dbfc50fa..cf2223700593 100644 --- a/src/bin/edit/draw_editor.rs +++ b/src/bin/edit/draw_editor.rs @@ -19,11 +19,20 @@ pub fn draw_editor(ctx: &mut Context, state: &mut State) { let size = ctx.size(); // TODO: The layout code should be able to just figure out the height on its own. - let height_reduction = match state.wants_search.kind { - StateSearchKind::Search => 4, - StateSearchKind::Replace => 5, - _ => 2, + let mut height_reduction = match state.wants_search.kind { + StateSearchKind::Search => 5, + StateSearchKind::Replace => 6, + _ => 3, }; + + // Add space for AI dock if visible + if state.ai_dock_visible { + height_reduction += match state.ai_dock_size { + AiDockSize::Minimized => 3, // Just header and border + AiDockSize::Default => 8, // Normal size + AiDockSize::Expanded => size.height / 2, // 50% of screen + }; + } if let Some(doc) = state.documents.active() { ctx.textarea("textarea", doc.buffer.clone()); diff --git a/src/bin/edit/draw_menubar.rs b/src/bin/edit/draw_menubar.rs index 9fe8b7cefb63..c61a3cdce64d 100644 --- a/src/bin/edit/draw_menubar.rs +++ b/src/bin/edit/draw_menubar.rs @@ -122,6 +122,15 @@ fn draw_menu_view(ctx: &mut Context, state: &mut State) { ctx.needs_rerender(); } } + + // AI Assistant menu item + if !state.ai_dock_visible { + if ctx.menubar_menu_button("Open AI Assistant", 'I', kbmod::CTRL_ALT | vk::B) { + state.ai_dock_visible = true; + state.ai_dock_size = AiDockSize::Default; + ctx.needs_rerender(); + } + } ctx.menubar_menu_end(); } @@ -160,6 +169,26 @@ pub fn draw_dialog_about(ctx: &mut Context, state: &mut State) { ctx.attr_overflow(Overflow::TruncateTail); ctx.attr_position(Position::Center); + ctx.label("empty-line", ""); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_position(Position::Center); + + ctx.label("pavel-contributions", "Contributions by Pavel Sich:"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_position(Position::Left); + + ctx.label("pavel-syntax", "• Syntax Highlighting"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_position(Position::Left); + + ctx.label("pavel-multifile", "• Multiple File Edit with Tab Support"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_position(Position::Left); + + ctx.label("pavel-theme", "• New Dark Color Theme"); + ctx.attr_overflow(Overflow::TruncateTail); + ctx.attr_position(Position::Left); + ctx.block_begin("choices"); ctx.inherit_focus(); ctx.attr_padding(Rect::three(1, 2, 0)); diff --git a/src/bin/edit/draw_tabbar.rs b/src/bin/edit/draw_tabbar.rs new file mode 100644 index 000000000000..3674f7add8d5 --- /dev/null +++ b/src/bin/edit/draw_tabbar.rs @@ -0,0 +1,82 @@ +// Copyright (c) Pavel Sich. +// Licensed under the MIT License. + +use edit::framebuffer::IndexedColor; +use edit::helpers::*; +use edit::tui::*; + +use crate::state::*; + +/// Store information about a tab for rendering +struct TabInfo { + index: usize, + filename: String, + is_dirty: bool, + is_active: bool, +} + +/// Draw the tab bar showing all open documents +pub fn draw_tabbar(ctx: &mut Context, state: &mut State) { + if state.documents.len() <= 1 { + // Don't show tab bar if there's only one document or no documents + return; + } + + // Collect tab information first to avoid borrowing conflicts + let active_index = state.documents.active_index(); + let tab_infos: Vec = state.documents.iter().enumerate().map(|(index, doc)| { + TabInfo { + index, + filename: doc.filename.clone(), + is_dirty: doc.buffer.borrow().is_dirty(), + is_active: index == active_index, + } + }).collect(); + + ctx.block_begin("tabbar"); + ctx.attr_background_rgba(ctx.indexed(IndexedColor::Background)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Foreground)); + ctx.attr_intrinsic_size(Size { width: 0, height: 1 }); + { + ctx.table_begin("tabs"); + ctx.table_set_cell_gap(Size { width: 0, height: 0 }); + ctx.table_next_row(); + + for tab_info in &tab_infos { + // Create tab text with filename and dirty indicator + let mut tab_text = String::new(); + tab_text.push_str(&tab_info.filename); + if tab_info.is_dirty { + tab_text.push('*'); + } + + // [TODO] Include close button in tab text if there are multiple tabs + if tab_infos.len() > 1 { + tab_text = format!(" {} ", tab_text); + } else { + tab_text = format!(" {} ", tab_text); + } + + // Create button ID by mixing the index into the class name + ctx.next_block_id_mixin(tab_info.index as u64); + + if ctx.button("tab", &tab_text, ButtonStyle::default().bracketed(false)) { + // Switch to this document + state.documents.set_active_index(tab_info.index); + ctx.needs_rerender(); + } + + // Style the active tab differently + if tab_info.is_active { + ctx.attr_background_rgba(ctx.indexed(IndexedColor::BrightBlue)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite)); + } else { + ctx.attr_background_rgba(ctx.indexed(IndexedColor::BrightBlack)); + ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::White)); + } + } + + ctx.table_end(); + } + ctx.block_end(); +} diff --git a/src/bin/edit/main.rs b/src/bin/edit/main.rs index dabef35847a5..f47b6427689e 100644 --- a/src/bin/edit/main.rs +++ b/src/bin/edit/main.rs @@ -4,10 +4,12 @@ #![feature(allocator_api, let_chains, linked_list_cursors, string_from_utf8_lossy_owned)] mod documents; +mod draw_ai_dock; mod draw_editor; mod draw_filepicker; mod draw_menubar; mod draw_statusbar; +mod draw_tabbar; mod localization; mod state; @@ -18,10 +20,12 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use std::{env, process}; +use draw_ai_dock::*; use draw_editor::*; use draw_filepicker::*; use draw_menubar::*; use draw_statusbar::*; +use draw_tabbar::*; use edit::arena::{self, Arena, ArenaString, scratch_arena}; use edit::framebuffer::{self, IndexedColor}; use edit::helpers::{CoordType, KIBI, MEBI, MetricFormatter, Rect, Size}; @@ -290,8 +294,10 @@ fn print_version() { fn draw(ctx: &mut Context, state: &mut State) { draw_menubar(ctx, state); + draw_tabbar(ctx, state); draw_editor(ctx, state); draw_statusbar(ctx, state); + draw_ai_dock(ctx, state); // Draw AI dock above status bar if state.wants_close { draw_handle_wants_close(ctx, state); @@ -337,6 +343,18 @@ fn draw(ctx: &mut Context, state: &mut State) { state.wants_file_picker = StateFilePicker::SaveAs; } else if key == kbmod::CTRL | vk::W { state.wants_close = true; + } else if key == kbmod::ALT | vk::W && state.documents.len() > 1 { + // Close current tab with Alt+W (only if multiple tabs open) + let active_index = state.documents.active_index(); + if let Some(doc) = state.documents.active() { + if doc.buffer.borrow().is_dirty() { + // If dirty, show close confirmation + state.wants_close = true; + } else { + // Close without confirmation + state.documents.remove_at_index(active_index); + } + } } else if key == kbmod::CTRL | vk::P { state.wants_go_to_file = true; } else if key == kbmod::CTRL | vk::Q { @@ -353,6 +371,46 @@ fn draw(ctx: &mut Context, state: &mut State) { state.wants_search.focus = true; } else if key == vk::F3 { search_execute(ctx, state, SearchAction::Search); + } else if key == kbmod::CTRL | vk::TAB && state.documents.len() > 1 { + // Switch to next tab + let active_index = state.documents.active_index(); + let next_index = (active_index + 1) % state.documents.len(); + state.documents.set_active_index(next_index); + } else if key == kbmod::CTRL_SHIFT | vk::TAB && state.documents.len() > 1 { + // Switch to previous tab + let active_index = state.documents.active_index(); + let prev_index = if active_index == 0 { + state.documents.len() - 1 + } else { + active_index - 1 + }; + state.documents.set_active_index(prev_index); + } else if key == kbmod::CTRL | vk::N1 && state.documents.len() > 0 { + state.documents.set_active_index(0); + } else if key == kbmod::CTRL | vk::N2 && state.documents.len() > 1 { + state.documents.set_active_index(1); + } else if key == kbmod::CTRL | vk::N3 && state.documents.len() > 2 { + state.documents.set_active_index(2); + } else if key == kbmod::CTRL | vk::N4 && state.documents.len() > 3 { + state.documents.set_active_index(3); + } else if key == kbmod::CTRL | vk::N5 && state.documents.len() > 4 { + state.documents.set_active_index(4); + } else if key == kbmod::CTRL | vk::N6 && state.documents.len() > 5 { + state.documents.set_active_index(5); + } else if key == kbmod::CTRL | vk::N7 && state.documents.len() > 6 { + state.documents.set_active_index(6); + } else if key == kbmod::CTRL | vk::N8 && state.documents.len() > 7 { + state.documents.set_active_index(7); + } else if key == kbmod::CTRL | vk::N9 && state.documents.len() > 8 { + state.documents.set_active_index(8); + } else if key == kbmod::CTRL_ALT | vk::B { + // Toggle AI dock + state.ai_dock_visible = !state.ai_dock_visible; + if state.ai_dock_visible { + state.ai_dock_focused = true; + } else { + state.ai_dock_focused = false; + } } else { return; } diff --git a/src/bin/edit/state.rs b/src/bin/edit/state.rs index c5229ed6059e..ead4e3aa7830 100644 --- a/src/bin/edit/state.rs +++ b/src/bin/edit/state.rs @@ -120,6 +120,13 @@ pub enum StateEncodingChange { Reopen, } +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AiDockSize { + Minimized, // Single line with title and up arrow + Default, // Normal size (8 lines) + Expanded, // 50% of screen height +} + pub struct State { pub menubar_color_bg: u32, pub menubar_color_fg: u32, @@ -161,6 +168,13 @@ pub struct State { pub goto_target: String, pub goto_invalid: bool, + // AI Dock + pub ai_dock_visible: bool, + pub ai_dock_focused: bool, + pub ai_dock_size: AiDockSize, + pub ai_prompt: String, + pub ai_output: String, + pub osc_title_filename: String, pub osc_clipboard_sync: bool, pub osc_clipboard_always_send: bool, @@ -209,6 +223,13 @@ impl State { goto_target: Default::default(), goto_invalid: false, + // AI Dock initialization + ai_dock_visible: true, // Make visible by default for testing + ai_dock_focused: false, + ai_dock_size: AiDockSize::Default, + ai_prompt: Default::default(), + ai_output: Default::default(), + osc_title_filename: Default::default(), osc_clipboard_sync: false, osc_clipboard_always_send: false, diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index f0276b913053..8ac2ccfec83a 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -45,7 +45,7 @@ use crate::helpers::*; use crate::oklab::oklab_blend; use crate::simd::memchr2; use crate::unicode::{self, Cursor, MeasurementConfig, Utf8Chars}; -use crate::{apperr, icu, simd}; +use crate::{apperr, icu, simd, syntax}; /// The margin template is used for line numbers. /// The max. line number we should ever expect is probably 64-bit, @@ -244,6 +244,7 @@ pub struct TextBuffer { insert_final_newline: bool, overtype: bool, + syntax_highlighter: syntax::SyntaxHighlighter, wants_cursor_visibility: bool, } @@ -292,6 +293,7 @@ impl TextBuffer { insert_final_newline: false, overtype: false, + syntax_highlighter: syntax::SyntaxHighlighter::default(), wants_cursor_visibility: false, }) } @@ -347,6 +349,12 @@ impl TextBuffer { } } + /// Set the syntax highlighter based on a file extension + pub fn set_syntax_from_extension(&mut self, extension: &str) { + let language = syntax::Language::from_extension(extension); + self.syntax_highlighter = syntax::SyntaxHighlighter::new(language); + } + /// The newline type used in the document. LF or CRLF. pub fn is_crlf(&self) -> bool { self.newlines_are_crlf @@ -1951,6 +1959,28 @@ impl TextBuffer { fb.blend_bg(visualizer_rect, bg); fb.blend_fg(visualizer_rect, fg); } else { + // Apply syntax highlighting + let char_offset = global_off; + let line_text = String::from_utf8_lossy(chunk); + let syntax_element = self.syntax_highlighter.get_syntax_element(&line_text, char_offset - (global_off - chunk_off)); + + if syntax_element != syntax::SyntaxElement::None { + // Get character position for color highlighting + cursor_line = self.cursor_move_to_offset_internal(cursor_line, global_off); + let highlight_rect = { + let left = destination.left + self.margin_width + cursor_line.visual_pos.x - origin.x; + let top = destination.top + cursor_line.visual_pos.y - origin.y; + Rect { left, top, right: left + 1, bottom: top + 1 } + }; + + // Handle both IndexedColor and RGB colors + let color = match syntax_element.color() { + syntax::SyntaxColor::Indexed(indexed_color) => fb.indexed(indexed_color), + syntax::SyntaxColor::Rgb(rgb_color) => rgb_color, + }; + fb.blend_fg(highlight_rect, color); + } + line.push(ch); } } diff --git a/src/framebuffer.rs b/src/framebuffer.rs index b86d4808148d..a0700d20c540 100644 --- a/src/framebuffer.rs +++ b/src/framebuffer.rs @@ -30,7 +30,7 @@ const CACHE_TABLE_SIZE: usize = 1 << CACHE_TABLE_LOG2_SIZE; const CACHE_TABLE_SHIFT: usize = usize::BITS as usize - CACHE_TABLE_LOG2_SIZE; /// Standard 16 VT & default foreground/background colors. -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IndexedColor { Black, Red, diff --git a/src/lib.rs b/src/lib.rs index d6e64d5e73fe..4af955648882 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod input; pub mod oklab; pub mod path; pub mod simd; +pub mod syntax; pub mod sys; pub mod tui; pub mod unicode; diff --git a/src/syntax.rs b/src/syntax.rs new file mode 100644 index 000000000000..588d4cd28a48 --- /dev/null +++ b/src/syntax.rs @@ -0,0 +1,276 @@ +// Copyright (c) Pavel Sich. +// Licensed under the MIT License. + +//! Syntax highlighting for various programming languages. + +use regex::Regex; +use crate::framebuffer::IndexedColor; + +/// Color type that can be either indexed or RGB +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxColor { + Indexed(IndexedColor), + Rgb(u32), +} + +/// Represents different types of syntax elements +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxElement { + Keyword, + Type, + String, + Comment, + Number, + Operator, + Function, + Variable, + None, +} + +impl SyntaxElement { + /// Returns the color to use for this syntax element + /// Uses custom RGB colors for strings and functions, IndexedColor for others + pub fn color(self) -> SyntaxColor { + match self { + SyntaxElement::Keyword => SyntaxColor::Indexed(IndexedColor::BrightMagenta), // Purple for keywords + SyntaxElement::Type => SyntaxColor::Indexed(IndexedColor::BrightCyan), // Bright cyan for types + SyntaxElement::String => SyntaxColor::Rgb(0xfffd8273), // Custom coral/salmon color + SyntaxElement::Comment => SyntaxColor::Indexed(IndexedColor::BrightBlack), // Gray for comments + SyntaxElement::Number => SyntaxColor::Indexed(IndexedColor::BrightYellow), // Yellow for numbers + SyntaxElement::Operator => SyntaxColor::Indexed(IndexedColor::White), // White for operators + SyntaxElement::Function => SyntaxColor::Rgb(0xff75c2b3), // Custom teal green color + SyntaxElement::Variable => SyntaxColor::Indexed(IndexedColor::Foreground), // Default foreground + SyntaxElement::None => SyntaxColor::Indexed(IndexedColor::Foreground), + } + } + + /// Legacy method that returns IndexedColor for backward compatibility + pub fn indexed_color(self) -> IndexedColor { + match self.color() { + SyntaxColor::Indexed(color) => color, + SyntaxColor::Rgb(_) => match self { + SyntaxElement::String => IndexedColor::BrightRed, // Fallback for strings + SyntaxElement::Function => IndexedColor::BrightGreen, // Fallback for functions + _ => IndexedColor::Foreground, + }, + } + } +} + +/// A syntax highlighter for a specific programming language +pub struct SyntaxHighlighter { + language: Language, + keyword_regex: Regex, + type_regex: Regex, + string_regex: Regex, + comment_regex: Regex, + number_regex: Regex, + function_regex: Regex, +} + +/// Supported programming languages +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Language { + Rust, + Go, + C, + Cpp, + CSharp, + Unknown, +} + +impl Language { + /// Detect language from file extension + pub fn from_extension(ext: &str) -> Self { + match ext.to_lowercase().as_str() { + "rs" => Language::Rust, + "go" => Language::Go, + "c" | "h" => Language::C, + "cpp" | "cc" | "cxx" | "hpp" | "hxx" => Language::Cpp, + "cs" => Language::CSharp, + _ => Language::Unknown, + } + } +} + +impl SyntaxHighlighter { + /// Create a new syntax highlighter for the given language + pub fn new(language: Language) -> Self { + let (keywords, types, comment_pattern, function_pattern) = match language { + Language::Rust => ( + r"\b(?:fn|let|mut|struct|enum|trait|impl|for|if|else|while|loop|match|return|use|mod|pub|crate|self|super|const|static|async|await|move|unsafe|extern|dyn|where|type|as|in|ref|break|continue)\b", + r"\b(?:u8|u16|u32|u64|u128|i8|i16|i32|i64|i128|f32|f64|usize|isize|bool|char|String|str|Vec|Option|Result|Box|Rc|Arc|RefCell|Cell)\b", + r"//.*|/\*[\s\S]*?\*/", + r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", + ), + Language::Go => ( + r"\b(?:func|var|const|type|struct|interface|package|import|for|if|else|switch|case|default|return|break|continue|go|defer|select|chan|map|range|fallthrough)\b", + r"\b(?:int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|float32|float64|bool|string|byte|rune|error|interface\{\})\b", + r"//.*|/\*[\s\S]*?\*/", + r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", + ), + Language::C => ( + r"\b(?:auto|break|case|char|const|continue|default|do|double|else|enum|extern|float|for|goto|if|inline|int|long|register|restrict|return|short|signed|sizeof|static|struct|switch|typedef|union|unsigned|void|volatile|while|_Bool|_Complex|_Imaginary)\b", + r"\b(?:char|short|int|long|float|double|void|signed|unsigned|size_t|ptrdiff_t|FILE|NULL)\b", + r"//.*|/\*[\s\S]*?\*/", + r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", + ), + Language::Cpp => ( + r"\b(?:alignas|alignof|and|and_eq|asm|auto|bitand|bitor|bool|break|case|catch|char|char16_t|char32_t|class|compl|const|constexpr|const_cast|continue|decltype|default|delete|do|double|dynamic_cast|else|enum|explicit|export|extern|false|float|for|friend|goto|if|inline|int|long|mutable|namespace|new|noexcept|not|not_eq|nullptr|operator|or|or_eq|private|protected|public|register|reinterpret_cast|return|short|signed|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|true|try|typedef|typeid|typename|union|unsigned|using|virtual|void|volatile|wchar_t|while|xor|xor_eq)\b", + r"\b(?:std::string|std::vector|std::map|std::set|std::pair|std::shared_ptr|std::unique_ptr|std::weak_ptr|bool|char|short|int|long|float|double|void|size_t|ptrdiff_t)\b", + r"//.*|/\*[\s\S]*?\*/", + r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", + ), + Language::CSharp => ( + r"\b(?:abstract|as|base|bool|break|byte|case|catch|char|checked|class|const|continue|decimal|default|delegate|do|double|else|enum|event|explicit|extern|false|finally|fixed|float|for|foreach|goto|if|implicit|in|int|interface|internal|is|lock|long|namespace|new|null|object|operator|out|override|params|private|protected|public|readonly|ref|return|sbyte|sealed|short|sizeof|stackalloc|static|string|struct|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unsafe|ushort|using|virtual|void|volatile|while)\b", + r"\b(?:bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort|object|string|var|dynamic|List|Dictionary|IEnumerable|ICollection|Array)\b", + r"//.*|/\*[\s\S]*?\*/", + r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", + ), + Language::Unknown => return Self::default(), + }; + + Self { + language, + keyword_regex: Regex::new(keywords).unwrap(), + type_regex: Regex::new(types).unwrap(), + string_regex: Regex::new(r#""([^"\\]|\\.)*"|'([^'\\]|\\.)*'"#).unwrap(), + comment_regex: Regex::new(comment_pattern).unwrap(), + number_regex: Regex::new(r"\b\d+\.?\d*([eE][+-]?\d+)?[fFdDlL]?\b|\b0[xX][0-9a-fA-F]+[lL]?\b|\b0[bB][01]+[lL]?\b").unwrap(), + function_regex: Regex::new(function_pattern).unwrap(), + } + } + + /// Get the syntax element type for text at the given position + pub fn get_syntax_element(&self, text: &str, position: usize) -> SyntaxElement { + if self.language == Language::Unknown { + return SyntaxElement::None; + } + + // Check if position is within a comment + for mat in self.comment_regex.find_iter(text) { + if position >= mat.start() && position < mat.end() { + return SyntaxElement::Comment; + } + } + + // Check if position is within a string + for mat in self.string_regex.find_iter(text) { + if position >= mat.start() && position < mat.end() { + return SyntaxElement::String; + } + } + + // Check for keywords + for mat in self.keyword_regex.find_iter(text) { + if position >= mat.start() && position < mat.end() { + return SyntaxElement::Keyword; + } + } + + // Check for types + for mat in self.type_regex.find_iter(text) { + if position >= mat.start() && position < mat.end() { + return SyntaxElement::Type; + } + } + + // Check for numbers + for mat in self.number_regex.find_iter(text) { + if position >= mat.start() && position < mat.end() { + return SyntaxElement::Number; + } + } + + // Check for functions + for mat in self.function_regex.find_iter(text) { + if position >= mat.start() && position < mat.end() { + return SyntaxElement::Function; + } + } + + SyntaxElement::None + } + + /// Highlight a line of text and return syntax elements for each character position + pub fn highlight_line(&self, line: &str) -> Vec { + let mut result = vec![SyntaxElement::None; line.len()]; + + if self.language == Language::Unknown { + return result; + } + + // Apply highlighting in order of precedence (comments and strings first) + + // Comments (highest precedence) + for mat in self.comment_regex.find_iter(line) { + for i in mat.start()..mat.end().min(line.len()) { + result[i] = SyntaxElement::Comment; + } + } + + // Strings (second highest precedence) + for mat in self.string_regex.find_iter(line) { + for i in mat.start()..mat.end().min(line.len()) { + if result[i] == SyntaxElement::None { + result[i] = SyntaxElement::String; + } + } + } + + // Keywords + for mat in self.keyword_regex.find_iter(line) { + for i in mat.start()..mat.end().min(line.len()) { + if result[i] == SyntaxElement::None { + result[i] = SyntaxElement::Keyword; + } + } + } + + // Types + for mat in self.type_regex.find_iter(line) { + for i in mat.start()..mat.end().min(line.len()) { + if result[i] == SyntaxElement::None { + result[i] = SyntaxElement::Type; + } + } + } + + // Numbers + for mat in self.number_regex.find_iter(line) { + for i in mat.start()..mat.end().min(line.len()) { + if result[i] == SyntaxElement::None { + result[i] = SyntaxElement::Number; + } + } + } + + // Functions (captured groups only) + for mat in self.function_regex.find_iter(line) { + if let Some(func_match) = mat.as_str().find('(') { + let func_end = mat.start() + func_match; + for i in mat.start()..func_end.min(line.len()) { + if result[i] == SyntaxElement::None && line.chars().nth(i).map_or(false, |c| c.is_alphabetic() || c == '_') { + result[i] = SyntaxElement::Function; + } + } + } + } + + result + } +} + +impl Default for SyntaxHighlighter { + fn default() -> Self { + Self { + language: Language::Unknown, + keyword_regex: Regex::new(r"$^").unwrap(), // Never matches + type_regex: Regex::new(r"$^").unwrap(), + string_regex: Regex::new(r"$^").unwrap(), + comment_regex: Regex::new(r"$^").unwrap(), + number_regex: Regex::new(r"$^").unwrap(), + function_regex: Regex::new(r"$^").unwrap(), + } + } +} diff --git a/test.rs b/test.rs new file mode 100644 index 000000000000..daf341316b53 --- /dev/null +++ b/test.rs @@ -0,0 +1,26 @@ +// This is a test Rust file for syntax highlighting +use std::collections::HashMap; + +fn main() { + let mut map: HashMap = HashMap::new(); + map.insert("hello".to_string(), 42); + + if let Some(value) = map.get("hello") { + println!("Value: {}", value); + } + + for i in 0..10 { + println!("Number: {}", i); + } +} + +struct Person { + name: String, + age: u32, +} + +impl Person { + fn new(name: String, age: u32) -> Self { + Person { name, age } + } +} diff --git a/test2.go b/test2.go new file mode 100644 index 000000000000..8e4cfb9031f0 --- /dev/null +++ b/test2.go @@ -0,0 +1,25 @@ +// Test Go file for syntax highlighting and tabs +package main + +import ( + "fmt" + "strings" +) + +func main() { + message := "Hello, World!" + upperMessage := strings.ToUpper(message) + + for i := 0; i < 5; i++ { + fmt.Printf("Iteration %d: %s\n", i+1, upperMessage) + } +} + +type Person struct { + Name string + Age int +} + +func (p Person) Greet() { + fmt.Printf("Hello, my name is %s and I'm %d years old\n", p.Name, p.Age) +}