├── 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 |
3 |
4 | # Zing Text Editor
5 |
6 | A beautiful, cross-platform text editor written in Rust
7 |
8 | [](https://github.com/sukeesh/zing)
9 | [](https://www.rust-lang.org/)
10 | [](http://sukeesh.in/zing/)
11 |
12 | [Features](#-features) • [Building from Source](#-building-from-source) • [Usage](#-usage) • [Development](#️-development)
13 |
14 |
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 |
183 |
184 |
185 |
186 |
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 |
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 |
681 |
682 |
683 |
Zing
684 |
685 |
690 |
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 |
710 |
711 |
712 |
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 |
793 |
794 |
795 |
796 |
841 |
844 |
845 |
846 |
847 |
921 |
922 |
923 |
--------------------------------------------------------------------------------