├── assets ├── icon.png ├── .DS_Store ├── icon.icns └── zing_text.png ├── .gitignore ├── .github └── workflows │ ├── build-rust.yml │ └── release-package.yml ├── LICENSE ├── ICON_SETUP.md ├── Cargo.toml ├── src ├── ui │ ├── statusbar.rs │ ├── mod.rs │ ├── tabs.rs │ ├── editor.rs │ └── toolbar.rs ├── file_io │ └── mod.rs ├── config │ └── mod.rs ├── main.rs └── buffer │ └── mod.rs ├── README.md └── webpage ├── privacy.html └── index.html /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sukeesh/zing/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sukeesh/zing/HEAD/assets/.DS_Store -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sukeesh/zing/HEAD/assets/icon.icns -------------------------------------------------------------------------------- /assets/zing_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sukeesh/zing/HEAD/assets/zing_text.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | 4 | # Rust build directory 5 | /target 6 | 7 | # Rust backup files 8 | **/*.rs.bk 9 | 10 | # Build artifacts 11 | debug/ 12 | release/ 13 | 14 | # Common binary artifacts 15 | *.exe 16 | *.dll 17 | *.so 18 | *.dylib 19 | 20 | .DS_Store -------------------------------------------------------------------------------- /.github/workflows/build-rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install dependencies 20 | run: | 21 | sudo apt-get update 22 | sudo apt-get install -y libgtk-3-dev libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev libgdk-pixbuf2.0-dev 23 | 24 | - name: Build 25 | run: cargo build --verbose 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sukeesh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /ICON_SETUP.md: -------------------------------------------------------------------------------- 1 | # Application Icon Setup for Zing 2 | 3 | This document explains how to set up and build Zing with a custom application icon. 4 | 5 | ## Creating Icon Files 6 | 7 | 1. Create or download a PNG image for your icon (recommended size: 256x256 pixels) 8 | 2. Save it as `assets/icon.png` in the project directory 9 | 10 | ### Platform-Specific Icons 11 | 12 | #### macOS 13 | 14 | For macOS, you'll need an `.icns` file: 15 | 16 | ```bash 17 | # Install the required tool (on macOS) 18 | brew install makeicns 19 | 20 | # Convert your PNG to ICNS 21 | makeicns -in assets/icon.png -out assets/icon.icns 22 | ``` 23 | 24 | #### Windows 25 | 26 | For Windows, you'll need an `.ico` file: 27 | 28 | ```bash 29 | # You can use online converters or tools like ImageMagick 30 | convert assets/icon.png -define icon:auto-resize=256,128,64,48,32,16 assets/icon.ico 31 | ``` 32 | 33 | ## Building with Icon 34 | 35 | ### macOS 36 | 37 | To create a macOS application bundle with the icon: 38 | 39 | ```bash 40 | # Install cargo-bundle 41 | cargo install cargo-bundle 42 | 43 | # Create the bundle 44 | cargo bundle --release 45 | 46 | # The bundled app will be in target/release/bundle/osx/ 47 | ``` 48 | 49 | ### Windows and Linux 50 | 51 | The icon will be automatically used when running the application normally: 52 | 53 | ```bash 54 | cargo run --release 55 | ``` 56 | 57 | ## Troubleshooting 58 | 59 | If the icon doesn't appear: 60 | 61 | 1. Make sure the icon file paths in `Cargo.toml` are correct 62 | 2. For macOS, ensure the `.icns` file is properly formatted 63 | 3. For Windows, ensure the `.ico` file contains multiple resolutions 64 | 4. Rebuild the application completely after adding the icon files -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zing" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Zing Contributors"] 6 | description = "A fast, beautiful, cross-platform text editor written in Rust" 7 | readme = "README.md" 8 | license = "MIT" 9 | 10 | # macOS specific configuration 11 | [package.metadata.bundle] 12 | name = "Zing" 13 | identifier = "com.zing.editor" 14 | icon = ["assets/icon.icns"] 15 | version = "0.1.0" 16 | copyright = "© 2025 Sukeesh" 17 | category = "public.app-category.developer-tools" 18 | short_description = "A fast, beautiful text editor built on Rust" 19 | 20 | [dependencies] 21 | # UI Framework 22 | eframe = "0.24.1" # egui framework with winit and web support 23 | egui = "0.24.1" # Immediate mode GUI library 24 | 25 | # File handling 26 | rfd = "0.12.1" # Rust file dialogs (cross-platform) 27 | tempfile = "3.8.1" # Temporary files for printing and other operations 28 | 29 | # Text buffer and editing 30 | ropey = "1.6.1" # Fast rope data structure for text editing 31 | syntect = "5.1.0" # Syntax highlighting (for future extension) 32 | 33 | # Async utilities 34 | tokio = { version = "1.34.0", features = ["rt", "fs", "io-util", "macros"] } 35 | 36 | # Logging and error handling 37 | log = "0.4.20" 38 | env_logger = "0.10.1" 39 | anyhow = "1.0.75" 40 | 41 | # Image handling for app icon 42 | image = "0.24.7" # Image loading and manipulation 43 | 44 | # For macOS bundling 45 | cargo-bundle = "0.6.0" 46 | 47 | # For windows bundling 48 | [target.'cfg(windows)'.dependencies] 49 | winapi = { version = "0.3.5", features = ["winuser"] } 50 | 51 | [dev-dependencies] 52 | 53 | [profile.release] 54 | opt-level = 3 # Maximum optimization 55 | lto = true # Link-time optimization 56 | codegen-units = 1 # Maximize performance 57 | panic = "abort" # Abort on panic in release mode 58 | strip = true # Strip symbols from binary 59 | -------------------------------------------------------------------------------- /src/ui/statusbar.rs: -------------------------------------------------------------------------------- 1 | //! Status bar component for Zing text editor. 2 | 3 | use egui::{Color32, Ui, Stroke, Rect, Pos2, FontId, Rounding, Vec2, Sense}; 4 | 5 | use crate::ui::ZingApp; 6 | use crate::config::Theme; 7 | 8 | /// Status bar component. 9 | #[derive(Debug)] 10 | pub struct StatusBar; 11 | 12 | /// Renders the status bar UI. 13 | pub fn ui(app: &mut ZingApp, ui: &mut Ui) { 14 | let is_dark = matches!(app.config.theme, Theme::Dark); 15 | 16 | // Get the status bar height - extremely compact 17 | let height = 10.0; 18 | 19 | // Calculate the status bar rect 20 | let rect = ui.available_rect_before_wrap(); 21 | let status_rect = Rect::from_min_size( 22 | rect.min, 23 | Vec2::new(rect.width(), height), 24 | ); 25 | 26 | // Draw the status bar background 27 | let bg_color = if is_dark { 28 | Color32::from_rgb(30, 30, 40) 29 | } else { 30 | Color32::from_rgb(230, 230, 235) 31 | }; 32 | 33 | ui.painter().rect_filled( 34 | status_rect, 35 | 0.0, 36 | bg_color, 37 | ); 38 | 39 | // Draw the status message if there is one 40 | if let Some((message, _)) = &app.status_message { 41 | let text_color = if is_dark { 42 | Color32::from_rgb(200, 200, 200) 43 | } else { 44 | Color32::from_rgb(60, 60, 60) 45 | }; 46 | 47 | ui.painter().text( 48 | status_rect.min + Vec2::new(4.0, height / 2.0), 49 | egui::Align2::LEFT_CENTER, 50 | message, 51 | FontId::proportional(10.0), // Smaller font 52 | text_color, 53 | ); 54 | } 55 | 56 | // Draw the cursor position on the right side 57 | let text_color = if is_dark { 58 | Color32::from_rgb(220, 220, 220) // Much brighter for dark mode 59 | } else { 60 | Color32::from_rgb(30, 30, 30) // Much darker for light mode 61 | }; 62 | 63 | let cursor_text = format!("Ln {}, Col {}", app.cursor_line + 1, app.cursor_column + 1); 64 | let font_id = FontId::proportional(11.0); // Slightly larger font 65 | let galley = ui.painter().layout_no_wrap( 66 | cursor_text.clone(), 67 | font_id.clone(), 68 | text_color, 69 | ); 70 | 71 | // Add a subtle background behind the line/column text for better visibility 72 | let text_rect = Rect::from_min_size( 73 | status_rect.right_center() - Vec2::new(8.0 + galley.size().x, galley.size().y / 2.0), 74 | Vec2::new(galley.size().x + 8.0, galley.size().y) 75 | ); 76 | 77 | let bg_highlight = if is_dark { 78 | Color32::from_rgb(40, 40, 55) // Slightly lighter than background for dark mode 79 | } else { 80 | Color32::from_rgb(210, 210, 220) // Slightly darker than background for light mode 81 | }; 82 | 83 | ui.painter().rect_filled( 84 | text_rect, 85 | 3.0, // Rounded corners 86 | bg_highlight, 87 | ); 88 | 89 | ui.painter().text( 90 | status_rect.right_center() - Vec2::new(4.0 + galley.size().x, 0.0), 91 | egui::Align2::RIGHT_CENTER, 92 | cursor_text, 93 | font_id, 94 | text_color, 95 | ); 96 | 97 | // Allocate the space for the status bar 98 | ui.allocate_rect(status_rect, Sense::hover()); 99 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Zing Logo 3 | 4 | # Zing Text Editor 5 | 6 | A beautiful, cross-platform text editor written in Rust 7 | 8 | [![Open Source](https://img.shields.io/badge/Open%20Source-Free%20Forever-blue?style=for-the-badge&logo=github)](https://github.com/sukeesh/zing) 9 | [![Made with Rust](https://img.shields.io/badge/Made%20with-Rust-orange?style=for-the-badge&logo=rust)](https://www.rust-lang.org/) 10 | [![Website](https://img.shields.io/badge/Website-Visit%20Zing-purple?style=for-the-badge&logo=safari)](http://sukeesh.in/zing/) 11 | 12 | [Features](#-features) • [Building from Source](#-building-from-source) • [Usage](#-usage) • [Development](#️-development) 13 | 14 | Zing Editor Screenshot 15 |
16 | 17 | ## ✨ Features 18 | 19 | - 🎨 **Beautiful UI**: Clean, minimal design with light and dark themes 20 | - 💻 **Cross-Platform**: Runs on Windows, macOS, and Linux 21 | - 📄 **Core Functionality**: Open, Save, and Print text files 22 | - 🔌 **Extensible**: Designed with future extensions in mind 23 | 24 | ## 🚀 Building from Source 25 | 26 | ### Prerequisites 27 | 28 | - Rust (latest stable version) and Cargo 29 | - A C/C++ compiler (for some dependencies) 30 | 31 | ### Build Instructions 32 | 33 |
34 | Windows 35 | 36 | ```bash 37 | # Clone the repository 38 | git clone https://github.com/sukeesh/zing.git 39 | cd zing 40 | 41 | # Build in release mode 42 | cargo build --release 43 | 44 | # Run the application 45 | cargo run --release 46 | ``` 47 |
48 | 49 |
50 | macOS 51 | 52 | ```bash 53 | # Clone the repository 54 | git clone https://github.com/sukeesh/zing.git 55 | cd zing 56 | 57 | # Build in release mode 58 | cargo build --release 59 | 60 | # Run the application 61 | cargo run --release 62 | ``` 63 |
64 | 65 |
66 | Linux 67 | 68 | ```bash 69 | # Install required dependencies (Ubuntu/Debian example) 70 | sudo apt-get update 71 | sudo apt-get install -y libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev 72 | 73 | # Clone the repository 74 | git clone https://github.com/sukeesh/zing.git 75 | cd zing 76 | 77 | # Build in release mode 78 | cargo build --release 79 | 80 | # Run the application 81 | cargo run --release 82 | ``` 83 |
84 | 85 | ## 🤝 Contributing 86 | 87 | Contributions to Zing are warmly welcomed! Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your help is appreciated. 88 | 89 | ### How to Contribute 90 | 91 | 1. **Fork the repository** and create your branch from `main` 92 | 2. **Make your changes** and ensure they follow the project's coding style 93 | 3. **Test your changes** thoroughly 94 | 4. **Submit a pull request** with a clear description of your improvements 95 | 96 | ### Areas for Contribution 97 | 98 | - Bug fixes and performance improvements 99 | - New features and enhancements 100 | - Documentation and examples 101 | - UI/UX improvements 102 | - Cross-platform compatibility 103 | 104 | No contribution is too small! Feel free to open issues for discussions or suggestions before implementing changes. 105 | 106 | ## 📝 Usage 107 | 108 | ### Opening a File 109 | 110 | Click on the "Open" button in the toolbar or use the keyboard shortcut `Ctrl+O` (Windows/Linux) or `Cmd+O` (macOS). 111 | 112 | ### Saving a File 113 | 114 | - **Save**: Click the "Save" button or use `Ctrl+S`/`Cmd+S` to save changes to the current file. 115 | - **Save As**: Use `Ctrl+Shift+S`/`Cmd+Shift+S` to save the current file with a new name or location. 116 | 117 | ### Printing 118 | 119 | Click on the "Print" button or use `Ctrl+P`/`Cmd+P` to print the current document. 120 | 121 | ## 🛠️ Development 122 | 123 | Zing is organized into several modules: 124 | 125 | - `buffer`: Text buffer implementation using the Ropey crate 126 | - `ui`: User interface components built with egui 127 | - `file_io`: File input/output operations 128 | - `config`: Configuration and theming 129 | 130 | ## 👨‍💻 About the Developer 131 | 132 | Zing is developed by [Sukeesh](https://github.com/sukeesh). Connect with me: 133 | - GitHub: [@sukeesh](https://github.com/sukeesh) 134 | - LinkedIn: [Sukeesh](https://www.linkedin.com/in/sukeesh/) 135 | - Repository: [github.com/sukeesh/zing](https://github.com/sukeesh/zing) 136 | - Website: [sukeesh.in/zing](http://sukeesh.in/zing/) 137 | 138 | ## 📜 License 139 | 140 | This project is licensed under the MIT License - see the LICENSE file for details. 141 | -------------------------------------------------------------------------------- /src/file_io/mod.rs: -------------------------------------------------------------------------------- 1 | //! File I/O module for Zing text editor. 2 | //! 3 | //! This module provides functionality for opening, saving, and printing files. 4 | 5 | use anyhow::{Context, Result}; 6 | use rfd::FileDialog; 7 | use std::path::{Path, PathBuf}; 8 | use tokio::fs; 9 | 10 | use crate::buffer::TextBuffer; 11 | 12 | /// Opens a file dialog for selecting a file to open. 13 | pub fn open_file_dialog() -> Option { 14 | FileDialog::new() 15 | .add_filter("Text Files", &["txt", "md", "rs", "toml", "json", "yaml", "yml"]) 16 | .add_filter("All Files", &["*"]) 17 | .set_title("Open File") 18 | .pick_file() 19 | } 20 | 21 | /// Opens a file dialog for saving a file. 22 | pub fn save_file_dialog() -> Option { 23 | FileDialog::new() 24 | .add_filter("Text Files", &["txt", "md", "rs", "toml", "json", "yaml", "yml"]) 25 | .add_filter("All Files", &["*"]) 26 | .set_title("Save File") 27 | .save_file() 28 | } 29 | 30 | /// Loads a file into a text buffer. 31 | pub async fn load_file>(path: P) -> Result { 32 | TextBuffer::from_file(path).await 33 | } 34 | 35 | /// Saves a text buffer to a file. 36 | pub async fn save_buffer_to_file(buffer: &mut TextBuffer, path: Option) -> Result<()> { 37 | match path { 38 | Some(path) => buffer.save_to(path).await, 39 | None => { 40 | if buffer.file_path.is_some() { 41 | buffer.save().await 42 | } else { 43 | Err(anyhow::anyhow!("No file path provided and buffer has no associated path")) 44 | } 45 | } 46 | } 47 | } 48 | 49 | /// Prints the content of a text buffer. 50 | /// 51 | /// This implementation saves the buffer to a temporary file and opens it with the system's 52 | /// default application, which will allow the user to print it. 53 | pub fn print_buffer(buffer: &TextBuffer) -> Result<()> { 54 | use std::fs::File; 55 | use std::io::Write; 56 | use std::process::Command; 57 | use tempfile::NamedTempFile; 58 | 59 | log::info!("Preparing buffer with {} characters for printing", buffer.len_chars()); 60 | 61 | // Create a temporary file 62 | let mut temp_file = NamedTempFile::new()?; 63 | let temp_path = temp_file.path().to_path_buf(); 64 | 65 | // Write the buffer content to the temporary file 66 | temp_file.write_all(buffer.content.to_string().as_bytes())?; 67 | 68 | // Flush to ensure all data is written 69 | temp_file.flush()?; 70 | 71 | // Open the file with the system's default application 72 | #[cfg(target_os = "macos")] 73 | { 74 | Command::new("open") 75 | .arg(&temp_path) 76 | .spawn() 77 | .context("Failed to open file for printing")?; 78 | } 79 | 80 | #[cfg(target_os = "windows")] 81 | { 82 | Command::new("cmd") 83 | .args(&["/C", "start", "", temp_path.to_str().unwrap()]) 84 | .spawn() 85 | .context("Failed to open file for printing")?; 86 | } 87 | 88 | #[cfg(target_os = "linux")] 89 | { 90 | Command::new("xdg-open") 91 | .arg(&temp_path) 92 | .spawn() 93 | .context("Failed to open file for printing")?; 94 | } 95 | 96 | log::info!("File opened with system default application for printing"); 97 | 98 | // Don't delete the temp file immediately, as it needs to be accessed by the application 99 | // The OS will clean it up when the application closes 100 | std::mem::forget(temp_file); 101 | 102 | Ok(()) 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | use super::*; 108 | use std::io::Write; 109 | use tempfile::NamedTempFile; 110 | 111 | #[tokio::test] 112 | async fn test_load_file() -> Result<()> { 113 | // Create a temporary file with some content 114 | let mut temp_file = NamedTempFile::new()?; 115 | let content = "Hello, world!\nThis is a test."; 116 | write!(temp_file, "{}", content)?; 117 | 118 | // Load the file into a buffer 119 | let buffer = load_file(temp_file.path()).await?; 120 | 121 | // Check that the buffer contains the expected content 122 | assert_eq!(buffer.content.to_string(), content); 123 | assert_eq!(buffer.file_path, Some(temp_file.path().to_path_buf())); 124 | assert!(!buffer.modified); 125 | 126 | Ok(()) 127 | } 128 | 129 | #[tokio::test] 130 | async fn test_save_buffer() -> Result<()> { 131 | // Create a buffer with some content 132 | let mut buffer = TextBuffer::from_str("Hello, world!"); 133 | 134 | // Create a temporary file to save to 135 | let temp_file = NamedTempFile::new()?; 136 | let path = temp_file.path().to_path_buf(); 137 | 138 | // Save the buffer to the file 139 | save_buffer_to_file(&mut buffer, Some(path.clone())).await?; 140 | 141 | // Check that the file contains the expected content 142 | let content = fs::read_to_string(&path).await?; 143 | assert_eq!(content, "Hello, world!"); 144 | 145 | // Check that the buffer's file path was updated 146 | assert_eq!(buffer.file_path, Some(path)); 147 | assert!(!buffer.modified); 148 | 149 | Ok(()) 150 | } 151 | } -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | //! Configuration module for Zing text editor. 2 | //! 3 | //! This module provides functionality for managing editor settings and themes. 4 | 5 | use egui::{Color32, Stroke, Style, Visuals}; 6 | use std::sync::Arc; 7 | 8 | /// Theme options for the editor. 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 10 | pub enum Theme { 11 | /// Light theme with dark text on light background 12 | Light, 13 | /// Dark theme with light text on dark background 14 | Dark, 15 | } 16 | 17 | impl Default for Theme { 18 | fn default() -> Self { 19 | Theme::Dark 20 | } 21 | } 22 | 23 | /// Editor configuration settings. 24 | #[derive(Debug, Clone)] 25 | pub struct EditorConfig { 26 | /// The current theme 27 | pub theme: Theme, 28 | /// Font size in points 29 | pub font_size: f32, 30 | /// Line spacing factor (1.0 = normal) 31 | pub line_spacing: f32, 32 | /// Whether to show line numbers 33 | pub show_line_numbers: bool, 34 | /// Whether to wrap text 35 | pub word_wrap: bool, 36 | /// Tab size in spaces 37 | pub tab_size: usize, 38 | /// Whether to use spaces for tabs 39 | pub use_spaces: bool, 40 | } 41 | 42 | impl Default for EditorConfig { 43 | fn default() -> Self { 44 | Self { 45 | theme: Theme::default(), 46 | font_size: 14.0, 47 | line_spacing: 1.2, 48 | show_line_numbers: true, 49 | word_wrap: true, 50 | tab_size: 4, 51 | use_spaces: true, 52 | } 53 | } 54 | } 55 | 56 | impl EditorConfig { 57 | /// Creates a new editor configuration with default settings. 58 | pub fn new() -> Self { 59 | Self::default() 60 | } 61 | 62 | /// Creates a new editor configuration with the specified theme. 63 | pub fn with_theme(theme: Theme) -> Self { 64 | Self { 65 | theme, 66 | ..Self::default() 67 | } 68 | } 69 | 70 | /// Applies the configuration to the egui context. 71 | pub fn apply_to_context(&self, ctx: &egui::Context) { 72 | let mut style = (*ctx.style()).clone(); 73 | 74 | match self.theme { 75 | Theme::Light => { 76 | style.visuals = Visuals::light(); 77 | style.visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(245, 245, 245); 78 | style.visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, Color32::from_rgb(20, 20, 20)); 79 | } 80 | Theme::Dark => { 81 | style.visuals = Visuals::dark(); 82 | style.visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(30, 30, 30); 83 | style.visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, Color32::from_rgb(220, 220, 220)); 84 | } 85 | } 86 | 87 | // Customize text styles 88 | let mut text_styles = style.text_styles.clone(); 89 | for (_text_style, font_id) in text_styles.iter_mut() { 90 | font_id.size = self.font_size; 91 | } 92 | style.text_styles = text_styles; 93 | 94 | ctx.set_style(style); 95 | } 96 | 97 | /// Toggles between light and dark themes. 98 | pub fn toggle_theme(&mut self) { 99 | self.theme = match self.theme { 100 | Theme::Light => Theme::Dark, 101 | Theme::Dark => Theme::Light, 102 | }; 103 | } 104 | 105 | /// Increases the font size. 106 | pub fn increase_font_size(&mut self) { 107 | self.font_size = (self.font_size + 1.0).min(32.0); 108 | } 109 | 110 | /// Decreases the font size. 111 | pub fn decrease_font_size(&mut self) { 112 | self.font_size = (self.font_size - 1.0).max(8.0); 113 | } 114 | 115 | /// Toggles line numbers. 116 | pub fn toggle_line_numbers(&mut self) { 117 | self.show_line_numbers = !self.show_line_numbers; 118 | } 119 | 120 | /// Toggles word wrap. 121 | pub fn toggle_word_wrap(&mut self) { 122 | self.word_wrap = !self.word_wrap; 123 | } 124 | } 125 | 126 | #[cfg(test)] 127 | mod tests { 128 | use super::*; 129 | 130 | #[test] 131 | fn test_default_config() { 132 | let config = EditorConfig::default(); 133 | assert_eq!(config.theme, Theme::Dark); 134 | assert_eq!(config.font_size, 14.0); 135 | assert_eq!(config.line_spacing, 1.2); 136 | assert!(config.show_line_numbers); 137 | assert!(config.word_wrap); 138 | assert_eq!(config.tab_size, 4); 139 | assert!(config.use_spaces); 140 | } 141 | 142 | #[test] 143 | fn test_with_theme() { 144 | let config = EditorConfig::with_theme(Theme::Light); 145 | assert_eq!(config.theme, Theme::Light); 146 | } 147 | 148 | #[test] 149 | fn test_toggle_theme() { 150 | let mut config = EditorConfig::with_theme(Theme::Light); 151 | config.toggle_theme(); 152 | assert_eq!(config.theme, Theme::Dark); 153 | config.toggle_theme(); 154 | assert_eq!(config.theme, Theme::Light); 155 | } 156 | 157 | #[test] 158 | fn test_font_size_adjustments() { 159 | let mut config = EditorConfig::default(); 160 | let original_size = config.font_size; 161 | 162 | config.increase_font_size(); 163 | assert_eq!(config.font_size, original_size + 1.0); 164 | 165 | config.decrease_font_size(); 166 | assert_eq!(config.font_size, original_size); 167 | 168 | // Test bounds 169 | for _ in 0..100 { 170 | config.increase_font_size(); 171 | } 172 | assert_eq!(config.font_size, 32.0); 173 | 174 | for _ in 0..100 { 175 | config.decrease_font_size(); 176 | } 177 | assert_eq!(config.font_size, 8.0); 178 | } 179 | } -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | //! UI module for Zing text editor. 2 | //! 3 | //! This module provides the user interface components for the editor. 4 | 5 | pub mod editor; 6 | pub mod statusbar; 7 | pub mod toolbar; 8 | pub mod tabs; 9 | 10 | use editor::EditorView; 11 | use toolbar::Toolbar; 12 | use statusbar::StatusBar; 13 | use tabs::TabsView; 14 | 15 | use egui::{Context, Ui, Vec2, Rounding, Color32, Stroke}; 16 | use std::sync::{Arc, Mutex}; 17 | use std::time::Instant; 18 | 19 | use crate::buffer::TextBuffer; 20 | use crate::config::{EditorConfig, Theme}; 21 | 22 | /// Main application state. 23 | #[derive(Debug)] 24 | pub struct ZingApp { 25 | /// Editor configuration 26 | pub config: EditorConfig, 27 | /// Cursor position (character index) 28 | pub cursor_pos: usize, 29 | /// Cursor line position (0-indexed) 30 | pub cursor_line: usize, 31 | /// Cursor column position (0-indexed) 32 | pub cursor_column: usize, 33 | /// Whether a file dialog is open 34 | pub file_dialog_open: bool, 35 | /// Status message to display 36 | pub status_message: Option<(String, Instant)>, 37 | /// Status message timeout in seconds 38 | status_timeout: f32, 39 | /// Tabs view 40 | pub tabs: TabsView, 41 | /// Flag to track if the user has been warned about closing the last tab 42 | pub last_tab_close_warning: bool, 43 | } 44 | 45 | impl ZingApp { 46 | /// Creates a new application instance. 47 | pub fn new(ctx: &Context) -> Self { 48 | let config = EditorConfig::default(); 49 | config.apply_to_context(ctx); 50 | 51 | Self { 52 | config, 53 | cursor_pos: 0, 54 | cursor_line: 0, 55 | cursor_column: 0, 56 | file_dialog_open: false, 57 | status_message: None, 58 | status_timeout: 5.0, 59 | tabs: TabsView::new(), 60 | last_tab_close_warning: false, 61 | } 62 | } 63 | 64 | /// Sets the current buffer. 65 | pub fn set_buffer(&mut self, buffer: TextBuffer) { 66 | let title = buffer.file_path.as_ref() 67 | .and_then(|p| p.file_name()) 68 | .and_then(|n| n.to_str()) 69 | .unwrap_or("Untitled") 70 | .to_string(); 71 | 72 | let tab = tabs::Tab::with_buffer(title, buffer.file_path.clone(), buffer); 73 | self.tabs.tabs.push(tab); 74 | self.tabs.active_tab = self.tabs.tabs.len() - 1; 75 | self.cursor_pos = 0; 76 | } 77 | 78 | /// Gets a reference to the current buffer. 79 | pub fn buffer(&self) -> Arc> { 80 | self.tabs.active_buffer() 81 | } 82 | 83 | /// Sets a status message to display. 84 | pub fn set_status(&mut self, message: String, duration: f32) { 85 | self.status_message = Some((message, Instant::now() + std::time::Duration::from_secs_f32(duration))); 86 | } 87 | 88 | /// Updates the status message timeout. 89 | pub fn update_status(&mut self, delta_time: f32) { 90 | if let Some((_, expiration)) = &self.status_message { 91 | if Instant::now() >= *expiration { 92 | self.status_message = None; 93 | } 94 | } 95 | } 96 | 97 | /// Toggles the theme. 98 | pub fn toggle_theme(&mut self, ctx: &Context) { 99 | self.config.toggle_theme(); 100 | self.config.apply_to_context(ctx); 101 | } 102 | } 103 | 104 | /// The main application UI. 105 | pub fn ui(app: &mut ZingApp, ctx: &Context) { 106 | // Set up the main panel with proper styling 107 | let is_dark = matches!(app.config.theme, Theme::Dark); 108 | let bg_color = if is_dark { 109 | Color32::from_rgb(18, 18, 24) 110 | } else { 111 | Color32::from_rgb(248, 248, 252) 112 | }; 113 | 114 | // Configure the central panel 115 | egui::CentralPanel::default() 116 | .frame(egui::Frame::default() 117 | .fill(bg_color) 118 | .inner_margin(0.0) 119 | .outer_margin(0.0)) 120 | .show(ctx, |ui| { 121 | // Get the total available size 122 | let total_size = ui.available_size(); 123 | 124 | // Define fixed heights for status bar and toolbar (if needed) 125 | let statusbar_height = 24.0; 126 | let toolbar_height = 24.0; 127 | let tabs_width = 140.0; 128 | 129 | // Determine if we need to show the toolbar (only on non-macOS platforms) 130 | #[cfg(not(target_os = "macos"))] 131 | let show_toolbar = true; 132 | 133 | #[cfg(target_os = "macos")] 134 | let show_toolbar = false; 135 | 136 | // Calculate the editor size 137 | let editor_width = total_size.x - tabs_width - 1.0; // 1px for separator 138 | let editor_height = if show_toolbar { 139 | total_size.y - toolbar_height - statusbar_height - 2.0 // 2px for separators 140 | } else { 141 | total_size.y - statusbar_height - 1.0 // 1px for separator 142 | }; 143 | 144 | // Create a horizontal layout for the entire UI 145 | ui.horizontal(|ui| { 146 | // Left side: Tabs panel 147 | ui.vertical(|ui| { 148 | ui.set_min_width(tabs_width); 149 | ui.set_max_width(tabs_width); 150 | ui.set_min_height(total_size.y); 151 | app.tabs.ui(ui, app.config.theme); 152 | }); 153 | 154 | // Vertical separator 155 | ui.add(egui::Separator::default().vertical().spacing(1.0)); 156 | 157 | // Right side: Main content 158 | ui.vertical(|ui| { 159 | // Top: Toolbar (only on non-macOS platforms) 160 | if show_toolbar { 161 | ui.allocate_ui_with_layout( 162 | Vec2::new(editor_width, toolbar_height), 163 | egui::Layout::left_to_right(egui::Align::Center), 164 | |ui| { toolbar::ui(app, ui); } 165 | ); 166 | 167 | // Horizontal separator 168 | ui.add(egui::Separator::default().horizontal().spacing(1.0)); 169 | } 170 | 171 | // Middle: Editor (takes most space) 172 | let editor_rect = ui.allocate_rect( 173 | egui::Rect::from_min_size( 174 | ui.cursor().min, 175 | Vec2::new(editor_width, editor_height) 176 | ), 177 | egui::Sense::hover() 178 | ); 179 | 180 | // Create a child UI for the editor with the allocated rectangle 181 | let mut child_ui = ui.child_ui(editor_rect.rect, egui::Layout::default()); 182 | editor::ui(app, &mut child_ui); 183 | 184 | // Horizontal separator 185 | ui.add(egui::Separator::default().horizontal().spacing(1.0)); 186 | 187 | // Bottom: Status bar 188 | ui.allocate_ui_with_layout( 189 | Vec2::new(editor_width, statusbar_height), 190 | egui::Layout::left_to_right(egui::Align::Center), 191 | |ui| { statusbar::ui(app, ui); } 192 | ); 193 | }); 194 | }); 195 | }); 196 | } -------------------------------------------------------------------------------- /webpage/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Privacy Policy - Zing Text Editor 7 | 8 | 9 | 10 | 171 | 172 | 173 |
174 |
175 | 181 |
182 |
183 | 184 |
185 |
186 |
187 |

Privacy Policy

188 |

Last updated: March 2024

189 |
190 | 191 |
192 |

Introduction

193 |

Welcome to Zing's Privacy Policy. This policy describes how we collect, use, and handle your information when you use our text editor application and website.

194 |
195 | 196 |
197 |

Information We Collect

198 |

We collect minimal information to provide and improve our service:

199 |
    200 |
  • Basic usage statistics (application launches, feature usage)
  • 201 |
  • Crash reports and error logs
  • 202 |
  • System information (OS version, device type)
  • 203 |
  • Payment information (processed securely through our payment provider)
  • 204 |
205 |

We do NOT collect or store:

206 |
    207 |
  • The content of your text files
  • 208 |
  • Your personal documents
  • 209 |
  • Keystroke data
  • 210 |
  • Browser history
  • 211 |
212 |
213 | 214 |
215 |

How We Use Your Information

216 |

The information we collect is used for:

217 |
    218 |
  • Improving the Zing text editor
  • 219 |
  • Fixing bugs and technical issues
  • 220 |
  • Processing your purchase
  • 221 |
  • Providing customer support
  • 222 |
  • Sending important updates about the application
  • 223 |
224 |
225 | 226 |
227 |

Data Storage and Security

228 |

We take the security of your information seriously:

229 |
    230 |
  • All data is encrypted in transit and at rest
  • 231 |
  • We use industry-standard security measures
  • 232 |
  • Access to data is strictly limited to authorized personnel
  • 233 |
  • Regular security audits are performed
  • 234 |
235 |
236 | 237 |
238 |

Your Rights

239 |

You have the right to:

240 |
    241 |
  • Access your personal information
  • 242 |
  • Request deletion of your data
  • 243 |
  • Opt-out of non-essential data collection
  • 244 |
  • Receive a copy of your data
  • 245 |
  • Lodge a complaint with relevant authorities
  • 246 |
247 |
248 | 249 |
250 |

Updates to This Policy

251 |

We may update this privacy policy from time to time. We will notify you of any changes by posting the new policy on this page and updating the "Last updated" date.

252 |
253 | 254 |
255 |

Third-Party Services

256 |

We use the following third-party services:

257 |
    258 |
  • Payment processing (for purchases)
  • 259 |
  • Error tracking (for crash reports)
  • 260 |
  • Analytics (for usage statistics)
  • 261 |
262 |

Each of these services has their own privacy policy and handling of data.

263 |
264 | 265 |
266 |

If you have any questions about this Privacy Policy, please contact us at:

267 |

privacy@zing-editor.com

268 |
269 |
270 |
271 | 272 | -------------------------------------------------------------------------------- /src/ui/tabs.rs: -------------------------------------------------------------------------------- 1 | use egui::{Color32, Rect, Response, Sense, Stroke, Ui, Vec2}; 2 | use std::path::PathBuf; 3 | use std::sync::{Arc, Mutex}; 4 | 5 | use crate::config::Theme; 6 | use crate::buffer::TextBuffer; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct Tab { 10 | pub title: String, 11 | pub file_path: Option, 12 | pub is_modified: bool, 13 | pub buffer: Arc>, 14 | } 15 | 16 | impl Tab { 17 | pub fn new(title: String, file_path: Option) -> Self { 18 | Self { 19 | title, 20 | file_path, 21 | is_modified: false, 22 | buffer: Arc::new(Mutex::new(TextBuffer::new())), 23 | } 24 | } 25 | 26 | pub fn with_buffer(title: String, file_path: Option, buffer: TextBuffer) -> Self { 27 | Self { 28 | title, 29 | file_path, 30 | is_modified: false, 31 | buffer: Arc::new(Mutex::new(buffer)), 32 | } 33 | } 34 | 35 | pub fn display_name(&self) -> String { 36 | if let Some(path) = &self.file_path { 37 | path.file_name() 38 | .and_then(|n| n.to_str()) 39 | .unwrap_or("Untitled") 40 | .to_string() 41 | } else { 42 | "Untitled".to_string() 43 | } 44 | } 45 | } 46 | 47 | #[derive(Debug, Clone)] 48 | pub struct TabsView { 49 | pub tabs: Vec, 50 | pub active_tab: usize, 51 | } 52 | 53 | impl TabsView { 54 | pub fn new() -> Self { 55 | let mut tabs = Vec::new(); 56 | tabs.push(Tab::new("Untitled".to_string(), None)); 57 | Self { 58 | tabs, 59 | active_tab: 0, 60 | } 61 | } 62 | 63 | pub fn active_buffer(&self) -> Arc> { 64 | self.tabs[self.active_tab].buffer.clone() 65 | } 66 | 67 | /// Creates a new tab and makes it active 68 | pub fn new_tab(&mut self) { 69 | // Create a new tab 70 | self.tabs.push(Tab::new("Untitled".to_string(), None)); 71 | self.active_tab = self.tabs.len() - 1; 72 | } 73 | 74 | /// Closes the current active tab 75 | /// Returns true if successful, false if it was the last tab 76 | pub fn close_tab(&mut self) -> bool { 77 | // Don't close the last tab 78 | if self.tabs.len() <= 1 { 79 | return false; 80 | } 81 | 82 | // Remove the active tab 83 | self.tabs.remove(self.active_tab); 84 | 85 | // Adjust the active tab index if needed 86 | if self.active_tab >= self.tabs.len() { 87 | self.active_tab = self.tabs.len() - 1; 88 | } 89 | 90 | true 91 | } 92 | 93 | pub fn ui(&mut self, ui: &mut Ui, theme: Theme) -> Response { 94 | let is_dark = matches!(theme, Theme::Dark); 95 | 96 | // Colors for the tabs 97 | let (bg_color, active_bg_color, hover_bg_color, text_color, active_text_color) = if is_dark { 98 | ( 99 | Color32::from_rgb(18, 18, 24), // Darker background 100 | Color32::from_rgb(35, 35, 48), // Slightly lighter for active tab 101 | Color32::from_rgb(28, 28, 38), // Hover state 102 | Color32::from_rgb(160, 160, 180), // Muted text for inactive tabs 103 | Color32::from_rgb(230, 230, 250), // Brighter text for active tab 104 | ) 105 | } else { 106 | ( 107 | Color32::from_rgb(235, 235, 240), // Light gray background 108 | Color32::from_rgb(250, 250, 255), // Almost white for active tab 109 | Color32::from_rgb(242, 242, 247), // Hover state 110 | Color32::from_rgb(120, 120, 130), // Muted text for inactive tabs 111 | Color32::from_rgb(40, 40, 60), // Dark text for active tab 112 | ) 113 | }; 114 | 115 | // Set up the tabs panel 116 | let panel_rect = Rect::from_min_size( 117 | ui.min_rect().min, 118 | Vec2::new(140.0, ui.available_height()), // Increased width from 100.0 to 140.0 119 | ); 120 | 121 | // Draw the tabs background 122 | ui.painter().rect_filled( 123 | panel_rect, 124 | 0.0, 125 | bg_color, 126 | ); 127 | 128 | let mut clicked_tab = None; 129 | let tab_height = 32.0; // Slightly shorter tabs 130 | let tab_padding = Vec2::new(8.0, 0.0); // Less horizontal padding 131 | 132 | // Separator color - very subtle 133 | let separator_color = if is_dark { 134 | Color32::from_rgba_premultiplied(255, 255, 255, 10) // Almost invisible white 135 | } else { 136 | Color32::from_rgba_premultiplied(0, 0, 0, 10) // Almost invisible black 137 | }; 138 | 139 | // Add top padding before the first tab 140 | let top_padding = 8.0; 141 | 142 | // Draw each tab 143 | for (index, tab) in self.tabs.iter().enumerate() { 144 | let is_active = index == self.active_tab; 145 | let tab_rect = Rect::from_min_size( 146 | panel_rect.min + Vec2::new(0.0, top_padding + index as f32 * tab_height), 147 | Vec2::new(panel_rect.width(), tab_height), 148 | ); 149 | 150 | // Handle interactions 151 | let response = ui.allocate_rect(tab_rect, Sense::click()); 152 | let is_hovered = response.hovered(); 153 | 154 | // Background color based on state 155 | let bg_color = if is_active { 156 | active_bg_color 157 | } else if is_hovered { 158 | hover_bg_color 159 | } else { 160 | bg_color 161 | }; 162 | 163 | // Draw tab background with subtle rounded corners on the right side 164 | if is_active || is_hovered { 165 | let rounding = egui::Rounding { 166 | ne: 4.0, 167 | se: 4.0, 168 | ..Default::default() 169 | }; 170 | 171 | ui.painter().rect_filled( 172 | tab_rect, 173 | rounding, 174 | bg_color, 175 | ); 176 | } else { 177 | ui.painter().rect_filled( 178 | tab_rect, 179 | 0.0, 180 | bg_color, 181 | ); 182 | } 183 | 184 | // Active tab indicator - make it more stylish 185 | if is_active { 186 | let indicator_color = if is_dark { 187 | Color32::from_rgb(86, 156, 255) // Brighter blue for dark mode 188 | } else { 189 | Color32::from_rgb(0, 120, 215) // Standard blue for light mode 190 | }; 191 | 192 | // Draw a thicker, rounded indicator 193 | ui.painter().rect_filled( 194 | Rect::from_min_size( 195 | tab_rect.min, 196 | Vec2::new(3.0, tab_height), 197 | ), 198 | egui::Rounding { 199 | ne: 2.0, 200 | se: 2.0, 201 | ..Default::default() 202 | }, 203 | indicator_color, 204 | ); 205 | 206 | // Draw a subtle highlight at the top of the active tab 207 | ui.painter().rect_filled( 208 | Rect::from_min_size( 209 | tab_rect.min, 210 | Vec2::new(panel_rect.width(), 1.0), 211 | ), 212 | 0.0, 213 | indicator_color.linear_multiply(0.7), 214 | ); 215 | } 216 | 217 | // Draw tab title with icon 218 | let text_color = if is_active { active_text_color } else { text_color }; 219 | let mut text = tab.display_name(); 220 | 221 | // Truncate long filenames to fit in the tab 222 | if text.len() > 10 { 223 | text = format!("{}...", &text[0..7]); 224 | } 225 | 226 | // Add file icon and modified indicator using simple text characters instead of emojis 227 | let icon = if tab.is_modified { 228 | "● " // Filled circle for modified 229 | } else { 230 | "○ " // Empty circle for unmodified 231 | }; 232 | 233 | let display_text = format!("{}{}", icon, text); 234 | 235 | ui.painter().text( 236 | tab_rect.min + tab_padding + Vec2::new(4.0, tab_height/2.0), 237 | egui::Align2::LEFT_CENTER, 238 | display_text, 239 | egui::FontId::proportional(12.0), // Slightly smaller font 240 | text_color, 241 | ); 242 | 243 | if response.clicked() { 244 | clicked_tab = Some(index); 245 | } 246 | 247 | // Draw separator line AFTER the tab content (not through it) 248 | // Only draw separator if not the last tab 249 | if index < self.tabs.len() - 1 { 250 | let separator_y = tab_rect.min.y + tab_height; 251 | ui.painter().line_segment( 252 | [ 253 | egui::pos2(tab_rect.min.x + 8.0, separator_y), 254 | egui::pos2(tab_rect.max.x - 8.0, separator_y) 255 | ], 256 | egui::Stroke::new(1.0, separator_color) 257 | ); 258 | } 259 | } 260 | 261 | // Handle tab click 262 | if let Some(index) = clicked_tab { 263 | self.active_tab = index; 264 | } 265 | 266 | // Add a "New Tab" button at the bottom of the panel 267 | let new_tab_rect = Rect::from_min_size( 268 | panel_rect.min + Vec2::new(0.0, panel_rect.height() - 30.0), 269 | Vec2::new(panel_rect.width(), 30.0), 270 | ); 271 | 272 | let new_tab_response = ui.allocate_rect(new_tab_rect, Sense::click()); 273 | let is_new_tab_hovered = new_tab_response.hovered(); 274 | 275 | // Draw button background 276 | let new_tab_bg = if is_new_tab_hovered { 277 | if is_dark { 278 | Color32::from_rgb(40, 40, 55) 279 | } else { 280 | Color32::from_rgb(225, 225, 235) 281 | } 282 | } else { 283 | bg_color 284 | }; 285 | 286 | ui.painter().rect_filled( 287 | new_tab_rect, 288 | egui::Rounding::same(2.0), 289 | new_tab_bg, 290 | ); 291 | 292 | // Draw a subtle border 293 | if is_new_tab_hovered { 294 | let border_color = if is_dark { 295 | Color32::from_rgba_premultiplied(255, 255, 255, 20) 296 | } else { 297 | Color32::from_rgba_premultiplied(0, 0, 0, 20) 298 | }; 299 | 300 | ui.painter().rect_stroke( 301 | new_tab_rect, 302 | egui::Rounding::same(2.0), 303 | egui::Stroke::new(1.0, border_color) 304 | ); 305 | } 306 | 307 | // Draw plus icon and text 308 | let new_tab_text_color = if is_dark { 309 | Color32::from_rgb(160, 160, 180) 310 | } else { 311 | Color32::from_rgb(100, 100, 120) 312 | }; 313 | 314 | ui.painter().text( 315 | new_tab_rect.center(), 316 | egui::Align2::CENTER_CENTER, 317 | "+ New", 318 | egui::FontId::proportional(11.0), 319 | new_tab_text_color, 320 | ); 321 | 322 | // Handle new tab button click 323 | if new_tab_response.clicked() { 324 | // Create a new tab 325 | self.tabs.push(Tab::new("Untitled".to_string(), None)); 326 | self.active_tab = self.tabs.len() - 1; 327 | } 328 | 329 | // Return the overall response 330 | ui.allocate_rect(panel_rect, Sense::hover()) 331 | } 332 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! Zing - A fast, beautiful, cross-platform text editor written in Rust. 2 | //! 3 | //! Zing is designed to handle very large files with ease while maintaining 4 | //! a sleek, modern interface. 5 | 6 | mod buffer; 7 | mod config; 8 | mod file_io; 9 | mod ui; 10 | 11 | use eframe::{egui, NativeOptions}; 12 | use env_logger::Env; 13 | use std::time::Instant; 14 | 15 | /// Main entry point for the application. 16 | fn main() -> Result<(), eframe::Error> { 17 | // Initialize logging 18 | env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); 19 | 20 | log::info!("Starting Zing"); 21 | 22 | // Set up the native options 23 | let options = NativeOptions { 24 | viewport: egui::ViewportBuilder::default() 25 | .with_inner_size([1200.0, 800.0]) 26 | .with_min_inner_size([400.0, 300.0]) 27 | .with_position([100.0, 100.0]) 28 | .with_decorations(true) 29 | .with_transparent(false) 30 | .with_icon(load_icon()), 31 | ..Default::default() 32 | }; 33 | 34 | // Run the native application 35 | eframe::run_native( 36 | "Zing", 37 | options, 38 | Box::new(|cc| Box::new(ZingApp::new(cc))), 39 | ) 40 | } 41 | 42 | /// The main application state. 43 | struct ZingApp { 44 | /// The UI state 45 | ui_state: ui::ZingApp, 46 | /// Last update time for calculating delta time 47 | last_update: Instant, 48 | } 49 | 50 | impl ZingApp { 51 | /// Creates a new application instance. 52 | fn new(cc: &eframe::CreationContext<'_>) -> Self { 53 | // Set up the UI state 54 | let ui_state = ui::ZingApp::new(&cc.egui_ctx); 55 | 56 | Self { 57 | ui_state, 58 | last_update: Instant::now(), 59 | } 60 | } 61 | 62 | /// Handle keyboard shortcuts 63 | fn handle_keyboard_shortcuts(&mut self, ctx: &egui::Context) { 64 | let modifiers = ctx.input(|i| i.modifiers); 65 | let cmd_or_ctrl = if cfg!(target_os = "macos") { modifiers.mac_cmd } else { modifiers.ctrl }; 66 | 67 | // Save: Cmd+S or Ctrl+S 68 | if cmd_or_ctrl && ctx.input(|i| i.key_pressed(egui::Key::S)) && !modifiers.shift { 69 | ui::editor::save_file(&mut self.ui_state, false); 70 | } 71 | 72 | // Save As: Cmd+Shift+S or Ctrl+Shift+S 73 | if cmd_or_ctrl && modifiers.shift && ctx.input(|i| i.key_pressed(egui::Key::S)) { 74 | ui::editor::save_file(&mut self.ui_state, true); 75 | } 76 | 77 | // Open: Cmd+O or Ctrl+O 78 | if cmd_or_ctrl && ctx.input(|i| i.key_pressed(egui::Key::O)) { 79 | ui::editor::open_file(&mut self.ui_state); 80 | } 81 | 82 | // New Tab: Cmd+T or Ctrl+T 83 | if cmd_or_ctrl && ctx.input(|i| i.key_pressed(egui::Key::T)) { 84 | self.ui_state.tabs.new_tab(); 85 | } 86 | 87 | // Close Tab: Cmd+W or Ctrl+W 88 | if cmd_or_ctrl && ctx.input(|i| i.key_pressed(egui::Key::W)) { 89 | // Check if this is the last tab and the user has already been warned 90 | if self.ui_state.tabs.tabs.len() <= 1 && self.ui_state.last_tab_close_warning { 91 | // User confirmed closing the last tab, quit the application 92 | ctx.send_viewport_cmd(egui::ViewportCommand::Close); 93 | } else { 94 | // Normal tab closing behavior 95 | ui::editor::close_tab(&mut self.ui_state); 96 | } 97 | } 98 | 99 | // Print: Cmd+P or Ctrl+P 100 | if cmd_or_ctrl && ctx.input(|i| i.key_pressed(egui::Key::P)) { 101 | ui::editor::print_file(&mut self.ui_state); 102 | } 103 | 104 | // Undo: Cmd+Z or Ctrl+Z 105 | if cmd_or_ctrl && ctx.input(|i| i.key_pressed(egui::Key::Z)) && !modifiers.shift { 106 | ui::editor::undo(&mut self.ui_state); 107 | } 108 | 109 | // Redo: Cmd+Shift+Z or Ctrl+Shift+Z or Ctrl+Y 110 | if (cmd_or_ctrl && modifiers.shift && ctx.input(|i| i.key_pressed(egui::Key::Z))) || 111 | (cfg!(not(target_os = "macos")) && modifiers.ctrl && ctx.input(|i| i.key_pressed(egui::Key::Y))) { 112 | ui::editor::redo(&mut self.ui_state); 113 | } 114 | } 115 | 116 | /// Sets up the native menu bar for macOS 117 | fn setup_menu_bar(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 118 | // Only show the menu bar on macOS 119 | #[cfg(target_os = "macos")] 120 | { 121 | egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { 122 | // Don't set the panel to invisible - we need it to be visible 123 | // ui.set_visible(false); // Hide the panel but still process the menu 124 | 125 | egui::menu::bar(ui, |ui| { 126 | // File menu 127 | egui::menu::menu_button(ui, "File", |ui| { 128 | if ui.button("New Tab ⌘T").clicked() { 129 | self.ui_state.tabs.new_tab(); 130 | ui.close_menu(); 131 | } 132 | if ui.button("Open... ⌘O").clicked() { 133 | ui::editor::open_file(&mut self.ui_state); 134 | ui.close_menu(); 135 | } 136 | 137 | // Change the label based on whether it's the last tab 138 | let close_label = if self.ui_state.tabs.tabs.len() <= 1 { 139 | "Quit ⌘W" 140 | } else { 141 | "Close Tab ⌘W" 142 | }; 143 | 144 | if ui.button(close_label).clicked() { 145 | if self.ui_state.tabs.tabs.len() <= 1 && self.ui_state.last_tab_close_warning { 146 | // User confirmed closing the last tab, quit the application 147 | ctx.send_viewport_cmd(egui::ViewportCommand::Close); 148 | } else { 149 | // Normal tab closing behavior 150 | ui::editor::close_tab(&mut self.ui_state); 151 | } 152 | ui.close_menu(); 153 | } 154 | 155 | ui.separator(); 156 | if ui.button("Save ⌘S").clicked() { 157 | ui::editor::save_file(&mut self.ui_state, false); 158 | ui.close_menu(); 159 | } 160 | if ui.button("Save As... Shift+⌘S").clicked() { 161 | ui::editor::save_file(&mut self.ui_state, true); 162 | ui.close_menu(); 163 | } 164 | ui.separator(); 165 | if ui.button("Print... ⌘P").clicked() { 166 | ui::editor::print_file(&mut self.ui_state); 167 | ui.close_menu(); 168 | } 169 | }); 170 | 171 | // Edit menu 172 | egui::menu::menu_button(ui, "Edit", |ui| { 173 | if ui.button("Undo ⌘Z").clicked() { 174 | ui::editor::undo(&mut self.ui_state); 175 | ui.close_menu(); 176 | } 177 | if ui.button("Redo Shift+⌘Z").clicked() { 178 | ui::editor::redo(&mut self.ui_state); 179 | ui.close_menu(); 180 | } 181 | ui.separator(); 182 | if ui.button("Cut ⌘X").clicked() { 183 | // Implement cut functionality 184 | ui.close_menu(); 185 | } 186 | if ui.button("Copy ⌘C").clicked() { 187 | // Implement copy functionality 188 | ui.close_menu(); 189 | } 190 | if ui.button("Paste ⌘V").clicked() { 191 | // Implement paste functionality 192 | ui.close_menu(); 193 | } 194 | }); 195 | 196 | // View menu 197 | egui::menu::menu_button(ui, "View", |ui| { 198 | if ui.button(if self.ui_state.config.show_line_numbers { "Hide Line Numbers" } else { "Show Line Numbers" }).clicked() { 199 | self.ui_state.config.toggle_line_numbers(); 200 | ui.close_menu(); 201 | } 202 | if ui.button(if self.ui_state.config.word_wrap { "Disable Word Wrap" } else { "Enable Word Wrap" }).clicked() { 203 | self.ui_state.config.toggle_word_wrap(); 204 | ui.close_menu(); 205 | } 206 | ui.separator(); 207 | if ui.button(if matches!(self.ui_state.config.theme, crate::config::Theme::Dark) { "Light Theme" } else { "Dark Theme" }).clicked() { 208 | self.ui_state.toggle_theme(ctx); 209 | ui.close_menu(); 210 | } 211 | }); 212 | 213 | // Format menu 214 | egui::menu::menu_button(ui, "Format", |ui| { 215 | if ui.button("Decrease Font Size").clicked() { 216 | self.ui_state.config.decrease_font_size(); 217 | self.ui_state.config.apply_to_context(ctx); 218 | ui.close_menu(); 219 | } 220 | if ui.button("Increase Font Size").clicked() { 221 | self.ui_state.config.increase_font_size(); 222 | self.ui_state.config.apply_to_context(ctx); 223 | ui.close_menu(); 224 | } 225 | }); 226 | }); 227 | }); 228 | } 229 | } 230 | } 231 | 232 | impl eframe::App for ZingApp { 233 | /// Called each time the UI needs repainting. 234 | fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { 235 | // Calculate delta time 236 | let now = Instant::now(); 237 | let delta_time = now.duration_since(self.last_update).as_secs_f32(); 238 | self.last_update = now; 239 | 240 | // Update status message timeout 241 | self.ui_state.update_status(delta_time); 242 | 243 | // Handle keyboard shortcuts 244 | self.handle_keyboard_shortcuts(ctx); 245 | 246 | // Set up the menu bar (macOS native menu) 247 | self.setup_menu_bar(ctx, frame); 248 | 249 | // Render the UI 250 | ui::ui(&mut self.ui_state, ctx); 251 | } 252 | } 253 | 254 | /// Loads the application icon. 255 | fn load_icon() -> egui::IconData { 256 | // Load icon from the assets directory 257 | let (icon_rgba, icon_width, icon_height) = { 258 | let icon_path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icon.png"); 259 | 260 | // Try to open the icon file 261 | match image::open(icon_path) { 262 | Ok(image) => { 263 | let image = image.into_rgba8(); 264 | let (width, height) = image.dimensions(); 265 | let rgba = image.into_raw(); 266 | (rgba, width, height) 267 | }, 268 | Err(err) => { 269 | // If the icon file doesn't exist or can't be opened, create a default icon 270 | log::warn!("Failed to load icon: {}", err); 271 | 272 | // Create a simple 32x32 icon with a gradient 273 | let width = 32; 274 | let height = 32; 275 | let mut rgba = Vec::with_capacity((width * height * 4) as usize); 276 | 277 | for y in 0..height { 278 | for x in 0..width { 279 | // Create a simple blue gradient 280 | let r = 0; 281 | let g = ((x as f32 / width as f32) * 100.0) as u8 + 50; 282 | let b = ((y as f32 / height as f32) * 200.0) as u8 + 55; 283 | let a = 255; 284 | 285 | rgba.push(r); 286 | rgba.push(g); 287 | rgba.push(b); 288 | rgba.push(a); 289 | } 290 | } 291 | 292 | (rgba, width, height) 293 | } 294 | } 295 | }; 296 | 297 | egui::IconData { 298 | rgba: icon_rgba, 299 | width: icon_width, 300 | height: icon_height, 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/buffer/mod.rs: -------------------------------------------------------------------------------- 1 | //! Text buffer module for Zing text editor. 2 | //! 3 | //! This module provides an efficient text buffer implementation using the Ropey crate, 4 | //! which is optimized for handling large text files and efficient editing operations. 5 | 6 | use anyhow::{Context, Result}; 7 | use ropey::Rope; 8 | use std::path::{Path, PathBuf}; 9 | use std::sync::Arc; 10 | use tokio::fs; 11 | 12 | /// Represents an edit operation that can be undone or redone. 13 | #[derive(Debug, Clone)] 14 | enum EditOperation { 15 | /// Insert text at a position 16 | Insert { 17 | position: usize, 18 | text: String, 19 | }, 20 | /// Delete text from a range 21 | Delete { 22 | start: usize, 23 | end: usize, 24 | text: String, 25 | }, 26 | } 27 | 28 | /// Represents a text buffer in the editor. 29 | #[derive(Debug, Clone)] 30 | pub struct TextBuffer { 31 | /// The text content stored as a rope data structure 32 | pub content: Rope, 33 | /// The file path associated with this buffer, if any 34 | pub file_path: Option, 35 | /// Whether the buffer has unsaved changes 36 | pub modified: bool, 37 | /// History of edit operations for undo 38 | undo_stack: Vec, 39 | /// History of edit operations for redo 40 | redo_stack: Vec, 41 | /// Whether we're currently in an undo/redo operation 42 | in_undo_redo: bool, 43 | } 44 | 45 | impl TextBuffer { 46 | /// Creates a new empty text buffer. 47 | pub fn new() -> Self { 48 | Self { 49 | content: Rope::new(), 50 | file_path: None, 51 | modified: false, 52 | undo_stack: Vec::new(), 53 | redo_stack: Vec::new(), 54 | in_undo_redo: false, 55 | } 56 | } 57 | 58 | /// Creates a new text buffer from the given content. 59 | pub fn from_str(content: &str) -> Self { 60 | Self { 61 | content: Rope::from_str(content), 62 | file_path: None, 63 | modified: false, 64 | undo_stack: Vec::new(), 65 | redo_stack: Vec::new(), 66 | in_undo_redo: false, 67 | } 68 | } 69 | 70 | /// Loads a text buffer from a file. 71 | pub async fn from_file>(path: P) -> Result { 72 | let path = path.as_ref(); 73 | let content = fs::read_to_string(path) 74 | .await 75 | .with_context(|| format!("Failed to read file: {}", path.display()))?; 76 | 77 | Ok(Self { 78 | content: Rope::from_str(&content), 79 | file_path: Some(path.to_path_buf()), 80 | modified: false, 81 | undo_stack: Vec::new(), 82 | redo_stack: Vec::new(), 83 | in_undo_redo: false, 84 | }) 85 | } 86 | 87 | /// Saves the buffer content to the associated file. 88 | pub async fn save(&mut self) -> Result<()> { 89 | if let Some(path) = self.file_path.clone() { 90 | self.save_to(path).await?; 91 | self.modified = false; 92 | Ok(()) 93 | } else { 94 | Err(anyhow::anyhow!("No file path associated with this buffer")) 95 | } 96 | } 97 | 98 | /// Saves the buffer content to a specific file path. 99 | pub async fn save_to>(&mut self, path: P) -> Result<()> { 100 | let path = path.as_ref(); 101 | let content = self.content.to_string(); 102 | 103 | fs::write(path, content) 104 | .await 105 | .with_context(|| format!("Failed to write to file: {}", path.display()))?; 106 | 107 | self.file_path = Some(path.to_path_buf()); 108 | self.modified = false; 109 | Ok(()) 110 | } 111 | 112 | /// Inserts text at the specified character position. 113 | pub fn insert(&mut self, char_idx: usize, text: &str) -> Result<()> { 114 | if char_idx <= self.content.len_chars() { 115 | // If not in an undo/redo operation, record this edit for undo 116 | if !self.in_undo_redo { 117 | self.undo_stack.push(EditOperation::Insert { 118 | position: char_idx, 119 | text: text.to_string(), 120 | }); 121 | // Clear redo stack when a new edit is made 122 | self.redo_stack.clear(); 123 | } 124 | 125 | self.content.insert(char_idx, text); 126 | self.modified = true; 127 | Ok(()) 128 | } else { 129 | Err(anyhow::anyhow!("Character index out of bounds")) 130 | } 131 | } 132 | 133 | /// Removes text in the specified character range. 134 | pub fn remove(&mut self, char_start: usize, char_end: usize) -> Result<()> { 135 | if char_start <= char_end && char_end <= self.content.len_chars() { 136 | // If not in an undo/redo operation, record this edit for undo 137 | if !self.in_undo_redo { 138 | let removed_text = self.content.slice(char_start..char_end).to_string(); 139 | self.undo_stack.push(EditOperation::Delete { 140 | start: char_start, 141 | end: char_end, 142 | text: removed_text, 143 | }); 144 | // Clear redo stack when a new edit is made 145 | self.redo_stack.clear(); 146 | } 147 | 148 | self.content.remove(char_start..char_end); 149 | self.modified = true; 150 | Ok(()) 151 | } else { 152 | Err(anyhow::anyhow!("Character range out of bounds")) 153 | } 154 | } 155 | 156 | /// Performs an undo operation, reverting the last edit. 157 | pub fn undo(&mut self) -> Result<()> { 158 | if let Some(operation) = self.undo_stack.pop() { 159 | self.in_undo_redo = true; 160 | 161 | match operation { 162 | EditOperation::Insert { position, text } => { 163 | // To undo an insert, we delete the inserted text 164 | let end_pos = position + text.chars().count(); 165 | self.remove(position, end_pos)?; 166 | 167 | // Add to redo stack 168 | self.redo_stack.push(EditOperation::Insert { 169 | position, 170 | text, 171 | }); 172 | }, 173 | EditOperation::Delete { start, end: _, text } => { 174 | // To undo a delete, we insert the deleted text 175 | self.insert(start, &text)?; 176 | 177 | // Add to redo stack 178 | self.redo_stack.push(EditOperation::Delete { 179 | start, 180 | end: start + text.chars().count(), 181 | text, 182 | }); 183 | }, 184 | } 185 | 186 | self.in_undo_redo = false; 187 | Ok(()) 188 | } else { 189 | // Nothing to undo 190 | Ok(()) 191 | } 192 | } 193 | 194 | /// Performs a redo operation, reapplying a previously undone edit. 195 | pub fn redo(&mut self) -> Result<()> { 196 | if let Some(operation) = self.redo_stack.pop() { 197 | self.in_undo_redo = true; 198 | 199 | match operation { 200 | EditOperation::Insert { position, text } => { 201 | // To redo an insert, we insert the text again 202 | self.insert(position, &text)?; 203 | 204 | // Add back to undo stack 205 | self.undo_stack.push(EditOperation::Insert { 206 | position, 207 | text, 208 | }); 209 | }, 210 | EditOperation::Delete { start, end, text } => { 211 | // To redo a delete, we delete the text again 212 | self.remove(start, end)?; 213 | 214 | // Add back to undo stack 215 | self.undo_stack.push(EditOperation::Delete { 216 | start, 217 | end, 218 | text, 219 | }); 220 | }, 221 | } 222 | 223 | self.in_undo_redo = false; 224 | Ok(()) 225 | } else { 226 | // Nothing to redo 227 | Ok(()) 228 | } 229 | } 230 | 231 | /// Returns the total number of characters in the buffer. 232 | pub fn len_chars(&self) -> usize { 233 | self.content.len_chars() 234 | } 235 | 236 | /// Returns the total number of lines in the buffer. 237 | pub fn len_lines(&self) -> usize { 238 | self.content.len_lines() 239 | } 240 | 241 | /// Returns whether the buffer is empty. 242 | pub fn is_empty(&self) -> bool { 243 | self.content.len_chars() == 0 244 | } 245 | 246 | /// Gets a slice of the buffer as a string. 247 | pub fn slice(&self, char_start: usize, char_end: usize) -> Result { 248 | if char_start <= char_end && char_end <= self.content.len_chars() { 249 | Ok(self.content.slice(char_start..char_end).to_string()) 250 | } else { 251 | Err(anyhow::anyhow!("Character range out of bounds")) 252 | } 253 | } 254 | 255 | /// Gets a line from the buffer. 256 | pub fn line(&self, line_idx: usize) -> Result { 257 | if line_idx < self.content.len_lines() { 258 | Ok(self.content.line(line_idx).to_string()) 259 | } else { 260 | Err(anyhow::anyhow!("Line index out of bounds")) 261 | } 262 | } 263 | 264 | /// Converts a character index to a line and column. 265 | pub fn char_to_line_col(&self, char_idx: usize) -> Result<(usize, usize)> { 266 | if char_idx <= self.content.len_chars() { 267 | let line_idx = self.content.char_to_line(char_idx); 268 | let line_char_idx = self.content.line_to_char(line_idx); 269 | let col = char_idx - line_char_idx; 270 | Ok((line_idx, col)) 271 | } else { 272 | Err(anyhow::anyhow!("Character index out of bounds")) 273 | } 274 | } 275 | 276 | /// Converts a line and column to a character index. 277 | pub fn line_col_to_char(&self, line: usize, col: usize) -> Result { 278 | if line < self.content.len_lines() { 279 | let line_char_idx = self.content.line_to_char(line); 280 | let line_len = self.content.line(line).len_chars(); 281 | 282 | if col <= line_len { 283 | Ok(line_char_idx + col) 284 | } else { 285 | Err(anyhow::anyhow!("Column index out of bounds")) 286 | } 287 | } else { 288 | Err(anyhow::anyhow!("Line index out of bounds")) 289 | } 290 | } 291 | 292 | /// Updates the buffer content from a string and records it as a single edit operation. 293 | pub fn update_content(&mut self, new_content: &str) -> Result<()> { 294 | // Record the entire change as a single edit operation 295 | if !self.in_undo_redo { 296 | let old_content = self.content.to_string(); 297 | 298 | // Only record if there's an actual change 299 | if old_content != new_content { 300 | // For simplicity, we'll treat this as a delete-all + insert-all operation 301 | self.undo_stack.push(EditOperation::Delete { 302 | start: 0, 303 | end: old_content.chars().count(), 304 | text: old_content, 305 | }); 306 | 307 | // Clear redo stack when a new edit is made 308 | self.redo_stack.clear(); 309 | } 310 | } 311 | 312 | // Update the content 313 | self.content = Rope::from_str(new_content); 314 | self.modified = true; 315 | 316 | Ok(()) 317 | } 318 | } 319 | 320 | impl Default for TextBuffer { 321 | fn default() -> Self { 322 | Self::new() 323 | } 324 | } 325 | 326 | #[cfg(test)] 327 | mod tests { 328 | use super::*; 329 | 330 | #[test] 331 | fn test_new_buffer() { 332 | let buffer = TextBuffer::new(); 333 | assert!(buffer.is_empty()); 334 | assert_eq!(buffer.len_chars(), 0); 335 | assert_eq!(buffer.len_lines(), 1); // Empty buffer has one line 336 | } 337 | 338 | #[test] 339 | fn test_from_str() { 340 | let text = "Hello, world!\nThis is a test."; 341 | let buffer = TextBuffer::from_str(text); 342 | assert_eq!(buffer.len_chars(), text.len()); 343 | assert_eq!(buffer.len_lines(), 2); 344 | } 345 | 346 | #[test] 347 | fn test_insert_remove() { 348 | let mut buffer = TextBuffer::new(); 349 | 350 | // Insert text 351 | buffer.insert(0, "Hello").unwrap(); 352 | assert_eq!(buffer.content.to_string(), "Hello"); 353 | 354 | // Insert more text 355 | buffer.insert(5, ", world!").unwrap(); 356 | assert_eq!(buffer.content.to_string(), "Hello, world!"); 357 | 358 | // Remove text 359 | buffer.remove(5, 13).unwrap(); 360 | assert_eq!(buffer.content.to_string(), "Hello!"); 361 | } 362 | 363 | #[test] 364 | fn test_undo_redo() { 365 | let mut buffer = TextBuffer::new(); 366 | 367 | // Insert text 368 | buffer.insert(0, "Hello").unwrap(); 369 | assert_eq!(buffer.content.to_string(), "Hello"); 370 | 371 | // Insert more text 372 | buffer.insert(5, ", world!").unwrap(); 373 | assert_eq!(buffer.content.to_string(), "Hello, world!"); 374 | 375 | // Undo the second insert 376 | buffer.undo().unwrap(); 377 | assert_eq!(buffer.content.to_string(), "Hello"); 378 | 379 | // Redo the second insert 380 | buffer.redo().unwrap(); 381 | assert_eq!(buffer.content.to_string(), "Hello, world!"); 382 | 383 | // Undo both inserts 384 | buffer.undo().unwrap(); 385 | buffer.undo().unwrap(); 386 | assert_eq!(buffer.content.to_string(), ""); 387 | } 388 | 389 | #[test] 390 | fn test_line_operations() { 391 | let text = "Line 1\nLine 2\nLine 3"; 392 | let buffer = TextBuffer::from_str(text); 393 | 394 | assert_eq!(buffer.line(0).unwrap(), "Line 1"); 395 | assert_eq!(buffer.line(1).unwrap(), "Line 2"); 396 | assert_eq!(buffer.line(2).unwrap(), "Line 3"); 397 | 398 | assert_eq!(buffer.char_to_line_col(0).unwrap(), (0, 0)); 399 | assert_eq!(buffer.char_to_line_col(6).unwrap(), (0, 6)); 400 | assert_eq!(buffer.char_to_line_col(7).unwrap(), (1, 0)); 401 | 402 | assert_eq!(buffer.line_col_to_char(0, 0).unwrap(), 0); 403 | assert_eq!(buffer.line_col_to_char(0, 6).unwrap(), 6); 404 | assert_eq!(buffer.line_col_to_char(1, 0).unwrap(), 7); 405 | } 406 | } -------------------------------------------------------------------------------- /.github/workflows/release-package.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release macOS Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | # Add permissions block to grant write access to releases 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-macos: 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Rust 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Install create-dmg and imagemagick 23 | run: brew install create-dmg imagemagick 24 | 25 | - name: Build Release Binary 26 | run: cargo build --release 27 | 28 | # Create proper macOS icon set 29 | - name: Create Icon Set 30 | run: | 31 | # Create temporary directory for icon processing 32 | mkdir -p iconset.iconset 33 | 34 | # Convert PNG to ICNS format with multiple resolutions 35 | # Assuming assets/icon.png is at least 1024x1024 pixels 36 | for size in 16 32 64 128 256 512 1024; do 37 | # Standard resolution 38 | sips -z $size $size assets/icon.png --out iconset.iconset/icon_${size}x${size}.png 39 | 40 | # High resolution (retina) 41 | if [ $size -lt 512 ]; then 42 | sips -z $((size*2)) $((size*2)) assets/icon.png --out iconset.iconset/icon_${size}x${size}@2x.png 43 | fi 44 | done 45 | 46 | # Create .icns file from the iconset 47 | iconutil -c icns iconset.iconset -o AppIcon.icns 48 | 49 | - name: Create App Bundle 50 | run: | 51 | mkdir -p Zing.app/Contents/{MacOS,Resources} 52 | cp target/release/zing Zing.app/Contents/MacOS/ 53 | cp AppIcon.icns Zing.app/Contents/Resources/ 54 | 55 | # Create comprehensive Info.plist with proper icon reference 56 | cat > Zing.app/Contents/Info.plist << EOF 57 | 58 | 59 | 60 | 61 | CFBundleExecutable 62 | zing 63 | CFBundleIconFile 64 | AppIcon 65 | CFBundleIdentifier 66 | com.sukeesh.zing 67 | CFBundleInfoDictionaryVersion 68 | 6.0 69 | CFBundleName 70 | Zing 71 | CFBundlePackageType 72 | APPL 73 | CFBundleShortVersionString 74 | ${{ github.event.release.tag_name }} 75 | CFBundleVersion 76 | ${{ github.event.release.tag_name }} 77 | LSMinimumSystemVersion 78 | 10.13 79 | NSHighResolutionCapable 80 | 81 | NSPrincipalClass 82 | NSApplication 83 | LSApplicationCategoryType 84 | public.app-category.developer-tools 85 | CFBundleDocumentTypes 86 | 87 | 88 | CFBundleTypeExtensions 89 | 90 | txt 91 | md 92 | * 93 | 94 | CFBundleTypeName 95 | Text Document 96 | CFBundleTypeRole 97 | Editor 98 | LSHandlerRank 99 | Alternate 100 | 101 | 102 | 103 | 104 | EOF 105 | 106 | # Set up keychain and import certificates 107 | - name: Set up keychain 108 | env: 109 | KEYCHAIN_PASSWORD: ${{ github.run_id }} 110 | run: | 111 | security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain 112 | security default-keychain -s build.keychain 113 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain 114 | security set-keychain-settings -t 3600 -u build.keychain 115 | 116 | # Import Developer ID certificate 117 | - name: Import Developer ID Certificate 118 | env: 119 | CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_BASE64 }} 120 | CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} 121 | KEYCHAIN_PASSWORD: ${{ github.run_id }} 122 | run: | 123 | echo "$CERTIFICATE_BASE64" | base64 --decode > certificate.p12 124 | security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign 125 | security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain 126 | rm certificate.p12 127 | 128 | # Sign the application with enhanced entitlements 129 | - name: Sign the application 130 | env: 131 | DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} 132 | run: | 133 | # Create enhanced entitlements file 134 | cat > entitlements.plist << EOF 135 | 136 | 137 | 138 | 139 | com.apple.security.cs.allow-jit 140 | 141 | com.apple.security.cs.allow-unsigned-executable-memory 142 | 143 | com.apple.security.cs.disable-library-validation 144 | 145 | com.apple.security.files.user-selected.read-only 146 | 147 | com.apple.security.files.user-selected.read-write 148 | 149 | com.apple.security.network.client 150 | 151 | 152 | 153 | EOF 154 | 155 | # Make sure executable has proper permissions 156 | chmod +x Zing.app/Contents/MacOS/zing 157 | 158 | # Sign the app with hardened runtime 159 | /usr/bin/codesign --force --options runtime --entitlements entitlements.plist --sign "$DEVELOPER_ID" Zing.app --deep --verbose 160 | 161 | # Verify signing 162 | codesign -vvv --deep --strict Zing.app 163 | 164 | # Notarize the application with fixed script 165 | - name: Notarize the application 166 | id: notarize 167 | continue-on-error: true 168 | env: 169 | APPLE_ID: ${{ secrets.APPLE_ID }} 170 | APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} 171 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 172 | run: | 173 | # Create a ZIP for notarization 174 | ditto -c -k --keepParent Zing.app Zing.zip 175 | 176 | # Submit for notarization with JSON output for reliable parsing 177 | echo "Submitting app for notarization..." 178 | SUBMISSION_JSON=$(xcrun notarytool submit Zing.zip --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait --output-format json) 179 | echo "Submission JSON: $SUBMISSION_JSON" 180 | 181 | # Extract submission ID properly using grep and cut 182 | SUBMISSION_ID=$(echo "$SUBMISSION_JSON" | grep -o '"id" *: *"[^"]*"' | cut -d'"' -f4) 183 | echo "Extracted Submission ID: $SUBMISSION_ID" 184 | 185 | if [ -n "$SUBMISSION_ID" ]; then 186 | # Get detailed logs about the failure 187 | echo "Getting detailed notarization log..." 188 | xcrun notarytool log "$SUBMISSION_ID" --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID" notarization.log || true 189 | 190 | if [ -f notarization.log ]; then 191 | echo "=== NOTARIZATION LOG ===" 192 | cat notarization.log 193 | echo "========================" 194 | else 195 | echo "Failed to get notarization log, trying to get info instead" 196 | fi 197 | 198 | # Check status 199 | STATUS_JSON=$(xcrun notarytool info "$SUBMISSION_ID" --apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID" --output-format json) 200 | echo "Status JSON: $STATUS_JSON" 201 | 202 | STATUS=$(echo "$STATUS_JSON" | grep -o '"status" *: *"[^"]*"' | cut -d'"' -f4) 203 | echo "Extracted Status: $STATUS" 204 | 205 | if [ "$STATUS" = "Accepted" ]; then 206 | echo "Notarization successful!" 207 | xcrun stapler staple Zing.app 208 | echo "::set-output name=notarized::true" 209 | else 210 | echo "Notarization failed with status: $STATUS" 211 | echo "::set-output name=notarized::false" 212 | fi 213 | else 214 | echo "Failed to get submission ID" 215 | echo "::set-output name=notarized::false" 216 | fi 217 | 218 | # Create helper script to open the app 219 | - name: Create helper script 220 | run: | 221 | cat > "open-zing.command" << 'EOF' 222 | #!/bin/bash 223 | 224 | # Get the directory where this script is located 225 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 226 | 227 | # Path to the app 228 | APP_PATH="$DIR/Zing.app" 229 | 230 | # Remove quarantine attribute if present 231 | echo "Removing quarantine attribute from Zing.app..." 232 | xattr -d com.apple.quarantine "$APP_PATH" 2>/dev/null || true 233 | 234 | # Open the app 235 | echo "Opening Zing.app..." 236 | open "$APP_PATH" 237 | 238 | echo "Done! If Zing opened successfully, you can close this terminal window." 239 | EOF 240 | 241 | # Make the script executable 242 | chmod +x open-zing.command 243 | 244 | # Create detailed instructions file 245 | - name: Create detailed instructions 246 | run: | 247 | cat > "HOW_TO_OPEN_ZING.md" << 'EOF' 248 | # How to Open Zing on macOS 249 | 250 | Since Zing is signed but not notarized with Apple, macOS security features may prevent it from opening normally. Here are three ways to open it: 251 | 252 | ## Option 1: Use the Helper Script (Easiest) 253 | 254 | 1. Double-click the `open-zing.command` file in this folder 255 | 2. If prompted with a security warning, click "Open" 256 | 3. The script will remove security restrictions and open Zing automatically 257 | 258 | ## Option 2: Right-Click Method 259 | 260 | 1. In Finder, right-click (or Control-click) on `Zing.app` 261 | 2. Select "Open" from the context menu 262 | 3. Click "Open" in the security dialog that appears 263 | 4. The app will now open and be remembered as safe 264 | 265 | ## Option 3: Security & Privacy Settings 266 | 267 | If the above methods don't work: 268 | 269 | 1. Try to open the app normally (it will be blocked) 270 | 2. Open System Preferences > Security & Privacy 271 | 3. Look for a message about Zing being blocked 272 | 4. Click "Open Anyway" 273 | 274 | ## Option 4: Terminal Command 275 | 276 | For advanced users: 277 | 278 | ``` 279 | xattr -d com.apple.quarantine /path/to/Zing.app 280 | ``` 281 | 282 | Replace `/path/to/Zing.app` with the actual path to where you installed Zing. 283 | 284 | ## Need Help? 285 | 286 | If you're still having trouble, please open an issue on GitHub. 287 | EOF 288 | 289 | # Create installation instructions 290 | - name: Create installation instructions 291 | run: | 292 | cat > "INSTALL.md" << 'EOF' 293 | # Installing Zing 294 | 295 | ## Installation 296 | 297 | 1. Drag the Zing app to your Applications folder 298 | 2. Use the included helper script to open it the first time 299 | 300 | ## First Run 301 | 302 | The first time you run Zing, macOS may show security warnings. This is normal for apps from independent developers. 303 | 304 | ## Troubleshooting 305 | 306 | If you see a broken icon in the Dock: 307 | 308 | 1. Quit Zing if it's running 309 | 2. Delete the app from your Applications folder 310 | 3. Empty the Trash 311 | 4. Reinstall Zing from this DMG 312 | 5. Use the helper script to open it 313 | 314 | This ensures the icon cache is properly refreshed. 315 | EOF 316 | 317 | # Create DMG with signed app and helper files 318 | - name: Create DMG 319 | run: | 320 | # Create a temporary directory for DMG contents 321 | mkdir -p dmg_contents 322 | cp -r Zing.app dmg_contents/ 323 | cp open-zing.command dmg_contents/ 324 | cp HOW_TO_OPEN_ZING.md dmg_contents/ 325 | cp INSTALL.md dmg_contents/ 326 | 327 | # Create Applications folder symlink 328 | ln -s /Applications dmg_contents/ 329 | 330 | # Create the DMG 331 | create-dmg \ 332 | --volname "Zing" \ 333 | --volicon "AppIcon.icns" \ 334 | --window-pos 200 120 \ 335 | --window-size 800 500 \ 336 | --icon-size 100 \ 337 | --icon "Zing.app" 200 190 \ 338 | --icon "open-zing.command" 400 190 \ 339 | --icon "Applications" 600 190 \ 340 | --hide-extension "Zing.app" \ 341 | --hide-extension "open-zing.command" \ 342 | --hide-extension "HOW_TO_OPEN_ZING.md" \ 343 | --hide-extension "INSTALL.md" \ 344 | "Zing.dmg" \ 345 | dmg_contents 346 | 347 | # Sign the DMG 348 | - name: Sign the DMG 349 | env: 350 | DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} 351 | run: | 352 | /usr/bin/codesign --force --sign "$DEVELOPER_ID" Zing.dmg --verbose 353 | 354 | - name: Upload App Bundle 355 | uses: actions/upload-artifact@v4 356 | with: 357 | name: Zing.app 358 | path: Zing.app 359 | 360 | - name: Upload DMG 361 | uses: actions/upload-artifact@v4 362 | with: 363 | name: Zing.dmg 364 | path: Zing.dmg 365 | 366 | - name: Attach to Release 367 | uses: softprops/action-gh-release@v1 368 | with: 369 | files: Zing.dmg 370 | -------------------------------------------------------------------------------- /src/ui/editor.rs: -------------------------------------------------------------------------------- 1 | //! Editor view component for Zing text editor. 2 | 3 | use egui::{Color32, FontId, TextEdit, Ui, Vec2, Rounding, Stroke, TextStyle}; 4 | use egui::text::{LayoutJob, TextFormat}; 5 | use std::sync::mpsc::{self, Sender, Receiver}; 6 | use std::sync::{Arc, Mutex, Once}; 7 | use std::path::PathBuf; 8 | 9 | use crate::config::Theme; 10 | use crate::ui::ZingApp; 11 | 12 | // Global channel for file operations 13 | static INIT: Once = Once::new(); 14 | static mut FILE_OP_SENDER: Option> = None; 15 | static mut FILE_OP_RECEIVER: Option> = None; 16 | 17 | // File operation types 18 | enum FileOperation { 19 | OpenComplete(Option), 20 | SaveComplete(Option, bool), 21 | ResetDialogFlag, 22 | } 23 | 24 | /// Editor view component. 25 | #[derive(Debug)] 26 | pub struct EditorView; 27 | 28 | /// Renders the editor UI. 29 | pub fn ui(app: &mut ZingApp, ui: &mut Ui) { 30 | // Initialize the channel if not already done 31 | INIT.call_once(|| { 32 | let (sender, receiver) = mpsc::channel(); 33 | unsafe { 34 | FILE_OP_SENDER = Some(sender); 35 | FILE_OP_RECEIVER = Some(receiver); 36 | } 37 | }); 38 | 39 | // Check for file operation results 40 | unsafe { 41 | if let Some(receiver) = &FILE_OP_RECEIVER { 42 | while let Ok(op) = receiver.try_recv() { 43 | match op { 44 | FileOperation::OpenComplete(Some(buffer)) => { 45 | app.set_buffer(buffer); 46 | app.set_status("File opened successfully".to_string(), 3.0); 47 | }, 48 | FileOperation::SaveComplete(Some(path), _) => { 49 | // Update the tab title and file path in the main app 50 | if let Some(tab) = app.tabs.tabs.get_mut(app.tabs.active_tab) { 51 | tab.title = path.file_name().unwrap_or_default().to_string_lossy().to_string(); 52 | tab.file_path = Some(path.clone()); 53 | tab.is_modified = false; 54 | } 55 | app.set_status(format!("File saved: {}", path.display()), 3.0); 56 | }, 57 | FileOperation::SaveComplete(None, true) => { 58 | app.set_status("Save cancelled".to_string(), 3.0); 59 | }, 60 | FileOperation::SaveComplete(None, false) => { 61 | app.set_status("Failed to save file".to_string(), 5.0); 62 | }, 63 | FileOperation::ResetDialogFlag => { 64 | app.file_dialog_open = false; 65 | }, 66 | _ => {} 67 | } 68 | } 69 | } 70 | } 71 | 72 | let buffer = app.buffer(); 73 | let mut buffer_lock = buffer.lock().unwrap(); 74 | 75 | // Get the content as a string 76 | let mut content_str = buffer_lock.content.to_string(); 77 | 78 | // Store the font size for the layouter 79 | let font_size = app.config.font_size; 80 | 81 | // Get available size 82 | let available_size = ui.available_size(); 83 | 84 | // Set up editor styling based on theme 85 | let is_dark = matches!(app.config.theme, Theme::Dark); 86 | 87 | // Define modern color scheme with extreme contrast 88 | let (bg_color, text_color) = if is_dark { 89 | ( 90 | Color32::from_rgb(10, 10, 15), // Dark background 91 | Color32::from_rgb(255, 255, 255) // Pure white text 92 | ) 93 | } else { 94 | ( 95 | Color32::from_rgb(252, 252, 255), // Light background 96 | Color32::from_rgb(0, 0, 0) // Pure black text 97 | ) 98 | }; 99 | 100 | // Set the background color for the entire UI 101 | ui.style_mut().visuals.panel_fill = bg_color; 102 | ui.style_mut().visuals.window_fill = bg_color; 103 | ui.style_mut().visuals.faint_bg_color = bg_color; 104 | ui.style_mut().visuals.extreme_bg_color = bg_color; 105 | ui.style_mut().visuals.code_bg_color = bg_color; 106 | 107 | // Set text colors with maximum contrast using fg_stroke 108 | ui.style_mut().visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, text_color); 109 | ui.style_mut().visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, text_color); 110 | ui.style_mut().visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, text_color); 111 | ui.style_mut().visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, text_color); 112 | 113 | // Remove all strokes and borders 114 | ui.style_mut().visuals.widgets.noninteractive.bg_stroke = Stroke::NONE; 115 | ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; 116 | ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE; 117 | ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; 118 | 119 | // Set consistent background fills 120 | ui.style_mut().visuals.widgets.noninteractive.bg_fill = bg_color; 121 | ui.style_mut().visuals.widgets.inactive.bg_fill = bg_color; 122 | ui.style_mut().visuals.widgets.active.bg_fill = bg_color; 123 | ui.style_mut().visuals.widgets.hovered.bg_fill = bg_color; 124 | 125 | // Set text and selection colors 126 | ui.style_mut().visuals.widgets.noninteractive.fg_stroke.color = text_color; 127 | ui.style_mut().visuals.widgets.inactive.fg_stroke.color = text_color; 128 | ui.style_mut().visuals.widgets.active.fg_stroke.color = text_color; 129 | ui.style_mut().visuals.widgets.hovered.fg_stroke.color = text_color; 130 | ui.style_mut().visuals.selection.bg_fill = Color32::from_rgba_premultiplied(100, 100, 255, 100); 131 | ui.style_mut().visuals.selection.stroke = Stroke::NONE; 132 | 133 | // Customize scrolling behavior 134 | ui.style_mut().visuals.clip_rect_margin = 0.0; 135 | ui.style_mut().spacing.item_spacing = Vec2::splat(0.0); 136 | ui.style_mut().spacing.window_margin = egui::Margin::same(0.0); 137 | 138 | // Create a scrollable area for the editor content 139 | egui::ScrollArea::vertical() 140 | .auto_shrink([false, false]) 141 | .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible) 142 | .show(ui, |ui| { 143 | // Override text color settings directly 144 | ui.visuals_mut().override_text_color = Some(text_color); 145 | 146 | // Add padding at the top to ensure first line is visible 147 | ui.add_space(20.0); 148 | 149 | // Create a text edit widget with explicit styling 150 | let text_edit = TextEdit::multiline(&mut content_str) 151 | .font(FontId::monospace(font_size)) 152 | .desired_width(f32::INFINITY) 153 | .desired_rows(50) // Set a large number of visible rows to encourage scrolling 154 | .interactive(true) // Ensure it's interactive 155 | .text_color(text_color) 156 | .frame(false); // Remove frame to maximize space 157 | 158 | // Show the text edit widget 159 | let output = text_edit.show(ui); 160 | let response = output.response; 161 | 162 | // Update cursor position using TextEdit's output 163 | if let Some(cursor_range) = output.cursor_range { 164 | app.cursor_pos = cursor_range.primary.ccursor.index; 165 | } 166 | 167 | // Add some space at the bottom 168 | ui.add_space(100.0); 169 | 170 | // Handle text changes and cursor position 171 | if response.changed() { 172 | // If the text changed, update the buffer content 173 | buffer_lock.update_content(&content_str).unwrap(); 174 | 175 | // Mark the current tab as modified 176 | if let Some(tab) = app.tabs.tabs.get_mut(app.tabs.active_tab) { 177 | tab.is_modified = true; 178 | } 179 | } 180 | }); 181 | 182 | // Get cursor position for status bar 183 | let cursor_info = if let Ok((line, col)) = buffer_lock.char_to_line_col(app.cursor_pos) { 184 | Some((line, col)) 185 | } else { 186 | None 187 | }; 188 | 189 | // Update the app with cursor position info for the status bar 190 | if let Some((line, col)) = cursor_info { 191 | app.cursor_line = line; 192 | app.cursor_column = col; 193 | } 194 | 195 | // Release the lock before calling functions that might need it 196 | drop(buffer_lock); 197 | 198 | // Handle keyboard shortcuts 199 | if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl) { 200 | // Ctrl+S: Save 201 | save_file(app, false); 202 | } else if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl && i.modifiers.shift) { 203 | // Ctrl+Shift+S: Save As 204 | save_file(app, true); 205 | } else if ui.input(|i| i.key_pressed(egui::Key::O) && i.modifiers.ctrl) { 206 | // Ctrl+O: Open 207 | open_file(app); 208 | } else if ui.input(|i| i.key_pressed(egui::Key::P) && i.modifiers.ctrl) { 209 | // Ctrl+P: Print 210 | print_file(app); 211 | } else if ui.input(|i| i.key_pressed(egui::Key::Z) && i.modifiers.ctrl && !i.modifiers.shift) { 212 | // Ctrl+Z: Undo 213 | undo(app); 214 | } else if (ui.input(|i| i.key_pressed(egui::Key::Z) && i.modifiers.ctrl && i.modifiers.shift) || 215 | ui.input(|i| i.key_pressed(egui::Key::Y) && i.modifiers.ctrl)) { 216 | // Ctrl+Shift+Z or Ctrl+Y: Redo 217 | redo(app); 218 | } 219 | } 220 | 221 | /// Opens a file using a file dialog. 222 | pub fn open_file(app: &mut ZingApp) { 223 | if app.file_dialog_open { 224 | return; 225 | } 226 | 227 | app.file_dialog_open = true; 228 | let tabs = Arc::new(Mutex::new(app.tabs.clone())); 229 | 230 | // Use a background thread for file dialog to avoid blocking the UI 231 | std::thread::spawn({ 232 | let sender = unsafe { FILE_OP_SENDER.clone().unwrap() }; 233 | let tabs = Arc::clone(&tabs); 234 | 235 | move || { 236 | if let Some(path) = crate::file_io::open_file_dialog() { 237 | let runtime = tokio::runtime::Builder::new_current_thread() 238 | .enable_all() 239 | .build() 240 | .unwrap(); 241 | 242 | runtime.block_on(async { 243 | match crate::file_io::load_file(&path).await { 244 | Ok(new_buffer) => { 245 | // Create a new tab for the opened file 246 | sender.send(FileOperation::OpenComplete(Some(new_buffer))).ok(); 247 | // Update the tab information 248 | let mut tabs = tabs.lock().unwrap(); 249 | tabs.tabs.push(crate::ui::tabs::Tab::new( 250 | path.file_name().unwrap_or_default().to_string_lossy().to_string(), 251 | Some(path) 252 | )); 253 | tabs.active_tab = tabs.tabs.len() - 1; 254 | } 255 | Err(err) => { 256 | log::error!("Failed to load file: {}", err); 257 | sender.send(FileOperation::OpenComplete(None)).ok(); 258 | } 259 | } 260 | }); 261 | } 262 | 263 | // Reset the file dialog flag 264 | sender.send(FileOperation::ResetDialogFlag).ok(); 265 | } 266 | }); 267 | } 268 | 269 | /// Saves the current buffer to a file. 270 | pub fn save_file(app: &mut ZingApp, save_as: bool) { 271 | if app.file_dialog_open { 272 | return; 273 | } 274 | 275 | let buffer = app.buffer(); 276 | let buffer_lock = buffer.lock().unwrap(); 277 | 278 | // If save_as is true or the buffer has no associated file path, show a save dialog 279 | let need_path = save_as || buffer_lock.file_path.is_none(); 280 | 281 | // Get the current file path if it exists 282 | let current_path = buffer_lock.file_path.clone(); 283 | 284 | // Release the lock 285 | drop(buffer_lock); 286 | 287 | if need_path { 288 | app.file_dialog_open = true; 289 | let tabs = Arc::new(Mutex::new(app.tabs.clone())); 290 | let active_tab = app.tabs.active_tab; 291 | 292 | // Use a background thread for file dialog to avoid blocking the UI 293 | std::thread::spawn({ 294 | let buffer = app.buffer(); 295 | let sender = unsafe { FILE_OP_SENDER.clone().unwrap() }; 296 | let tabs = Arc::clone(&tabs); 297 | 298 | move || { 299 | if let Some(path) = crate::file_io::save_file_dialog() { 300 | let runtime = tokio::runtime::Builder::new_current_thread() 301 | .enable_all() 302 | .build() 303 | .unwrap(); 304 | 305 | runtime.block_on(async { 306 | let mut buffer_lock = buffer.lock().unwrap(); 307 | match buffer_lock.save_to(&path).await { 308 | Ok(_) => { 309 | log::info!("File saved successfully: {}", path.display()); 310 | // Update the tab information 311 | let mut tabs = tabs.lock().unwrap(); 312 | if let Some(tab) = tabs.tabs.get_mut(active_tab) { 313 | tab.title = path.file_name().unwrap_or_default().to_string_lossy().to_string(); 314 | tab.file_path = Some(path.clone()); 315 | tab.is_modified = false; 316 | } 317 | sender.send(FileOperation::SaveComplete(Some(path), true)).ok(); 318 | } 319 | Err(err) => { 320 | log::error!("Failed to save file: {}", err); 321 | sender.send(FileOperation::SaveComplete(None, false)).ok(); 322 | } 323 | } 324 | }); 325 | } else { 326 | // User cancelled the save dialog 327 | sender.send(FileOperation::SaveComplete(None, true)).ok(); 328 | } 329 | 330 | // Reset the file dialog flag 331 | sender.send(FileOperation::ResetDialogFlag).ok(); 332 | } 333 | }); 334 | } else if let Some(path) = current_path { 335 | // Save to the existing file path 336 | let runtime = tokio::runtime::Builder::new_current_thread() 337 | .enable_all() 338 | .build() 339 | .unwrap(); 340 | 341 | let buffer = app.buffer(); 342 | let path_clone = path.clone(); 343 | let tabs = Arc::new(Mutex::new(app.tabs.clone())); 344 | let active_tab = app.tabs.active_tab; 345 | 346 | runtime.block_on(async { 347 | let mut buffer_lock = buffer.lock().unwrap(); 348 | match buffer_lock.save().await { 349 | Ok(_) => { 350 | log::info!("File saved successfully: {}", path_clone.display()); 351 | // Update the tab information 352 | let mut tabs = tabs.lock().unwrap(); 353 | if let Some(tab) = tabs.tabs.get_mut(active_tab) { 354 | tab.is_modified = false; 355 | } 356 | app.set_status(format!("Saved file: {}", path_clone.display()), 3.0); 357 | } 358 | Err(err) => { 359 | log::error!("Failed to save file: {}", err); 360 | app.set_status(format!("Failed to save file: {}", err), 5.0); 361 | } 362 | } 363 | }); 364 | } 365 | } 366 | 367 | /// Prints the current buffer. 368 | pub fn print_file(app: &mut ZingApp) { 369 | let buffer = app.buffer(); 370 | let buffer_lock = buffer.lock().unwrap(); 371 | 372 | match crate::file_io::print_buffer(&buffer_lock) { 373 | Ok(_) => { 374 | app.set_status("File opened with default application for printing.".to_string(), 3.0); 375 | } 376 | Err(err) => { 377 | app.set_status(format!("Failed to print: {}", err), 5.0); 378 | log::error!("Failed to print: {}", err); 379 | } 380 | } 381 | } 382 | 383 | /// Creates a new empty tab. 384 | pub fn new_tab(app: &mut ZingApp) { 385 | // Create a new tab with an empty buffer 386 | let tab = crate::ui::tabs::Tab::new("Untitled".to_string(), None); 387 | app.tabs.tabs.push(tab); 388 | app.tabs.active_tab = app.tabs.tabs.len() - 1; 389 | app.cursor_pos = 0; 390 | app.set_status("Created new tab".to_string(), 2.0); 391 | } 392 | 393 | /// Performs an undo operation on the current buffer. 394 | pub fn undo(app: &mut ZingApp) { 395 | let buffer = app.buffer(); 396 | let mut buffer_lock = buffer.lock().unwrap(); 397 | 398 | match buffer_lock.undo() { 399 | Ok(_) => { 400 | app.set_status("Undo successful".to_string(), 2.0); 401 | } 402 | Err(err) => { 403 | app.set_status(format!("Failed to undo: {}", err), 3.0); 404 | log::error!("Failed to undo: {}", err); 405 | } 406 | } 407 | } 408 | 409 | /// Performs a redo operation on the current buffer. 410 | pub fn redo(app: &mut ZingApp) { 411 | let buffer = app.buffer(); 412 | let mut buffer_lock = buffer.lock().unwrap(); 413 | 414 | match buffer_lock.redo() { 415 | Ok(_) => { 416 | app.set_status("Redo successful".to_string(), 2.0); 417 | } 418 | Err(err) => { 419 | app.set_status(format!("Failed to redo: {}", err), 3.0); 420 | log::error!("Failed to redo: {}", err); 421 | } 422 | } 423 | } 424 | 425 | /// Closes the current tab. 426 | pub fn close_tab(app: &mut ZingApp) { 427 | // Check if this is the last tab 428 | if app.tabs.tabs.len() <= 1 { 429 | // This is the last tab, warn the user 430 | app.set_status("Warning: Closing the last tab will quit the application. Press Cmd+W again to confirm.".to_string(), 5.0); 431 | 432 | // Set a flag in the app to track that the user has been warned 433 | app.last_tab_close_warning = true; 434 | return; 435 | } 436 | 437 | // If we previously showed a warning for the last tab but now have multiple tabs, 438 | // reset the warning flag 439 | if app.last_tab_close_warning && app.tabs.tabs.len() > 1 { 440 | app.last_tab_close_warning = false; 441 | } 442 | 443 | // Try to close the current tab 444 | if app.tabs.close_tab() { 445 | app.set_status("Tab closed".to_string(), 2.0); 446 | } 447 | } -------------------------------------------------------------------------------- /src/ui/toolbar.rs: -------------------------------------------------------------------------------- 1 | //! Toolbar component for Zing text editor. 2 | 3 | use egui::{Color32, RichText, Ui, Vec2, Stroke, Rounding}; 4 | 5 | use crate::ui::ZingApp; 6 | use crate::ui::editor; 7 | use crate::config::Theme; 8 | 9 | /// Toolbar component. 10 | #[derive(Debug)] 11 | pub struct Toolbar; 12 | 13 | /// Draw a custom icon in the UI 14 | fn draw_icon(ui: &mut Ui, icon_type: &str, is_dark: bool, word_wrap: bool) { 15 | let rect = ui.available_rect_before_wrap().shrink(8.0); 16 | let center = rect.center(); 17 | let stroke_width = 1.5; 18 | let text_color = ui.visuals().widgets.inactive.fg_stroke.color; 19 | let stroke = Stroke::new(stroke_width, text_color); 20 | 21 | match icon_type { 22 | "open" => { 23 | // Folder icon 24 | let folder_top = center.y - 4.0; 25 | let folder_left = center.x - 6.0; 26 | let folder_right = center.x + 6.0; 27 | let folder_bottom = center.y + 4.0; 28 | 29 | // Folder base 30 | ui.painter().rect_stroke( 31 | egui::Rect::from_min_max( 32 | egui::pos2(folder_left, folder_top), 33 | egui::pos2(folder_right, folder_bottom) 34 | ), 35 | Rounding::same(1.0), 36 | stroke 37 | ); 38 | 39 | // Folder tab 40 | ui.painter().line_segment( 41 | [ 42 | egui::pos2(folder_left + 2.0, folder_top), 43 | egui::pos2(folder_left + 4.0, folder_top - 2.0) 44 | ], 45 | stroke 46 | ); 47 | ui.painter().line_segment( 48 | [ 49 | egui::pos2(folder_left + 4.0, folder_top - 2.0), 50 | egui::pos2(folder_left + 7.0, folder_top - 2.0) 51 | ], 52 | stroke 53 | ); 54 | ui.painter().line_segment( 55 | [ 56 | egui::pos2(folder_left + 7.0, folder_top - 2.0), 57 | egui::pos2(folder_left + 7.0, folder_top) 58 | ], 59 | stroke 60 | ); 61 | }, 62 | "save" => { 63 | // Save icon (floppy disk) 64 | let size = 10.0; 65 | let left = center.x - size/2.0; 66 | let top = center.y - size/2.0; 67 | 68 | // Outer square 69 | ui.painter().rect_stroke( 70 | egui::Rect::from_min_max( 71 | egui::pos2(left, top), 72 | egui::pos2(left + size, top + size) 73 | ), 74 | Rounding::same(1.0), 75 | stroke 76 | ); 77 | 78 | // Inner square (disk label) 79 | ui.painter().rect_stroke( 80 | egui::Rect::from_min_max( 81 | egui::pos2(left + size*0.25, top + size*0.25), 82 | egui::pos2(left + size*0.75, top + size*0.5) 83 | ), 84 | Rounding::ZERO, 85 | stroke 86 | ); 87 | }, 88 | "save_as" => { 89 | // Save As icon (floppy with plus) 90 | let size = 10.0; 91 | let left = center.x - size/2.0; 92 | let top = center.y - size/2.0; 93 | 94 | // Outer square 95 | ui.painter().rect_stroke( 96 | egui::Rect::from_min_max( 97 | egui::pos2(left, top), 98 | egui::pos2(left + size, top + size) 99 | ), 100 | Rounding::same(1.0), 101 | stroke 102 | ); 103 | 104 | // Plus sign 105 | ui.painter().line_segment( 106 | [ 107 | egui::pos2(left + size*0.5, top + size*0.25), 108 | egui::pos2(left + size*0.5, top + size*0.75) 109 | ], 110 | stroke 111 | ); 112 | ui.painter().line_segment( 113 | [ 114 | egui::pos2(left + size*0.25, top + size*0.5), 115 | egui::pos2(left + size*0.75, top + size*0.5) 116 | ], 117 | stroke 118 | ); 119 | }, 120 | "print" => { 121 | // Printer icon 122 | let width = 12.0; 123 | let height = 10.0; 124 | let left = center.x - width/2.0; 125 | let top = center.y - height/2.0; 126 | 127 | // Printer body 128 | ui.painter().rect_stroke( 129 | egui::Rect::from_min_max( 130 | egui::pos2(left, top + 2.0), 131 | egui::pos2(left + width, top + height - 2.0) 132 | ), 133 | Rounding::same(1.0), 134 | stroke 135 | ); 136 | 137 | // Paper 138 | ui.painter().rect_stroke( 139 | egui::Rect::from_min_max( 140 | egui::pos2(left + 2.0, top), 141 | egui::pos2(left + width - 2.0, top + 2.0) 142 | ), 143 | Rounding::ZERO, 144 | stroke 145 | ); 146 | 147 | // Output paper 148 | ui.painter().rect_stroke( 149 | egui::Rect::from_min_max( 150 | egui::pos2(left + 2.0, top + height - 2.0), 151 | egui::pos2(left + width - 2.0, top + height) 152 | ), 153 | Rounding::ZERO, 154 | stroke 155 | ); 156 | }, 157 | "theme" => { 158 | // Moon/sun icon 159 | let radius = 5.0; 160 | 161 | if is_dark { 162 | // Sun icon for dark mode 163 | ui.painter().circle_stroke(center, radius, stroke); 164 | 165 | // Sun rays 166 | let ray_length = 2.0; 167 | for i in 0..8 { 168 | let angle = i as f32 * std::f32::consts::PI / 4.0; 169 | let start_x = center.x + (radius + 1.0) * angle.cos(); 170 | let start_y = center.y + (radius + 1.0) * angle.sin(); 171 | let end_x = center.x + (radius + ray_length + 1.0) * angle.cos(); 172 | let end_y = center.y + (radius + ray_length + 1.0) * angle.sin(); 173 | 174 | ui.painter().line_segment( 175 | [egui::pos2(start_x, start_y), egui::pos2(end_x, end_y)], 176 | stroke 177 | ); 178 | } 179 | } else { 180 | // Moon icon for light mode 181 | ui.painter().circle_stroke(center, radius, stroke); 182 | ui.painter().circle_filled( 183 | egui::pos2(center.x + 2.0, center.y - 2.0), 184 | radius - 1.0, 185 | ui.style().visuals.widgets.inactive.bg_fill 186 | ); 187 | } 188 | }, 189 | "line_numbers" => { 190 | // Line numbers icon 191 | let width = 10.0; 192 | let height = 10.0; 193 | let left = center.x - width/2.0; 194 | let top = center.y - height/2.0; 195 | 196 | // Document outline 197 | ui.painter().rect_stroke( 198 | egui::Rect::from_min_max( 199 | egui::pos2(left, top), 200 | egui::pos2(left + width, top + height) 201 | ), 202 | Rounding::same(1.0), 203 | stroke 204 | ); 205 | 206 | // Line number column 207 | ui.painter().line_segment( 208 | [ 209 | egui::pos2(left + 3.0, top), 210 | egui::pos2(left + 3.0, top + height) 211 | ], 212 | stroke 213 | ); 214 | 215 | // Text lines 216 | for i in 0..3 { 217 | let y = top + 2.5 + i as f32 * 2.5; 218 | ui.painter().line_segment( 219 | [ 220 | egui::pos2(left + 4.0, y), 221 | egui::pos2(left + width - 1.0, y) 222 | ], 223 | Stroke::new(0.7, text_color) 224 | ); 225 | } 226 | }, 227 | "word_wrap" => { 228 | // Word wrap icon 229 | let width = 10.0; 230 | let height = 10.0; 231 | let left = center.x - width/2.0; 232 | let top = center.y - height/2.0; 233 | 234 | // Text lines 235 | for i in 0..2 { 236 | let y = top + 3.0 + i as f32 * 4.0; 237 | ui.painter().line_segment( 238 | [ 239 | egui::pos2(left, y), 240 | egui::pos2(left + width, y) 241 | ], 242 | stroke 243 | ); 244 | } 245 | 246 | // Wrap arrow 247 | if word_wrap { 248 | let arrow_y = top + 7.0; 249 | ui.painter().line_segment( 250 | [ 251 | egui::pos2(left, arrow_y), 252 | egui::pos2(left + width - 3.0, arrow_y) 253 | ], 254 | stroke 255 | ); 256 | 257 | // Arrow head 258 | ui.painter().line_segment( 259 | [ 260 | egui::pos2(left + width - 3.0, arrow_y), 261 | egui::pos2(left + width - 5.0, arrow_y - 2.0) 262 | ], 263 | stroke 264 | ); 265 | ui.painter().line_segment( 266 | [ 267 | egui::pos2(left + width - 3.0, arrow_y), 268 | egui::pos2(left + width - 5.0, arrow_y + 2.0) 269 | ], 270 | stroke 271 | ); 272 | } else { 273 | // No wrap arrow 274 | ui.painter().line_segment( 275 | [ 276 | egui::pos2(left, top + 7.0), 277 | egui::pos2(left + width, top + 7.0) 278 | ], 279 | stroke 280 | ); 281 | 282 | // Arrow head 283 | ui.painter().line_segment( 284 | [ 285 | egui::pos2(left + width, top + 7.0), 286 | egui::pos2(left + width - 2.0, top + 5.0) 287 | ], 288 | stroke 289 | ); 290 | ui.painter().line_segment( 291 | [ 292 | egui::pos2(left + width, top + 7.0), 293 | egui::pos2(left + width - 2.0, top + 9.0) 294 | ], 295 | stroke 296 | ); 297 | } 298 | }, 299 | _ => {} 300 | } 301 | } 302 | 303 | /// Renders the toolbar UI. 304 | pub fn ui(app: &mut ZingApp, ui: &mut Ui) { 305 | // Set up toolbar styling 306 | let is_dark = matches!(app.config.theme, Theme::Dark); 307 | let accent_color = if is_dark { Color32::from_rgb(75, 135, 220) } else { Color32::from_rgb(59, 130, 246) }; 308 | let hover_color = if is_dark { Color32::from_rgb(45, 55, 72) } else { Color32::from_rgb(226, 232, 240) }; 309 | let divider_color = if is_dark { Color32::from_gray(45) } else { Color32::from_gray(220) }; 310 | 311 | // Style the toolbar background 312 | let toolbar_height = 36.0; 313 | let toolbar_rect = ui.max_rect(); 314 | ui.painter().rect_filled( 315 | egui::Rect::from_min_size( 316 | toolbar_rect.min, 317 | egui::vec2(toolbar_rect.width(), toolbar_height) 318 | ), 319 | Rounding::ZERO, 320 | if is_dark { Color32::from_rgb(30, 30, 30) } else { Color32::from_rgb(245, 245, 245) } 321 | ); 322 | 323 | // Configure button styling 324 | ui.style_mut().visuals.button_frame = true; 325 | ui.style_mut().spacing.button_padding = Vec2::new(10.0, 6.0); 326 | ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT; 327 | ui.style_mut().visuals.widgets.hovered.bg_fill = hover_color; 328 | ui.style_mut().visuals.widgets.active.bg_fill = accent_color.linear_multiply(0.8); 329 | ui.style_mut().visuals.widgets.active.fg_stroke = Stroke::new(1.0, Color32::WHITE); 330 | 331 | // Create a horizontal layout with some padding 332 | ui.add_space(8.0); 333 | ui.horizontal(|ui| { 334 | ui.set_height(36.0); 335 | 336 | // File operations buttons with modern icons 337 | let button_size = Vec2::new(32.0, 28.0); 338 | let text_color = ui.visuals().widgets.inactive.fg_stroke.color; 339 | let word_wrap = app.config.word_wrap; 340 | 341 | // Draw the icons directly on the buttons 342 | let draw_button = |ui: &mut Ui, icon_type: &str, tooltip: &str| -> bool { 343 | let btn = ui.add_sized(button_size, egui::Button::new(" ")).on_hover_text(tooltip); 344 | 345 | // Draw the icon on the button 346 | let rect = btn.rect; 347 | let painter = ui.painter(); 348 | let center = rect.center(); 349 | let stroke_width = 1.5; 350 | let stroke = Stroke::new(stroke_width, text_color); 351 | 352 | match icon_type { 353 | "open" => { 354 | // Folder icon 355 | let folder_top = center.y - 4.0; 356 | let folder_left = center.x - 6.0; 357 | let folder_right = center.x + 6.0; 358 | let folder_bottom = center.y + 4.0; 359 | 360 | // Folder base 361 | painter.rect_stroke( 362 | egui::Rect::from_min_max( 363 | egui::pos2(folder_left, folder_top), 364 | egui::pos2(folder_right, folder_bottom) 365 | ), 366 | Rounding::same(1.0), 367 | stroke 368 | ); 369 | 370 | // Folder tab 371 | painter.line_segment( 372 | [ 373 | egui::pos2(folder_left + 2.0, folder_top), 374 | egui::pos2(folder_left + 4.0, folder_top - 2.0) 375 | ], 376 | stroke 377 | ); 378 | painter.line_segment( 379 | [ 380 | egui::pos2(folder_left + 4.0, folder_top - 2.0), 381 | egui::pos2(folder_left + 7.0, folder_top - 2.0) 382 | ], 383 | stroke 384 | ); 385 | painter.line_segment( 386 | [ 387 | egui::pos2(folder_left + 7.0, folder_top - 2.0), 388 | egui::pos2(folder_left + 7.0, folder_top) 389 | ], 390 | stroke 391 | ); 392 | }, 393 | "save" => { 394 | // Save icon (floppy disk) 395 | let size = 10.0; 396 | let left = center.x - size/2.0; 397 | let top = center.y - size/2.0; 398 | 399 | // Outer square 400 | painter.rect_stroke( 401 | egui::Rect::from_min_max( 402 | egui::pos2(left, top), 403 | egui::pos2(left + size, top + size) 404 | ), 405 | Rounding::same(1.0), 406 | stroke 407 | ); 408 | 409 | // Inner square (disk label) 410 | painter.rect_stroke( 411 | egui::Rect::from_min_max( 412 | egui::pos2(left + size*0.25, top + size*0.25), 413 | egui::pos2(left + size*0.75, top + size*0.5) 414 | ), 415 | Rounding::ZERO, 416 | stroke 417 | ); 418 | }, 419 | "save_as" => { 420 | // Save As icon (floppy with plus) 421 | let size = 10.0; 422 | let left = center.x - size/2.0; 423 | let top = center.y - size/2.0; 424 | 425 | // Outer square 426 | painter.rect_stroke( 427 | egui::Rect::from_min_max( 428 | egui::pos2(left, top), 429 | egui::pos2(left + size, top + size) 430 | ), 431 | Rounding::same(1.0), 432 | stroke 433 | ); 434 | 435 | // Plus sign 436 | painter.line_segment( 437 | [ 438 | egui::pos2(left + size*0.5, top + size*0.25), 439 | egui::pos2(left + size*0.5, top + size*0.75) 440 | ], 441 | stroke 442 | ); 443 | painter.line_segment( 444 | [ 445 | egui::pos2(left + size*0.25, top + size*0.5), 446 | egui::pos2(left + size*0.75, top + size*0.5) 447 | ], 448 | stroke 449 | ); 450 | }, 451 | "print" => { 452 | // Printer icon 453 | let width = 12.0; 454 | let height = 10.0; 455 | let left = center.x - width/2.0; 456 | let top = center.y - height/2.0; 457 | 458 | // Printer body 459 | painter.rect_stroke( 460 | egui::Rect::from_min_max( 461 | egui::pos2(left, top + 2.0), 462 | egui::pos2(left + width, top + height - 2.0) 463 | ), 464 | Rounding::same(1.0), 465 | stroke 466 | ); 467 | 468 | // Paper 469 | painter.rect_stroke( 470 | egui::Rect::from_min_max( 471 | egui::pos2(left + 2.0, top), 472 | egui::pos2(left + width - 2.0, top + 2.0) 473 | ), 474 | Rounding::ZERO, 475 | stroke 476 | ); 477 | 478 | // Output paper 479 | painter.rect_stroke( 480 | egui::Rect::from_min_max( 481 | egui::pos2(left + 2.0, top + height - 2.0), 482 | egui::pos2(left + width - 2.0, top + height) 483 | ), 484 | Rounding::ZERO, 485 | stroke 486 | ); 487 | }, 488 | "theme" => { 489 | // Moon/sun icon 490 | let radius = 5.0; 491 | 492 | if is_dark { 493 | // Sun icon for dark mode 494 | painter.circle_stroke(center, radius, stroke); 495 | 496 | // Sun rays 497 | let ray_length = 2.0; 498 | for i in 0..8 { 499 | let angle = i as f32 * std::f32::consts::PI / 4.0; 500 | let start_x = center.x + (radius + 1.0) * angle.cos(); 501 | let start_y = center.y + (radius + 1.0) * angle.sin(); 502 | let end_x = center.x + (radius + ray_length + 1.0) * angle.cos(); 503 | let end_y = center.y + (radius + ray_length + 1.0) * angle.sin(); 504 | 505 | painter.line_segment( 506 | [egui::pos2(start_x, start_y), egui::pos2(end_x, end_y)], 507 | stroke 508 | ); 509 | } 510 | } else { 511 | // Moon icon for light mode 512 | painter.circle_stroke(center, radius, stroke); 513 | painter.circle_filled( 514 | egui::pos2(center.x + 2.0, center.y - 2.0), 515 | radius - 1.0, 516 | ui.style().visuals.widgets.inactive.bg_fill 517 | ); 518 | } 519 | }, 520 | "line_numbers" => { 521 | // Line numbers icon 522 | let width = 10.0; 523 | let height = 10.0; 524 | let left = center.x - width/2.0; 525 | let top = center.y - height/2.0; 526 | 527 | // Document outline 528 | painter.rect_stroke( 529 | egui::Rect::from_min_max( 530 | egui::pos2(left, top), 531 | egui::pos2(left + width, top + height) 532 | ), 533 | Rounding::same(1.0), 534 | stroke 535 | ); 536 | 537 | // Line number column 538 | painter.line_segment( 539 | [ 540 | egui::pos2(left + 3.0, top), 541 | egui::pos2(left + 3.0, top + height) 542 | ], 543 | stroke 544 | ); 545 | 546 | // Text lines 547 | for i in 0..3 { 548 | let y = top + 2.5 + i as f32 * 2.5; 549 | painter.line_segment( 550 | [ 551 | egui::pos2(left + 4.0, y), 552 | egui::pos2(left + width - 1.0, y) 553 | ], 554 | Stroke::new(0.7, text_color) 555 | ); 556 | } 557 | }, 558 | "word_wrap" => { 559 | // Word wrap icon 560 | let width = 10.0; 561 | let height = 10.0; 562 | let left = center.x - width/2.0; 563 | let top = center.y - height/2.0; 564 | 565 | // Text lines 566 | for i in 0..2 { 567 | let y = top + 3.0 + i as f32 * 4.0; 568 | painter.line_segment( 569 | [ 570 | egui::pos2(left, y), 571 | egui::pos2(left + width, y) 572 | ], 573 | stroke 574 | ); 575 | } 576 | 577 | // Wrap arrow 578 | if word_wrap { 579 | let arrow_y = top + 7.0; 580 | painter.line_segment( 581 | [ 582 | egui::pos2(left, arrow_y), 583 | egui::pos2(left + width - 3.0, arrow_y) 584 | ], 585 | stroke 586 | ); 587 | 588 | // Arrow head 589 | painter.line_segment( 590 | [ 591 | egui::pos2(left + width - 3.0, arrow_y), 592 | egui::pos2(left + width - 5.0, arrow_y - 2.0) 593 | ], 594 | stroke 595 | ); 596 | painter.line_segment( 597 | [ 598 | egui::pos2(left + width - 3.0, arrow_y), 599 | egui::pos2(left + width - 5.0, arrow_y + 2.0) 600 | ], 601 | stroke 602 | ); 603 | } else { 604 | // No wrap arrow 605 | painter.line_segment( 606 | [ 607 | egui::pos2(left, top + 7.0), 608 | egui::pos2(left + width, top + 7.0) 609 | ], 610 | stroke 611 | ); 612 | 613 | // Arrow head 614 | painter.line_segment( 615 | [ 616 | egui::pos2(left + width, top + 7.0), 617 | egui::pos2(left + width - 2.0, top + 5.0) 618 | ], 619 | stroke 620 | ); 621 | painter.line_segment( 622 | [ 623 | egui::pos2(left + width, top + 7.0), 624 | egui::pos2(left + width - 2.0, top + 9.0) 625 | ], 626 | stroke 627 | ); 628 | } 629 | }, 630 | _ => {} 631 | } 632 | 633 | btn.clicked() 634 | }; 635 | 636 | // Open file button 637 | if draw_button(ui, "open", "Open File (Ctrl+O)") { 638 | editor::open_file(app); 639 | } 640 | 641 | // Save button 642 | if draw_button(ui, "save", "Save (Ctrl+S)") { 643 | editor::save_file(app, false); 644 | } 645 | 646 | // Save As button 647 | if draw_button(ui, "save_as", "Save As (Ctrl+Shift+S)") { 648 | editor::save_file(app, true); 649 | } 650 | 651 | // Print button 652 | if draw_button(ui, "print", "Print (Ctrl+P)") { 653 | editor::print_file(app); 654 | } 655 | 656 | ui.add_space(8.0); 657 | // Draw vertical divider 658 | let divider_rect = ui.available_rect_before_wrap(); 659 | ui.painter().line_segment( 660 | [ 661 | egui::pos2(divider_rect.min.x, divider_rect.min.y + 8.0), 662 | egui::pos2(divider_rect.min.x, divider_rect.max.y - 8.0) 663 | ], 664 | Stroke::new(1.0, divider_color) 665 | ); 666 | ui.add_space(8.0); 667 | 668 | // Theme toggle button 669 | if draw_button(ui, "theme", if is_dark { "Switch to Light Mode" } else { "Switch to Dark Mode" }) { 670 | app.toggle_theme(ui.ctx()); 671 | } 672 | 673 | ui.add_space(8.0); 674 | // Draw vertical divider 675 | let divider_rect = ui.available_rect_before_wrap(); 676 | ui.painter().line_segment( 677 | [ 678 | egui::pos2(divider_rect.min.x, divider_rect.min.y + 8.0), 679 | egui::pos2(divider_rect.min.x, divider_rect.max.y - 8.0) 680 | ], 681 | Stroke::new(1.0, divider_color) 682 | ); 683 | ui.add_space(8.0); 684 | 685 | // Font size controls with modern styling 686 | let text_color = ui.visuals().widgets.inactive.fg_stroke.color; 687 | 688 | if ui.add_sized(Vec2::new(28.0, 28.0), 689 | egui::Button::new(RichText::new("A-").size(14.0).color(text_color))) 690 | .on_hover_text("Decrease Font Size") 691 | .clicked() 692 | { 693 | app.config.decrease_font_size(); 694 | app.config.apply_to_context(ui.ctx()); 695 | } 696 | 697 | ui.label(RichText::new(format!("{:.0}", app.config.font_size)).size(14.0)); 698 | 699 | if ui.add_sized(Vec2::new(28.0, 28.0), 700 | egui::Button::new(RichText::new("A+").size(14.0).color(text_color))) 701 | .on_hover_text("Increase Font Size") 702 | .clicked() 703 | { 704 | app.config.increase_font_size(); 705 | app.config.apply_to_context(ui.ctx()); 706 | } 707 | 708 | ui.add_space(8.0); 709 | // Draw vertical divider 710 | let divider_rect = ui.available_rect_before_wrap(); 711 | ui.painter().line_segment( 712 | [ 713 | egui::pos2(divider_rect.min.x, divider_rect.min.y + 8.0), 714 | egui::pos2(divider_rect.min.x, divider_rect.max.y - 8.0) 715 | ], 716 | Stroke::new(1.0, divider_color) 717 | ); 718 | ui.add_space(8.0); 719 | 720 | // Line numbers button 721 | if draw_button(ui, "line_numbers", if app.config.show_line_numbers { "Hide Line Numbers" } else { "Show Line Numbers" }) { 722 | app.config.toggle_line_numbers(); 723 | } 724 | 725 | // Word wrap button 726 | if draw_button(ui, "word_wrap", if app.config.word_wrap { "Disable Word Wrap" } else { "Enable Word Wrap" }) { 727 | app.config.toggle_word_wrap(); 728 | } 729 | }); 730 | } -------------------------------------------------------------------------------- /webpage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Zing - A Fast, Beautiful Text Editor for Mac 7 | 8 | 9 | 10 | 676 | 677 | 678 |
679 |
680 | 691 |
692 |
693 | 694 |
695 |
696 |
697 |

A Fast, Beautiful Text Editor for Mac

698 |
0/100 characters
699 | 700 | 701 | 702 | 703 | Open Source & Free Forever 704 | 705 |

Zing is a modern text editor written in Rust, designed to handle very large files with ease while maintaining a sleek, minimal interface.

706 |
0/100 characters
707 |
708 | Download Zing - Free 709 |
710 |
711 |
712 | Zing Editor Screenshot 713 |
714 |
715 |
716 | 717 |
718 |
719 |
720 |

Why Choose Zing?

721 |

Designed with speed, efficiency, and beauty in mind

722 |
723 |
724 |
725 |
726 |

Fast & Efficient

727 |

Optimized for speed and memory efficiency, capable of handling multi-GB files without breaking a sweat.

728 |
729 |
730 |
🎨
731 |

Beautiful UI

732 |

Clean, minimal design with light and dark themes that make editing text a pleasure.

733 |
734 |
735 |
💻
736 |

Mac Optimized

737 |

Built specifically for macOS, with native performance and integration with system features.

738 |
739 |
740 |
📄
741 |

Core Functionality

742 |

Open, Save, and Print text files with intuitive keyboard shortcuts and controls.

743 |
744 |
745 |
🔌
746 |

Extensible

747 |

Designed with future extensions in mind, ready to grow with your needs.

748 |
749 |
750 |
🔒
751 |

Secure

752 |

Built with Rust, ensuring memory safety and security for your important documents.

753 |
754 |
755 |
756 |
757 | 758 |
759 |
760 |

Ready to Transform Your Text Editing Experience?

761 |

Join thousands of users who have switched to Zing for a faster, more beautiful text editing experience. Download now for free!

762 | Download Now 763 |
764 |
765 | 766 |
767 |
768 |
769 |
770 |

Get in Touch

771 |

Have questions or suggestions? Reach out to us through any of these channels.

772 |
773 |
774 |
775 |

GitHub Issues

776 |

Report bugs or request features through our GitHub issues tracker.

777 | Create an Issue → 778 |
779 |
780 |

GitHub Discussions

781 |

Join the community discussion and share your ideas.

782 | Start Discussion → 783 |
784 |
785 |

Connect with Developer

786 |

Reach out to Sukeesh directly through LinkedIn or GitHub.

787 | LinkedIn → 788 |
789 |
790 |
791 |
792 |
793 | 794 | 846 | 847 | 921 | 922 | 923 | --------------------------------------------------------------------------------