├── .gitignore ├── .cargo └── config.toml ├── assets ├── fonts │ └── montserrat │ │ ├── bold.ttf │ │ ├── italic.ttf │ │ ├── regular.ttf │ │ └── OFL.txt ├── icons │ ├── dash.svg │ ├── minus.svg │ ├── check.svg │ ├── chevron-up.svg │ ├── plus.svg │ ├── close.svg │ ├── moon.svg │ ├── chevron-down.svg │ ├── arrow-up.svg │ ├── chevron-left.svg │ ├── chevron-right.svg │ ├── arrow-down.svg │ ├── loader-circle.svg │ ├── arrow-left.svg │ ├── arrow-right.svg │ ├── circle-check.svg │ ├── info.svg │ ├── circle-x.svg │ ├── search.svg │ ├── panel-left.svg │ ├── panel-right.svg │ ├── asterisk.svg │ ├── chevrons-up-down.svg │ ├── panel-bottom.svg │ ├── bell.svg │ ├── ellipsis.svg │ ├── ellipsis-vertical.svg │ ├── globe.svg │ ├── menu.svg │ ├── calendar.svg │ ├── copy.svg │ ├── panel-left-open.svg │ ├── star.svg │ ├── panel-bottom-open.svg │ ├── panel-right-open.svg │ ├── a-large-small.svg │ ├── delete.svg │ ├── triangle-alert.svg │ ├── heart.svg │ ├── eye.svg │ ├── maximize.svg │ ├── star-off.svg │ ├── inbox.svg │ ├── minimize.svg │ ├── thumbs-down.svg │ ├── thumbs-up.svg │ ├── sort-ascending.svg │ ├── sort-descending.svg │ ├── sun.svg │ ├── heart-off.svg │ ├── panel-left-close.svg │ ├── panel-right-close.svg │ ├── circle-user.svg │ ├── settings-2.svg │ ├── loader.svg │ ├── bot.svg │ ├── chart-pie.svg │ ├── book-open.svg │ ├── gallery-vertical-end.svg │ ├── square-terminal.svg │ ├── eye-off.svg │ ├── frame.svg │ ├── map.svg │ ├── palette.svg │ ├── layout-dashboard.svg │ ├── resize-corner.svg │ ├── github.svg │ ├── settings.svg │ ├── window-minimize.svg │ ├── window-close.svg │ ├── window-maximize.svg │ └── window-restore.svg └── brand │ ├── icon.svg │ └── menubar.svg ├── crates ├── settings │ ├── src │ │ ├── lib.rs │ │ ├── files.rs │ │ └── keymap.rs │ ├── Cargo.toml │ └── keymaps │ │ └── linux-windows.toml ├── editor │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ ├── markdown.rs │ │ ├── cursor.rs │ │ ├── markdown │ │ ├── skipmap.rs │ │ └── inline.rs │ │ ├── actions.rs │ │ ├── element.rs │ │ └── editor.rs └── glyph │ ├── Cargo.toml │ └── src │ ├── fonts.rs │ ├── assets.rs │ ├── ui.rs │ └── main.rs ├── SECURITY.md ├── Cargo.toml ├── example.md ├── LICENSE ├── README.md ├── CODE_OF_CONDUCT.md ├── .github └── readme_icon.svg └── legal └── LICENSE-GPUI-COMPONENTS /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | .idea -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = ["-C", "link-arg=-z", "-C", "link-arg=nostart-stop-gc"] 3 | -------------------------------------------------------------------------------- /assets/fonts/montserrat/bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bloeckchengrafik/glyph/HEAD/assets/fonts/montserrat/bold.ttf -------------------------------------------------------------------------------- /assets/fonts/montserrat/italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bloeckchengrafik/glyph/HEAD/assets/fonts/montserrat/italic.ttf -------------------------------------------------------------------------------- /assets/fonts/montserrat/regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bloeckchengrafik/glyph/HEAD/assets/fonts/montserrat/regular.ttf -------------------------------------------------------------------------------- /crates/settings/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod keymap; 2 | mod files; 3 | 4 | use gpui::{App, Window}; 5 | 6 | pub fn init(window: &mut Window, cx: &mut App) { 7 | keymap::init(window, cx); 8 | } 9 | -------------------------------------------------------------------------------- /assets/icons/dash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/minus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/chevron-up.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/moon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/loader-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/circle-check.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/editor/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "editor" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | gpui.workspace = true 8 | components.workspace = true 9 | log.workspace = true 10 | anyhow.workspace = true 11 | 12 | smallvec = "1.13.2" 13 | unicode-segmentation = "1.12.0" -------------------------------------------------------------------------------- /assets/icons/circle-x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/panel-left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/panel-right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /crates/settings/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "settings" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | gpui.workspace = true 8 | components.workspace = true 9 | log.workspace = true 10 | anyhow.workspace = true 11 | serde.workspace = true 12 | 13 | toml = "0.8.19" 14 | dirs = "6.0.0" -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Currently no versions are public nor supported. If you feel like you need to report a security vulnerability anyways, please do so by heading over to the "Security"-Tab in Github and creating a new one. 4 | Reports are processed as I have time, so don't expect same-day resolving. 5 | -------------------------------------------------------------------------------- /assets/icons/asterisk.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/chevrons-up-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/panel-bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/bell.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/ellipsis.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/ellipsis-vertical.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/calendar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/panel-left-open.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/star.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/panel-bottom-open.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/panel-right-open.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/a-large-small.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crates/editor/src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Editor heavily based on https://github.com/longbridge/gpui-component licensed under Apache-2.0 3 | * See legal/LICENSE-GPUI-COMPONENTS for more information 4 | */ 5 | mod actions; 6 | pub mod editor; 7 | mod element; 8 | mod cursor; 9 | mod markdown; 10 | 11 | pub fn init(cx: &mut gpui::App) { 12 | actions::init(cx); 13 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["crates/*"] 4 | 5 | [workspace.dependencies] 6 | gpui = { git = "https://github.com/scopeclient/zed.git", branch = "feature/export-platform-window" } 7 | components = { package = "ui", git = "https://github.com/scopeclient/components", version = "0.1.0" } 8 | log = "0.4.25" 9 | anyhow = "1.0.95" 10 | serde = "1.0.217" -------------------------------------------------------------------------------- /assets/icons/triangle-alert.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /crates/glyph/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "glyph" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | gpui.workspace = true 8 | components.workspace = true 9 | log.workspace = true 10 | anyhow.workspace = true 11 | 12 | pretty_env_logger = "0.5.0" 13 | rust-embed = "8.5.0" 14 | 15 | editor = { path = "../editor" } 16 | settings = { path = "../settings" } 17 | -------------------------------------------------------------------------------- /assets/icons/star-off.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/inbox.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/icons/thumbs-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/thumbs-up.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/sort-ascending.svg: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/icons/sort-descending.svg: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/icons/sun.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/heart-off.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/panel-left-close.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/icons/panel-right-close.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/icons/circle-user.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/icons/settings-2.svg: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /assets/icons/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /assets/icons/bot.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/icons/chart-pie.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/icons/book-open.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /assets/icons/gallery-vertical-end.svg: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /example.md: -------------------------------------------------------------------------------- 1 | # Hello Glyph 2 | 3 | This is an example of a Glyph document. It is written in Markdown and can 4 | use *Italic*, **Bold**, and `Code` formatting. 5 | 6 | ## Lists 7 | 8 | - Item 1 9 | - Item 2 10 | - Item 3 11 | 12 | 1. Numbered Item 1 13 | 2. Numbered Item 2 14 | 3. Numbered Item 3 15 | 16 | ## Code 17 | 18 | ```python 19 | print("Hello, World!") 20 | ``` 21 | 22 | ## Tables 23 | 24 | | Header 1 | Header 2 | 25 | |----------|----------| 26 | | Cell 1 | Cell 2 | 27 | | Cell 3 | Cell 4 | 28 | 29 | -------------------------------------------------------------------------------- /crates/glyph/src/fonts.rs: -------------------------------------------------------------------------------- 1 | use gpui::App; 2 | 3 | pub(crate) fn init(app: &mut App) { 4 | app.text_system().add_fonts(vec![ 5 | app 6 | .asset_source() 7 | .load("fonts/montserrat/regular.ttf") 8 | .unwrap() 9 | .unwrap(), 10 | app 11 | .asset_source() 12 | .load("fonts/montserrat/bold.ttf") 13 | .unwrap() 14 | .unwrap(), 15 | app 16 | .asset_source() 17 | .load("fonts/montserrat/italic.ttf") 18 | .unwrap() 19 | .unwrap() 20 | ]).unwrap(); 21 | } -------------------------------------------------------------------------------- /assets/icons/square-terminal.svg: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /assets/icons/eye-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/icons/frame.svg: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /assets/icons/map.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /crates/glyph/src/assets.rs: -------------------------------------------------------------------------------- 1 | use gpui::{AssetSource, SharedString}; 2 | use gpui::http_client::anyhow; 3 | use anyhow::Result; 4 | use rust_embed::RustEmbed; 5 | 6 | #[derive(RustEmbed)] 7 | #[folder = "../../assets"] 8 | pub struct Assets; 9 | 10 | impl AssetSource for Assets { 11 | fn load(&self, path: &str) -> Result>> { 12 | Self::get(path).map(|f| Some(f.data)).ok_or_else(|| anyhow!("asset \"{}\" not found", path)) 13 | } 14 | 15 | fn list(&self, path: &str) -> Result> { 16 | Ok(Self::iter().filter_map(|p| if p.starts_with(path) { Some(p.into()) } else { None }).collect()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /assets/icons/palette.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/icons/layout-dashboard.svg: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /crates/settings/keymaps/linux-windows.toml: -------------------------------------------------------------------------------- 1 | [editor] 2 | backspace = "editor::Backspace" 3 | delete = "editor::Delete" 4 | enter = "editor::Enter" 5 | up = "editor::Up" 6 | down = "editor::Down" 7 | left = "editor::Left" 8 | right = "editor::Right" 9 | shift-left = "editor::SelectLeft" 10 | shift-right = "editor::SelectRight" 11 | shift-up = "editor::SelectUp" 12 | shift-down = "editor::SelectDown" 13 | home = "editor::Home" 14 | end = "editor::End" 15 | shift-home = "editor::SelectToStartOfLine" 16 | shift-end = "editor::SelectToEndOfLine" 17 | ctrl-a = "editor::SelectAll" 18 | ctrl-c = "editor::Copy" 19 | ctrl-x = "editor::Cut" 20 | ctrl-v = "editor::Paste" 21 | ctrl-z = "editor::Undo" 22 | ctrl-y = "editor::Redo" 23 | -------------------------------------------------------------------------------- /assets/icons/resize-corner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Christian Bergschneider 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE -------------------------------------------------------------------------------- /crates/settings/src/files.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | 4 | fn get_user_settings_dir() -> Option { 5 | dirs::config_dir().map(|mut p| { 6 | p.push("glyph"); 7 | p 8 | }) 9 | } 10 | 11 | /// Assume the location is not writable 12 | fn get_system_settings_dir() -> Option { 13 | #[cfg(target_os = "linux")] 14 | return Some(PathBuf::from("/etc/glyph")); 15 | #[cfg(target_os = "macos")] 16 | return None; // TODO figure this out 17 | #[cfg(target_os = "windows")] 18 | return Some(PathBuf::from("C:/ProgramData/glyph")); 19 | } 20 | 21 | pub fn get_settings_file_locations(kind: &str) -> Vec { 22 | let mut path_options = vec![]; 23 | if let Some(mut p) = std::env::current_dir().ok() { 24 | p.push(kind); 25 | path_options.push(p); 26 | } 27 | 28 | if let Some(mut p) = get_user_settings_dir() { 29 | p.push(kind); 30 | path_options.push(p); 31 | } 32 | 33 | if let Some(mut p) = get_system_settings_dir() { 34 | p.push(kind); 35 | path_options.push(p); 36 | } 37 | 38 | path_options.iter().filter(|p| p.exists()).cloned().collect() 39 | } -------------------------------------------------------------------------------- /assets/icons/window-minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /crates/glyph/src/ui.rs: -------------------------------------------------------------------------------- 1 | use components::{ActiveTheme, Root, TitleBar}; 2 | use editor::editor::Editor; 3 | use gpui::{div, img, px, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window}; 4 | 5 | pub struct RootView { 6 | editor: Entity, 7 | } 8 | 9 | impl RootView { 10 | pub fn new(window: &mut Window, cx: &mut Context) -> Self { 11 | Self { 12 | editor: cx.new(|cx| Editor::new(window, cx)), 13 | } 14 | } 15 | } 16 | 17 | impl Render for RootView { 18 | fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { 19 | let theme = cx.theme().clone(); 20 | let notification_layer = Root::render_notification_layer(window, cx); 21 | 22 | let title_bar = TitleBar::new() 23 | .child( 24 | div() 25 | .flex() 26 | .gap_1() 27 | .w_20() 28 | .child( 29 | img("brand/icon.svg") 30 | .size_6() 31 | ) 32 | .child("Glyph") 33 | ); 34 | 35 | div() 36 | .h_full() 37 | .bg(theme.background) 38 | .text_color(theme.foreground) 39 | .font_family("Montserrat") 40 | .child(title_bar) 41 | .child( 42 | div() 43 | .flex() 44 | .justify_center() 45 | .h_full() 46 | .py_16() 47 | .px_16() 48 | .child( 49 | div() 50 | .max_w(px(1000f32)) 51 | .flex_1() 52 | .h_full() 53 | .child(self.editor.clone()) 54 | ) 55 | ) 56 | .child(div().absolute().top_8().children(notification_layer).debug()) 57 | } 58 | } -------------------------------------------------------------------------------- /crates/editor/src/markdown.rs: -------------------------------------------------------------------------------- 1 | mod skipmap; 2 | mod inline; 3 | 4 | use crate::markdown::inline::preshape_inline; 5 | use components::ActiveTheme; 6 | use gpui::{App, Pixels, SharedString, Window, WrappedLine}; 7 | use smallvec::SmallVec; 8 | use std::ops::Range; 9 | 10 | pub(crate) fn intersects(a: &Range, b: &Range) -> bool { 11 | a.start < b.end && a.end > b.start 12 | } 13 | 14 | fn shape_line(line: &str, line_range: Range, wrap_width: Pixels, selection: Range, window: &mut Window, cx: &mut App) -> SmallVec<[WrappedLine; 1]> { 15 | let relative_selection = (selection.start as i32 - line_range.start as i32)..(selection.end as i32 - line_range.start as i32); 16 | let text_style = window.text_style(); 17 | let font_size = text_style.font_size.to_pixels(window.rem_size()); 18 | let pre = preshape_inline(line, &text_style, &(cx.theme().foreground.clone()), relative_selection, window, cx); 19 | pre.shape(window, wrap_width, font_size) 20 | } 21 | 22 | pub(crate) fn shape_markdown(raw: SharedString, wrap_width: Pixels, selection: Range, window: &mut Window, cx: &mut App) -> SmallVec<[WrappedLine; 1]> { 23 | let mut index = 0; 24 | let mut lines = SmallVec::new(); 25 | 26 | while index < raw.len() { 27 | let start = index; 28 | let end = raw[index..].find('\n').map(|i| index + i).unwrap_or(raw.len()); 29 | let range = start..end; 30 | let line = &raw[range.clone()]; 31 | let line = if intersects(&range, &selection) { 32 | shape_line(line, range, wrap_width, selection.clone(), window, cx) // TODO shape with selection support selection 33 | } else { 34 | shape_line(line, range, wrap_width, selection.clone(), window, cx) 35 | }; 36 | lines.extend(line); 37 | index = end + 1; 38 | } 39 | 40 | lines 41 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![logo](.github/readme_icon.svg) Glyph Notes 2 | 3 | [![Discord](https://img.shields.io/discord/1209253136112549918?style=for-the-badge)](https://discord.gg/2y6HqtvBqW) 4 | [![GPUI](https://img.shields.io/badge/Built%20With-GPUI-blue?style=for-the-badge)](https://gpui.rs) 5 | 6 | _A Note-taking app for the 21st century_ 7 | 8 | ## Table of Contents 9 | 10 | - [Status](#status) 11 | - [Contributing](#contributing) 12 | - [License](#license) 13 | - [Acknowledgements](#acknowledgements) 14 | 15 | ## Status 16 | 17 | This project is currently in development. Feel free to contribute! 18 | Since I don't have a lot of time right now, don't expect frequent updates. 19 | 20 | ## Contributing 21 | 22 | All contributions are welcome! Please open an issue or a pull request. 23 | If you'd like to contribute on a larger scale, please chat with us on [Discord](https://discord.gg/2y6HqtvBqW) or open 24 | an issue, so we can discuss the best way to move forward. 25 | 26 | ## License 27 | 28 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 29 | 30 | GPUI is licensed under the Apache License, Version 2.0 - see 31 | their [LICENSE](https://github.com/zed-industries/zed/blob/main/LICENSE-APACHE) file for details. 32 | gpui-component is licensed under the Apache License, Version 2.0 - see 33 | their [LICENSE](https://github.com/longbridge/gpui-component/blob/main/LICENSE-APACHE) file for details. 34 | Icons are provided by [Lucide](https://lucide.dev/) and are licensed under the ISC License See 35 | their [LICENSE](https://lucide.dev/license) file for details. 36 | 37 | ## Acknowledgements 38 | 39 | - Thanks to the GPUI team for creating such an amazing UI framework and helping me out with my questions. 40 | - Thanks to the Lucide team for providing the icons. 41 | - Thanks to Longbridge for creating the GPUI Component library. -------------------------------------------------------------------------------- /assets/icons/window-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /crates/editor/src/cursor.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | use gpui::{Context, Timer}; 3 | 4 | static INTERVAL: Duration = Duration::from_millis(500); 5 | static PAUSE_DELAY: Duration = Duration::from_millis(300); 6 | 7 | pub(crate) struct Cursor { 8 | visible: bool, 9 | paused: bool, 10 | epoch: usize, 11 | } 12 | 13 | impl Cursor { 14 | pub fn new() -> Self { 15 | Self { 16 | visible: false, 17 | paused: false, 18 | epoch: 0, 19 | } 20 | } 21 | 22 | pub fn start(&mut self, cx: &mut Context) { 23 | self.blink(self.epoch, cx); 24 | } 25 | 26 | pub fn stop(&mut self, cx: &mut Context) { 27 | self.epoch = 0; 28 | cx.notify(); 29 | } 30 | 31 | fn next_epoch(&mut self) -> usize { 32 | self.epoch += 1; 33 | self.epoch 34 | } 35 | 36 | fn blink(&mut self, epoch: usize, cx: &mut Context) { 37 | if self.paused || epoch != self.epoch { 38 | return; 39 | } 40 | 41 | self.visible = !self.visible; 42 | cx.notify(); 43 | 44 | // Schedule the next blink 45 | let epoch = self.next_epoch(); 46 | cx.spawn(|this, mut cx| async move { 47 | Timer::after(INTERVAL).await; 48 | if let Some(this) = this.upgrade() { 49 | this.update(&mut cx, |this, cx| this.blink(epoch, cx)).ok(); 50 | } 51 | }) 52 | .detach(); 53 | } 54 | 55 | pub fn visible(&self) -> bool { 56 | // Keep showing the cursor if paused 57 | self.paused || self.visible 58 | } 59 | 60 | /// Pause the blinking, and delay 500ms to resume the blinking. 61 | pub fn pause(&mut self, cx: &mut Context) { 62 | self.paused = true; 63 | cx.notify(); 64 | 65 | // delay 500ms to start the blinking 66 | let epoch = self.next_epoch(); 67 | cx.spawn(|this, mut cx| async move { 68 | Timer::after(PAUSE_DELAY).await; 69 | 70 | if let Some(this) = this.upgrade() { 71 | this.update(&mut cx, |this, cx| { 72 | this.paused = false; 73 | this.blink(epoch, cx); 74 | }) 75 | .ok(); 76 | } 77 | }) 78 | .detach(); 79 | } 80 | } -------------------------------------------------------------------------------- /crates/glyph/src/main.rs: -------------------------------------------------------------------------------- 1 | mod assets; 2 | mod ui; 3 | mod fonts; 4 | 5 | use components::{hsl, Root, Theme, ThemeColor, ThemeMode}; 6 | use gpui::{AnyView, App, AppContext, Timer, TitlebarOptions, WindowOptions}; 7 | use log::info; 8 | use crate::ui::RootView; 9 | 10 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 11 | const DEV: bool = cfg!(debug_assertions); 12 | 13 | fn init_logging() { 14 | pretty_env_logger::formatted_builder() 15 | .format_timestamp(None) 16 | .filter_level(log::LevelFilter::Info) 17 | .filter_module("gpui", log::LevelFilter::Warn) 18 | .filter_module("blade_graphics", log::LevelFilter::Warn) 19 | .filter_module("naga", log::LevelFilter::Warn) 20 | .init(); 21 | } 22 | 23 | fn init_theme(cx: &mut App) { 24 | let mut theme = Theme::from(ThemeColor::dark()); 25 | theme.mode = ThemeMode::Dark; 26 | theme.accent = hsl(231.0, 97.0, 72.0); 27 | theme.title_bar = hsl(248.0, 12.0, 8.0); 28 | theme.background = hsl(248.0, 12.0, 8.0); 29 | 30 | cx.set_global(theme); 31 | cx.refresh_windows(); 32 | } 33 | 34 | fn main() { 35 | init_logging(); 36 | 37 | info!("Welcome to Glyph"); 38 | 39 | gpui::Application::new() 40 | .with_assets(assets::Assets) 41 | .run( 42 | |cx| { 43 | components::init(cx); 44 | init_theme(cx); 45 | editor::init(cx); 46 | fonts::init(cx); 47 | 48 | cx.open_window( 49 | WindowOptions { 50 | titlebar: Some(TitlebarOptions { 51 | title: Some(format!("Glyph Notes {}{}", VERSION, if DEV { "-dev" } else { "" }).into()), 52 | ..Default::default() 53 | }), 54 | ..Default::default() 55 | }, 56 | |window, cx| cx.new(|cx| { 57 | window.spawn(cx, |mut wnd| async move { 58 | Timer::after(std::time::Duration::from_millis(100)).await; 59 | let _ = wnd.update(|window, cx| { 60 | settings::init(window, cx); 61 | }); 62 | }).detach(); 63 | 64 | let root = cx.new(|cx| RootView::new(window, cx)); 65 | Root::new(AnyView::from(root), window, cx) 66 | }), 67 | ) 68 | .expect("failed to open window"); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /crates/editor/src/markdown/skipmap.rs: -------------------------------------------------------------------------------- 1 | /// The Skipmap is a list of indices where single characters are removed from the original text. 2 | /// This is used to map the shaped text back to the original text. 3 | /// For example, if you'd want to figure out the skip offset for the 5th shaped character, you'd 4 | /// count all the skipmap entries that are less or equal than 5. 5 | /// 6 | /// Example: 7 | /// Original text: 8 | /// "Hi *Dave*!" -> 9 | /// "Hi Dave!" (see how the '*' characters are removed) 10 | /// 11 | /// Skipmap: [4, 9] 12 | /// 13 | /// So to get the original text index for the 5th shaped character: 14 | /// 5 + count_skipmap_entries(5) = 5 + 1 = 6 15 | /// 16 | /// Or for the 8th shaped character: 17 | /// 8 + count_skipmap_entries(8) = 8 + 1 + count_skipmap_entries(4, 9) = 9 + 1 = 10 18 | /// 19 | /// As you can see, when you reach a skipmap entry, you increment the skipmap limit by 1. 20 | pub struct SkipMap { 21 | map: Vec, 22 | } 23 | 24 | impl SkipMap { 25 | pub fn new() -> Self { 26 | Self { map: Vec::new() } 27 | } 28 | 29 | pub fn add(&mut self, index: usize) { 30 | self.map.push(index); 31 | } 32 | 33 | pub fn count_skipmap_entries(&self, mut bound: usize) -> usize { 34 | let mut count = 0; 35 | for &skip in &self.map { 36 | if skip <= bound { 37 | count += 1; 38 | bound += 1; 39 | } else { 40 | break; 41 | } 42 | } 43 | 44 | count 45 | } 46 | 47 | pub fn original_index(&self, index: usize) -> usize { 48 | index + self.count_skipmap_entries(index) 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::SkipMap; 55 | 56 | #[test] 57 | fn test_skipmap_new() { 58 | let skipmap = SkipMap::new(); 59 | assert!(skipmap.map.is_empty()); 60 | } 61 | 62 | #[test] 63 | fn test_add_to_skipmap() { 64 | let mut skipmap = SkipMap::new(); 65 | skipmap.add(4); 66 | skipmap.add(9); 67 | assert_eq!(skipmap.map, vec![4, 9]); 68 | } 69 | 70 | #[test] 71 | fn test_count_skipmap_entries() { 72 | let mut skipmap = SkipMap::new(); 73 | skipmap.add(4); 74 | skipmap.add(9); 75 | assert_eq!(skipmap.count_skipmap_entries(3), 0); 76 | assert_eq!(skipmap.count_skipmap_entries(4), 1); 77 | assert_eq!(skipmap.count_skipmap_entries(5), 1); 78 | assert_eq!(skipmap.count_skipmap_entries(9), 2); 79 | assert_eq!(skipmap.count_skipmap_entries(10), 2); 80 | } 81 | 82 | #[test] 83 | fn test_original_index() { 84 | let mut skipmap = SkipMap::new(); 85 | skipmap.add(4); 86 | skipmap.add(9); 87 | assert_eq!(skipmap.original_index(3), 3); // No skips 88 | assert_eq!(skipmap.original_index(4), 5); // One skip 89 | assert_eq!(skipmap.original_index(8), 10); // Two skips 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /assets/icons/window-maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /crates/settings/src/keymap.rs: -------------------------------------------------------------------------------- 1 | use crate::files::get_settings_file_locations; 2 | use anyhow::Result; 3 | use components::notification::Notification; 4 | use components::ContextModal; 5 | use gpui::{App, KeyBinding, KeyBindingContextPredicate}; 6 | 7 | #[cfg(any(target_os = "linux", target_os = "windows"))] 8 | pub const DEFAULT_KEYMAP: &str = include_str!("../keymaps/linux-windows.toml"); 9 | 10 | #[cfg(not(any(target_os = "linux", target_os = "windows")))] 11 | compile_error!("Unsupported OS, please open an issue on GitHub"); 12 | 13 | fn build_keybinding(cx: &mut App, key: String, action: String, context: Option) -> Result { 14 | let action = cx.build_action(&action, None)?; // TODO handle data on actions 15 | let context_predicate = if let Some(context) = context { 16 | Some(KeyBindingContextPredicate::parse(&context)?.into()) 17 | } else { 18 | None 19 | }; 20 | Ok(KeyBinding::load(&key, action, context_predicate, None)?) 21 | } 22 | 23 | fn load_contextualized(window: &mut gpui::Window, cx: &mut App, context: &str, table: &toml::Table) { 24 | let mut items = vec![]; 25 | 26 | for (key, value) in table { 27 | if value.is_str() { 28 | let action = value.as_str().unwrap(); 29 | let binding = build_keybinding(cx, key.to_string(), action.to_string(), Some(context.to_string())); 30 | if let Ok(binding) = binding { 31 | items.push(binding); 32 | } else { 33 | window.push_notification( 34 | Notification::error(&format!("Failed to build contextualized keybinding for key {}: {}", key, binding.err().unwrap())) 35 | .autohide(true), 36 | cx, 37 | ); 38 | } 39 | } 40 | } 41 | 42 | cx.bind_keys(items); 43 | } 44 | 45 | fn load_keymap(window: &mut gpui::Window, cx: &mut App, keymap: &str) { 46 | let Ok(table) = toml::from_str::(keymap) else { 47 | window.push_notification( 48 | Notification::error("Failed to parse keymap file") 49 | .autohide(true), 50 | cx, 51 | ); 52 | return; 53 | }; 54 | 55 | for (key, value) in &table { 56 | if value.is_table() { 57 | load_contextualized(window, cx, &key, value.as_table().unwrap()); 58 | } else if value.is_str() { 59 | let action = value.as_str().unwrap(); 60 | let binding = build_keybinding(cx, key.to_string(), action.to_string(), None); 61 | if let Ok(binding) = binding { 62 | cx.bind_keys(vec![binding]); 63 | } else { 64 | window.push_notification( 65 | Notification::error(&format!("Failed to build uncontextualized keybinding for key {}: {}", key, binding.err().unwrap())) 66 | .autohide(true), 67 | cx, 68 | ); 69 | } 70 | } else { 71 | window.push_notification( 72 | Notification::error(&format!("Invalid keybinding: {}", key)) 73 | .autohide(true), 74 | cx, 75 | ); 76 | } 77 | } 78 | } 79 | 80 | pub fn reload_keymap_tree(window: &mut gpui::Window, cx: &mut App, announce: bool) { 81 | cx.clear_key_bindings(); 82 | load_keymap(window, cx, DEFAULT_KEYMAP); 83 | 84 | get_settings_file_locations("keymap.toml").iter().for_each(|path| { 85 | if let Ok(keymap) = std::fs::read_to_string(path) { 86 | load_keymap(window, cx, &keymap); 87 | } 88 | }); 89 | 90 | if announce { 91 | window.push_notification( 92 | Notification::info("Keymap reloaded") 93 | .autohide(true), 94 | cx, 95 | ); 96 | } 97 | } 98 | 99 | pub fn init(window: &mut gpui::Window, cx: &mut App) { 100 | reload_keymap_tree(window, cx, false); 101 | } 102 | 103 | -------------------------------------------------------------------------------- /assets/icons/window-restore.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/brand/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /crates/editor/src/markdown/inline.rs: -------------------------------------------------------------------------------- 1 | use crate::markdown::skipmap::SkipMap; 2 | use gpui::{px, App, Hsla, Pixels, SharedString, StrikethroughStyle, TextRun, TextStyle, UnderlineStyle, Window, WrappedLine}; 3 | use std::ops::Range; 4 | use smallvec::SmallVec; 5 | 6 | pub(crate) struct PreshapedInlineText { 7 | reduced_text: SharedString, 8 | skip_map: SkipMap, 9 | runs: Vec, 10 | } 11 | 12 | impl PreshapedInlineText { 13 | pub(crate) fn shape(&self, window: &mut Window, wrap_width: Pixels, font_size: Pixels) -> SmallVec<[WrappedLine; 1]> { 14 | window 15 | .text_system() 16 | .shape_text(self.reduced_text.clone(), font_size, &self.runs, Some(wrap_width), None) 17 | .unwrap() 18 | } 19 | } 20 | 21 | struct PreshapingEngine { 22 | runs: Vec, 23 | style: TextStyle, 24 | color: Hsla, 25 | skip_map: SkipMap, 26 | run_start: usize, 27 | is_bold: bool, 28 | is_italic: bool, 29 | is_strikethrough: bool, 30 | is_underline: bool, 31 | is_code: bool, 32 | } 33 | 34 | impl PreshapingEngine { 35 | fn new(text_style: TextStyle, text_color: Hsla) -> Self { 36 | Self { 37 | runs: vec![], 38 | skip_map: SkipMap::new(), 39 | style: text_style, 40 | color: text_color, 41 | run_start: 0, 42 | is_bold: false, 43 | is_italic: false, 44 | is_strikethrough: false, 45 | is_underline: false, 46 | is_code: false, 47 | } 48 | } 49 | 50 | fn flush_run(&mut self, end: usize) { 51 | let mut font = self.style.font(); 52 | 53 | if self.is_bold { 54 | font = font.bold(); 55 | } 56 | 57 | if self.is_italic { 58 | font = font.italic(); 59 | } 60 | 61 | let run = TextRun { 62 | len: end - self.run_start, 63 | font, 64 | color: self.color.clone(), 65 | background_color: None, 66 | underline: if self.is_underline { 67 | Some(UnderlineStyle { 68 | thickness: px(1.0), 69 | color: Some(self.color.clone()), 70 | wavy: false, 71 | }) 72 | } else { None }, 73 | strikethrough: if self.is_strikethrough { 74 | Some(StrikethroughStyle { 75 | thickness: px(1.0), 76 | color: Some(self.color.clone()), 77 | }) 78 | } else { None }, 79 | }; 80 | self.runs.push(run); 81 | self.run_start = end; 82 | } 83 | 84 | fn toggle_bold(&mut self, end: usize) { 85 | self.flush_run(end); 86 | self.is_bold = !self.is_bold; 87 | } 88 | fn toggle_italic(&mut self, end: usize) { 89 | self.flush_run(end); 90 | self.is_italic = !self.is_italic; 91 | } 92 | fn toggle_strikethrough(&mut self, end: usize) { 93 | self.flush_run(end); 94 | self.is_strikethrough = !self.is_strikethrough; 95 | } 96 | fn toggle_underline(&mut self, end: usize) { 97 | self.flush_run(end); 98 | self.is_underline = !self.is_underline; 99 | } 100 | fn toggle_code(&mut self, end: usize) { 101 | self.flush_run(end); 102 | self.is_code = !self.is_code; 103 | } 104 | } 105 | 106 | pub(crate) fn preshape_inline(line: &str, text_style: &TextStyle, text_color: &Hsla, _sel: Range, _: &mut Window, _: &mut App) -> PreshapedInlineText { 107 | let mut engine = PreshapingEngine::new(text_style.clone(), text_color.clone()); 108 | let mut iter = line.char_indices().peekable(); 109 | let mut reduced_text = String::new(); 110 | while let Some((index, char)) = iter.next() { 111 | match char { 112 | '_' => { 113 | if let Some((_, next)) = iter.peek() { 114 | if *next == '_' { 115 | engine.toggle_underline(index); 116 | iter.next(); 117 | } else { 118 | engine.toggle_italic(index); 119 | } 120 | } 121 | } 122 | '*' => { 123 | if let Some((_, next)) = iter.peek() { 124 | if *next == '*' { 125 | engine.toggle_bold(index); 126 | iter.next(); 127 | } else { 128 | engine.toggle_italic(index); 129 | } 130 | } 131 | } 132 | '~' => { 133 | engine.toggle_strikethrough(index); 134 | } 135 | '`' => { 136 | engine.toggle_code(index); 137 | } 138 | _ => reduced_text.push(char) 139 | } 140 | } 141 | 142 | engine.flush_run(line.len()); 143 | 144 | let PreshapingEngine { runs, skip_map, .. } = engine; 145 | 146 | PreshapedInlineText { 147 | runs, 148 | skip_map, 149 | reduced_text: line.to_string().into(), 150 | } 151 | } -------------------------------------------------------------------------------- /crates/editor/src/actions.rs: -------------------------------------------------------------------------------- 1 | use gpui::{actions, KeyBinding}; 2 | 3 | actions!( 4 | editor, 5 | [ 6 | Backspace, 7 | Delete, 8 | DeleteToBeginningOfLine, 9 | DeleteToEndOfLine, 10 | Enter, 11 | Up, 12 | Down, 13 | Left, 14 | Right, 15 | SelectUp, 16 | SelectDown, 17 | SelectLeft, 18 | SelectRight, 19 | SelectAll, 20 | Home, 21 | End, 22 | SelectToStartOfLine, 23 | SelectToEndOfLine, 24 | SelectToStart, 25 | SelectToEnd, 26 | ShowCharacterPalette, 27 | Copy, 28 | Cut, 29 | Paste, 30 | Undo, 31 | Redo, 32 | MoveToStartOfLine, 33 | MoveToEndOfLine, 34 | MoveToStart, 35 | MoveToEnd, 36 | TextChanged, 37 | ] 38 | ); 39 | 40 | pub const CONTEXT: &str = "editor"; 41 | 42 | pub(crate) fn init(cx: &mut gpui::App) { 43 | cx.bind_keys([ 44 | KeyBinding::new("backspace", Backspace, Some(CONTEXT)), 45 | KeyBinding::new("delete", Delete, Some(CONTEXT)), 46 | #[cfg(target_os = "macos")] 47 | KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)), 48 | #[cfg(target_os = "macos")] 49 | KeyBinding::new("cmd-delete", DeleteToEndOfLine, Some(CONTEXT)), 50 | KeyBinding::new("enter", Enter, Some(CONTEXT)), 51 | KeyBinding::new("up", Up, Some(CONTEXT)), 52 | KeyBinding::new("down", Down, Some(CONTEXT)), 53 | KeyBinding::new("left", Left, Some(CONTEXT)), 54 | KeyBinding::new("right", Right, Some(CONTEXT)), 55 | KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), 56 | KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), 57 | KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), 58 | KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), 59 | KeyBinding::new("home", Home, Some(CONTEXT)), 60 | KeyBinding::new("end", End, Some(CONTEXT)), 61 | KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), 62 | KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), 63 | #[cfg(target_os = "macos")] 64 | KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)), 65 | #[cfg(target_os = "macos")] 66 | KeyBinding::new("ctrl-shift-e", SelectToEndOfLine, Some(CONTEXT)), 67 | #[cfg(target_os = "macos")] 68 | KeyBinding::new("shift-cmd-left", SelectToStartOfLine, Some(CONTEXT)), 69 | #[cfg(target_os = "macos")] 70 | KeyBinding::new("shift-cmd-right", SelectToEndOfLine, Some(CONTEXT)), 71 | #[cfg(target_os = "macos")] 72 | KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)), 73 | #[cfg(target_os = "macos")] 74 | KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)), 75 | #[cfg(not(target_os = "macos"))] 76 | KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)), 77 | #[cfg(target_os = "macos")] 78 | KeyBinding::new("cmd-c", Copy, Some(CONTEXT)), 79 | #[cfg(not(target_os = "macos"))] 80 | KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)), 81 | #[cfg(target_os = "macos")] 82 | KeyBinding::new("cmd-x", Cut, Some(CONTEXT)), 83 | #[cfg(not(target_os = "macos"))] 84 | KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)), 85 | #[cfg(target_os = "macos")] 86 | KeyBinding::new("cmd-v", Paste, Some(CONTEXT)), 87 | #[cfg(not(target_os = "macos"))] 88 | KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)), 89 | #[cfg(target_os = "macos")] 90 | KeyBinding::new("ctrl-a", Home, Some(CONTEXT)), 91 | #[cfg(target_os = "macos")] 92 | KeyBinding::new("cmd-left", Home, Some(CONTEXT)), 93 | #[cfg(target_os = "macos")] 94 | KeyBinding::new("ctrl-e", End, Some(CONTEXT)), 95 | #[cfg(target_os = "macos")] 96 | KeyBinding::new("cmd-right", End, Some(CONTEXT)), 97 | #[cfg(target_os = "macos")] 98 | KeyBinding::new("cmd-z", Undo, Some(CONTEXT)), 99 | #[cfg(target_os = "macos")] 100 | KeyBinding::new("cmd-shift-z", Redo, Some(CONTEXT)), 101 | #[cfg(target_os = "macos")] 102 | KeyBinding::new("cmd-up", MoveToStart, Some(CONTEXT)), 103 | #[cfg(target_os = "macos")] 104 | KeyBinding::new("cmd-down", MoveToEnd, Some(CONTEXT)), 105 | #[cfg(target_os = "macos")] 106 | KeyBinding::new("cmd-shift-up", SelectToStart, Some(CONTEXT)), 107 | #[cfg(target_os = "macos")] 108 | KeyBinding::new("cmd-shift-down", SelectToEnd, Some(CONTEXT)), 109 | #[cfg(not(target_os = "macos"))] 110 | KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)), 111 | #[cfg(not(target_os = "macos"))] 112 | KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)) 113 | ]); 114 | } -------------------------------------------------------------------------------- /assets/fonts/montserrat/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | christian.bergschneider(at)gmx.de. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /.github/readme_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /assets/brand/menubar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /legal/LICENSE-GPUI-COMPONENTS: -------------------------------------------------------------------------------- 1 | Copyright 2024 Longbridge 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | 16 | Apache License 17 | Version 2.0, January 2004 18 | http://www.apache.org/licenses/ 19 | 20 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 21 | 22 | 1. Definitions. 23 | 24 | "License" shall mean the terms and conditions for use, reproduction, 25 | and distribution as defined by Sections 1 through 9 of this document. 26 | 27 | "Licensor" shall mean the copyright owner or entity authorized by 28 | the copyright owner that is granting the License. 29 | 30 | "Legal Entity" shall mean the union of the acting entity and all 31 | other entities that control, are controlled by, or are under common 32 | control with that entity. For the purposes of this definition, 33 | "control" means (i) the power, direct or indirect, to cause the 34 | direction or management of such entity, whether by contract or 35 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 36 | outstanding shares, or (iii) beneficial ownership of such entity. 37 | 38 | "You" (or "Your") shall mean an individual or Legal Entity 39 | exercising permissions granted by this License. 40 | 41 | "Source" form shall mean the preferred form for making modifications, 42 | including but not limited to software source code, documentation 43 | source, and configuration files. 44 | 45 | "Object" form shall mean any form resulting from mechanical 46 | transformation or translation of a Source form, including but 47 | not limited to compiled object code, generated documentation, 48 | and conversions to other media types. 49 | 50 | "Work" shall mean the work of authorship, whether in Source or 51 | Object form, made available under the License, as indicated by a 52 | copyright notice that is included in or attached to the work 53 | (an example is provided in the Appendix below). 54 | 55 | "Derivative Works" shall mean any work, whether in Source or Object 56 | form, that is based on (or derived from) the Work and for which the 57 | editorial revisions, annotations, elaborations, or other modifications 58 | represent, as a whole, an original work of authorship. For the purposes 59 | of this License, Derivative Works shall not include works that remain 60 | separable from, or merely link (or bind by name) to the interfaces of, 61 | the Work and Derivative Works thereof. 62 | 63 | "Contribution" shall mean any work of authorship, including 64 | the original version of the Work and any modifications or additions 65 | to that Work or Derivative Works thereof, that is intentionally 66 | submitted to Licensor for inclusion in the Work by the copyright owner 67 | or by an individual or Legal Entity authorized to submit on behalf of 68 | the copyright owner. For the purposes of this definition, "submitted" 69 | means any form of electronic, verbal, or written communication sent 70 | to the Licensor or its representatives, including but not limited to 71 | communication on electronic mailing lists, source code control systems, 72 | and issue tracking systems that are managed by, or on behalf of, the 73 | Licensor for the purpose of discussing and improving the Work, but 74 | excluding communication that is conspicuously marked or otherwise 75 | designated in writing by the copyright owner as "Not a Contribution." 76 | 77 | "Contributor" shall mean Licensor and any individual or Legal Entity 78 | on behalf of whom a Contribution has been received by Licensor and 79 | subsequently incorporated within the Work. 80 | 81 | 2. Grant of Copyright License. Subject to the terms and conditions of 82 | this License, each Contributor hereby grants to You a perpetual, 83 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 84 | copyright license to reproduce, prepare Derivative Works of, 85 | publicly display, publicly perform, sublicense, and distribute the 86 | Work and such Derivative Works in Source or Object form. 87 | 88 | 3. Grant of Patent License. Subject to the terms and conditions of 89 | this License, each Contributor hereby grants to You a perpetual, 90 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 91 | (except as stated in this section) patent license to make, have made, 92 | use, offer to sell, sell, import, and otherwise transfer the Work, 93 | where such license applies only to those patent claims licensable 94 | by such Contributor that are necessarily infringed by their 95 | Contribution(s) alone or by combination of their Contribution(s) 96 | with the Work to which such Contribution(s) was submitted. If You 97 | institute patent litigation against any entity (including a 98 | cross-claim or counterclaim in a lawsuit) alleging that the Work 99 | or a Contribution incorporated within the Work constitutes direct 100 | or contributory patent infringement, then any patent licenses 101 | granted to You under this License for that Work shall terminate 102 | as of the date such litigation is filed. 103 | 104 | 4. Redistribution. You may reproduce and distribute copies of the 105 | Work or Derivative Works thereof in any medium, with or without 106 | modifications, and in Source or Object form, provided that You 107 | meet the following conditions: 108 | 109 | (a) You must give any other recipients of the Work or 110 | Derivative Works a copy of this License; and 111 | 112 | (b) You must cause any modified files to carry prominent notices 113 | stating that You changed the files; and 114 | 115 | (c) You must retain, in the Source form of any Derivative Works 116 | that You distribute, all copyright, patent, trademark, and 117 | attribution notices from the Source form of the Work, 118 | excluding those notices that do not pertain to any part of 119 | the Derivative Works; and 120 | 121 | (d) If the Work includes a "NOTICE" text file as part of its 122 | distribution, then any Derivative Works that You distribute must 123 | include a readable copy of the attribution notices contained 124 | within such NOTICE file, excluding those notices that do not 125 | pertain to any part of the Derivative Works, in at least one 126 | of the following places: within a NOTICE text file distributed 127 | as part of the Derivative Works; within the Source form or 128 | documentation, if provided along with the Derivative Works; or, 129 | within a display generated by the Derivative Works, if and 130 | wherever such third-party notices normally appear. The contents 131 | of the NOTICE file are for informational purposes only and 132 | do not modify the License. You may add Your own attribution 133 | notices within Derivative Works that You distribute, alongside 134 | or as an addendum to the NOTICE text from the Work, provided 135 | that such additional attribution notices cannot be construed 136 | as modifying the License. 137 | 138 | You may add Your own copyright statement to Your modifications and 139 | may provide additional or different license terms and conditions 140 | for use, reproduction, or distribution of Your modifications, or 141 | for any such Derivative Works as a whole, provided Your use, 142 | reproduction, and distribution of the Work otherwise complies with 143 | the conditions stated in this License. 144 | 145 | 5. Submission of Contributions. Unless You explicitly state otherwise, 146 | any Contribution intentionally submitted for inclusion in the Work 147 | by You to the Licensor shall be under the terms and conditions of 148 | this License, without any additional terms or conditions. 149 | Notwithstanding the above, nothing herein shall supersede or modify 150 | the terms of any separate license agreement you may have executed 151 | with Licensor regarding such Contributions. 152 | 153 | 6. Trademarks. This License does not grant permission to use the trade 154 | names, trademarks, service marks, or product names of the Licensor, 155 | except as required for reasonable and customary use in describing the 156 | origin of the Work and reproducing the content of the NOTICE file. 157 | 158 | 7. Disclaimer of Warranty. Unless required by applicable law or 159 | agreed to in writing, Licensor provides the Work (and each 160 | Contributor provides its Contributions) on an "AS IS" BASIS, 161 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 162 | implied, including, without limitation, any warranties or conditions 163 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 164 | PARTICULAR PURPOSE. You are solely responsible for determining the 165 | appropriateness of using or redistributing the Work and assume any 166 | risks associated with Your exercise of permissions under this License. 167 | 168 | 8. Limitation of Liability. In no event and under no legal theory, 169 | whether in tort (including negligence), contract, or otherwise, 170 | unless required by applicable law (such as deliberate and grossly 171 | negligent acts) or agreed to in writing, shall any Contributor be 172 | liable to You for damages, including any direct, indirect, special, 173 | incidental, or consequential damages of any character arising as a 174 | result of this License or out of the use or inability to use the 175 | Work (including but not limited to damages for loss of goodwill, 176 | work stoppage, computer failure or malfunction, or any and all 177 | other commercial damages or losses), even if such Contributor 178 | has been advised of the possibility of such damages. 179 | 180 | 9. Accepting Warranty or Additional Liability. While redistributing 181 | the Work or Derivative Works thereof, You may choose to offer, 182 | and charge a fee for, acceptance of support, warranty, indemnity, 183 | or other liability obligations and/or rights consistent with this 184 | License. However, in accepting such obligations, You may act only 185 | on Your own behalf and on Your sole responsibility, not on behalf 186 | of any other Contributor, and only if You agree to indemnify, 187 | defend, and hold each Contributor harmless for any liability 188 | incurred by, or claims asserted against, such Contributor by reason 189 | of your accepting any such warranty or additional liability. 190 | 191 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /crates/editor/src/element.rs: -------------------------------------------------------------------------------- 1 | use crate::editor::Editor; 2 | use crate::markdown::shape_markdown; 3 | use components::ActiveTheme; 4 | use gpui::{fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels, Point, Style, Window, WrappedLine}; 5 | use smallvec::SmallVec; 6 | 7 | const RIGHT_MARGIN: Pixels = px(5.); 8 | const BOTTOM_MARGIN: Pixels = px(20.); 9 | 10 | 11 | pub(crate) struct EditorElement { 12 | editor: Entity, 13 | } 14 | 15 | impl EditorElement { 16 | pub(crate) fn new(editor: Entity) -> Self { 17 | Self { editor } 18 | } 19 | 20 | fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { 21 | window.on_mouse_event({ 22 | let editor = self.editor.clone(); 23 | 24 | move |event: &MouseMoveEvent, _, window, cx| { 25 | if event.pressed_button == Some(MouseButton::Left) { 26 | editor.update(cx, |input, cx| { 27 | input.on_drag_move(event, window, cx); 28 | }); 29 | } 30 | } 31 | }); 32 | } 33 | 34 | fn layout_cursor( 35 | &self, 36 | lines: &[WrappedLine], 37 | line_height: Pixels, 38 | bounds: &mut Bounds, 39 | window: &mut Window, 40 | cx: &mut App, 41 | ) -> (Option, Point) { 42 | let editor = self.editor.read(cx); 43 | let selected_range = &editor.selected_range; 44 | let cursor_offset = editor.cursor_offset(); 45 | let mut scroll_offset = editor.scroll_handle.offset(); 46 | let mut cursor = None; 47 | 48 | // The cursor corresponds to the current cursor position in the text no only the line. 49 | let mut cursor_pos = None; 50 | let mut cursor_start = None; 51 | let mut cursor_end = None; 52 | 53 | let mut prev_lines_offset = 0; 54 | let mut offset_y = px(0.); 55 | for line in lines.iter() { 56 | // break loop if all cursor positions are found 57 | if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() { 58 | break; 59 | } 60 | 61 | let line_origin = point(px(0.), offset_y); 62 | if cursor_pos.is_none() { 63 | let offset = cursor_offset.saturating_sub(prev_lines_offset); 64 | if let Some(pos) = line.position_for_index(offset, line_height) { 65 | cursor_pos = Some(line_origin + pos); 66 | } 67 | } 68 | if cursor_start.is_none() { 69 | let offset = selected_range.start.saturating_sub(prev_lines_offset); 70 | if let Some(pos) = line.position_for_index(offset, line_height) { 71 | cursor_start = Some(line_origin + pos); 72 | } 73 | } 74 | if cursor_end.is_none() { 75 | let offset = selected_range.end.saturating_sub(prev_lines_offset); 76 | if let Some(pos) = line.position_for_index(offset, line_height) { 77 | cursor_end = Some(line_origin + pos); 78 | } 79 | } 80 | 81 | offset_y += line.size(line_height).height; 82 | // +1 for skip the last `\n` 83 | prev_lines_offset += line.len() + 1; 84 | } 85 | 86 | if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) = 87 | (cursor_pos, cursor_start, cursor_end) 88 | { 89 | let cursor_moved = editor.last_cursor_offset != Some(cursor_offset); 90 | let selection_changed = editor.last_selected_range != Some(selected_range.clone()); 91 | 92 | if cursor_moved || selection_changed { 93 | scroll_offset.x = 94 | if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) { 95 | // cursor is out of right 96 | bounds.size.width - RIGHT_MARGIN - cursor_pos.x 97 | } else if scroll_offset.x + cursor_pos.x < px(0.) { 98 | // cursor is out of left 99 | scroll_offset.x - cursor_pos.x 100 | } else { 101 | scroll_offset.x 102 | }; 103 | scroll_offset.y = 104 | if scroll_offset.y + cursor_pos.y > (bounds.size.height - BOTTOM_MARGIN) { 105 | // cursor is out of bottom 106 | bounds.size.height - BOTTOM_MARGIN - cursor_pos.y 107 | } else if scroll_offset.y + cursor_pos.y < px(0.) { 108 | // cursor is out of top 109 | scroll_offset.y - cursor_pos.y 110 | } else { 111 | scroll_offset.y 112 | }; 113 | 114 | if editor.selection_reversed { 115 | if scroll_offset.x + cursor_start.x < px(0.) { 116 | // selection start is out of left 117 | scroll_offset.x = -cursor_start.x; 118 | } 119 | if scroll_offset.y + cursor_start.y < px(0.) { 120 | // selection start is out of top 121 | scroll_offset.y = -cursor_start.y; 122 | } 123 | } else { 124 | if scroll_offset.x + cursor_end.x <= px(0.) { 125 | // selection end is out of left 126 | scroll_offset.x = -cursor_end.x; 127 | } 128 | if scroll_offset.y + cursor_end.y <= px(0.) { 129 | // selection end is out of top 130 | scroll_offset.y = -cursor_end.y; 131 | } 132 | } 133 | } 134 | 135 | bounds.origin = bounds.origin + scroll_offset; 136 | 137 | if editor.show_cursor(window, cx) { 138 | // cursor blink 139 | let cursor_height = 140 | window.text_style().font_size.to_pixels(window.rem_size()) + px(2.); 141 | cursor = Some(fill( 142 | Bounds::new( 143 | point( 144 | bounds.left() + cursor_pos.x, 145 | bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), 146 | ), 147 | size(px(1.), cursor_height), 148 | ), 149 | cx.theme().caret, 150 | )) 151 | }; 152 | } 153 | 154 | (cursor, scroll_offset) 155 | } 156 | 157 | 158 | fn layout_selections( 159 | &self, 160 | lines: &[WrappedLine], 161 | line_height: Pixels, 162 | bounds: &mut Bounds, 163 | _: &mut Window, 164 | cx: &mut App, 165 | ) -> Option> { 166 | let editor = self.editor.read(cx); 167 | let selected_range = &editor.selected_range; 168 | if selected_range.is_empty() { 169 | return None; 170 | } 171 | 172 | let (start_ix, end_ix) = if selected_range.start < selected_range.end { 173 | (selected_range.start, selected_range.end) 174 | } else { 175 | (selected_range.end, selected_range.start) 176 | }; 177 | 178 | let mut prev_lines_offset = 0; 179 | let mut line_corners = vec![]; 180 | 181 | let mut offset_y = px(0.); 182 | for line in lines.iter() { 183 | let line_size = line.size(line_height); 184 | let line_wrap_width = line_size.width; 185 | 186 | let line_origin = point(px(0.), offset_y); 187 | 188 | let line_cursor_start = 189 | line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height); 190 | let line_cursor_end = 191 | line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height); 192 | 193 | if line_cursor_start.is_some() || line_cursor_end.is_some() { 194 | let start = line_cursor_start 195 | .unwrap_or_else(|| line.position_for_index(0, line_height).unwrap()); 196 | 197 | let end = line_cursor_end 198 | .unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap()); 199 | 200 | // Split the selection into multiple items 201 | let wrapped_lines = 202 | (end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize; 203 | 204 | let mut end_x = end.x; 205 | if wrapped_lines > 0 { 206 | end_x = line_wrap_width; 207 | } 208 | 209 | line_corners.push(Corners { 210 | top_left: line_origin + point(start.x, start.y), 211 | top_right: line_origin + point(end_x, start.y), 212 | bottom_left: line_origin + point(start.x, start.y + line_height), 213 | bottom_right: line_origin + point(end_x, start.y + line_height), 214 | }); 215 | 216 | // wrapped lines 217 | for i in 1..=wrapped_lines { 218 | let start = point(px(0.), start.y + i as f32 * line_height); 219 | let mut end = point(end.x, end.y + i as f32 * line_height); 220 | if i < wrapped_lines { 221 | end.x = line_size.width; 222 | } 223 | 224 | line_corners.push(Corners { 225 | top_left: line_origin + point(start.x, start.y), 226 | top_right: line_origin + point(end.x, start.y), 227 | bottom_left: line_origin + point(start.x, start.y + line_height), 228 | bottom_right: line_origin + point(end.x, start.y + line_height), 229 | }); 230 | } 231 | } 232 | 233 | if line_cursor_start.is_some() && line_cursor_end.is_some() { 234 | break; 235 | } 236 | 237 | offset_y += line_size.height; 238 | // +1 for skip the last `\n` 239 | prev_lines_offset += line.len() + 1; 240 | } 241 | 242 | let mut points = vec![]; 243 | if line_corners.is_empty() { 244 | return None; 245 | } 246 | 247 | // Fix corners to make sure the left to right direction 248 | for corners in &mut line_corners { 249 | if corners.top_left.x > corners.top_right.x { 250 | std::mem::swap(&mut corners.top_left, &mut corners.top_right); 251 | std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right); 252 | } 253 | } 254 | 255 | for corners in &line_corners { 256 | points.push(corners.top_right); 257 | points.push(corners.bottom_right); 258 | points.push(corners.bottom_left); 259 | } 260 | 261 | let mut rev_line_corners = line_corners.iter().rev().peekable(); 262 | while let Some(corners) = rev_line_corners.next() { 263 | points.push(corners.top_left); 264 | if let Some(next) = rev_line_corners.peek() { 265 | if next.top_left.x > corners.top_left.x { 266 | points.push(point(next.top_left.x, corners.top_left.y)); 267 | } 268 | } 269 | } 270 | 271 | let first_p = *points.get(0).unwrap(); 272 | let mut builder = gpui::PathBuilder::fill(); 273 | builder.move_to(bounds.origin + first_p); 274 | for p in points.iter().skip(1) { 275 | builder.line_to(bounds.origin + *p); 276 | } 277 | 278 | builder.build().ok() 279 | } 280 | } 281 | 282 | pub(crate) struct PrepaintState { 283 | lines: SmallVec<[WrappedLine; 1]>, 284 | cursor: Option, 285 | cursor_scroll_offset: Point, 286 | selection_path: Option>, 287 | bounds: Bounds, 288 | } 289 | 290 | impl IntoElement for EditorElement { 291 | type Element = Self; 292 | 293 | fn into_element(self) -> Self::Element { 294 | self 295 | } 296 | } 297 | 298 | impl Element for EditorElement { 299 | type RequestLayoutState = (); 300 | type PrepaintState = PrepaintState; 301 | 302 | fn id(&self) -> Option { 303 | None 304 | } 305 | 306 | fn request_layout(&mut self, _: Option<&GlobalElementId>, window: &mut Window, cx: &mut App) -> (LayoutId, Self::RequestLayoutState) { 307 | let mut style = Style::default(); 308 | style.size.width = relative(1f32).into(); 309 | style.size.height = relative(1f32).into(); 310 | 311 | (window.request_layout(style, [], cx), ()) 312 | } 313 | 314 | fn prepaint(&mut self, _: Option<&GlobalElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App) -> Self::PrepaintState { 315 | let line_height = window.line_height(); 316 | let editor = self.editor.read(cx); 317 | let display_text = editor.text.clone(); 318 | let mut bounds = bounds; 319 | 320 | let lines = shape_markdown( 321 | display_text, 322 | bounds.size.width - RIGHT_MARGIN, 323 | self.editor.read(cx).selected_range.clone(), 324 | window, 325 | cx 326 | ); 327 | 328 | // Calculate the scroll offset to keep the cursor in view 329 | let (cursor, cursor_scroll_offset) = 330 | self.layout_cursor(&lines, line_height, &mut bounds, window, cx); 331 | 332 | let selection_path = self.layout_selections(&lines, line_height, &mut bounds, window, cx); 333 | 334 | PrepaintState { 335 | bounds, 336 | lines, 337 | cursor, 338 | cursor_scroll_offset, 339 | selection_path, 340 | } 341 | } 342 | 343 | fn paint(&mut self, _: Option<&GlobalElementId>, input_bounds: Bounds, _: &mut Self::RequestLayoutState, prepaint: &mut Self::PrepaintState, window: &mut Window, cx: &mut App) { 344 | let focus_handle = self.editor.read(cx).focus_handle.clone(); 345 | let focused = focus_handle.is_focused(window); 346 | let bounds = prepaint.bounds; 347 | let selected_range = self.editor.read(cx).selected_range.clone(); 348 | 349 | window.handle_input( 350 | &focus_handle, 351 | ElementInputHandler::new(bounds, self.editor.clone()), 352 | cx 353 | ); 354 | 355 | if let Some(path) = prepaint.selection_path.take() { 356 | window.paint_path(path, cx.theme().selection); 357 | } 358 | 359 | // Paint multi line text 360 | let line_height = window.line_height(); 361 | let origin = bounds.origin; 362 | 363 | let mut offset_y = px(0.); 364 | for line in prepaint.lines.iter() { 365 | let p = point(origin.x, origin.y + offset_y); 366 | _ = line.paint(p, line_height, window, cx); 367 | offset_y += line.size(line_height).height; 368 | } 369 | 370 | if focused { 371 | if let Some(cursor) = prepaint.cursor.take() { 372 | window.paint_quad(cursor); 373 | } 374 | } 375 | 376 | let width = prepaint 377 | .lines 378 | .iter() 379 | .map(|l| l.width()) 380 | .max() 381 | .unwrap_or_default(); 382 | let height = prepaint 383 | .lines 384 | .iter() 385 | .map(|l| l.size(line_height).height.0) 386 | .sum::(); 387 | 388 | let scroll_size = size(width, px(height)); 389 | 390 | self.editor.update(cx, |input, _cx| { 391 | input.last_layout = Some(prepaint.lines.clone()); 392 | input.last_bounds = Some(bounds); 393 | input.last_cursor_offset = Some(input.cursor_offset()); 394 | input.last_line_height = line_height; 395 | input.input_bounds = input_bounds; 396 | input.last_selected_range = Some(selected_range); 397 | input 398 | .scroll_handle 399 | .set_offset(prepaint.cursor_scroll_offset); 400 | input.scroll_size = scroll_size; 401 | }); 402 | 403 | self.paint_mouse_listeners(window, cx); 404 | } 405 | } 406 | 407 | -------------------------------------------------------------------------------- /crates/editor/src/editor.rs: -------------------------------------------------------------------------------- 1 | use crate::actions; 2 | use components::{Sizable, StyleSized}; 3 | use gpui::{div, point, px, App, AppContext, Bounds, Context, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Rems, Render, ScrollHandle, SharedString, Size, Styled, UTF16Selection, ViewInputHandler, Window, WrappedLine}; 4 | use std::ops::Range; 5 | use smallvec::SmallVec; 6 | use unicode_segmentation::UnicodeSegmentation; 7 | use crate::cursor::Cursor; 8 | use crate::element::EditorElement; 9 | 10 | const LINE_HEIGHT: Rems = Rems(1.5); 11 | 12 | pub enum EditorEvent { 13 | TextChanged(SharedString), 14 | PressEnter, 15 | Focus, 16 | Blur, 17 | } 18 | 19 | pub struct Editor { 20 | pub(crate) focus_handle: FocusHandle, 21 | pub(crate) text: SharedString, 22 | pub(crate) selected_range: Range, 23 | pub(crate) size: components::Size, 24 | pub(crate) scroll_handle: ScrollHandle, 25 | pub(super) last_bounds: Option>, 26 | pub(super) last_selected_range: Option>, 27 | pub(super) last_cursor_offset: Option, 28 | pub(super) selection_reversed: bool, 29 | pub(super) last_layout: Option>, 30 | pub(super) last_line_height: Pixels, 31 | pub(super) input_bounds: Bounds, 32 | pub(super) scroll_size: Size, 33 | pub(super) marked_range: Option>, 34 | pub(super) preferred_x_offset: Option, 35 | pub(super) selected_word_range: Option>, 36 | pub(super) is_selecting: bool, 37 | pub(super) cursor: Entity, 38 | } 39 | 40 | impl EventEmitter for Editor {} 41 | 42 | impl Editor { 43 | pub fn new(window: &mut Window, cx: &mut Context) -> Self { 44 | let focus_handle = cx.focus_handle(); 45 | // Auto-acquire focus when the editor is created 46 | focus_handle.focus(window); 47 | let editor = Self { 48 | focus_handle, 49 | text: include_str!("../../../example.md").into(), 50 | selected_range: 0..0, 51 | size: components::Size::Medium, 52 | scroll_handle: ScrollHandle::default(), 53 | last_bounds: None, 54 | last_selected_range: None, 55 | last_cursor_offset: None, 56 | selection_reversed: false, 57 | last_layout: None, 58 | last_line_height: px(20.0), 59 | input_bounds: Bounds::default(), 60 | scroll_size: Size::default(), 61 | marked_range: None, 62 | preferred_x_offset: None, 63 | selected_word_range: None, 64 | is_selecting: false, 65 | cursor: cx.new(|_| Cursor::new()), 66 | }; 67 | 68 | // Redraw the cursor when it blinks 69 | cx.observe(&editor.cursor, |_, _, cx| cx.notify()).detach(); 70 | cx.observe_window_activation(window, |editor, window, cx| { 71 | if window.is_window_active() { 72 | let focus_handle = editor.focus_handle.clone(); 73 | if focus_handle.is_focused(window) { 74 | editor.cursor.update(cx, |blink_cursor, cx| { 75 | blink_cursor.start(cx); 76 | }); 77 | } 78 | } 79 | }) 80 | .detach(); 81 | 82 | editor 83 | } 84 | //#region utf16-Utilities 85 | fn next_boundary(&self, offset: usize) -> usize { 86 | self.text 87 | .grapheme_indices(true) 88 | .find_map(|(idx, _)| (idx > offset).then_some(idx)) 89 | .unwrap_or(self.text.len()) 90 | } 91 | 92 | fn prev_boundary(&self, offset: usize) -> usize { 93 | if offset == 0 { 94 | return 0; 95 | } 96 | 97 | self.text 98 | .grapheme_indices(true) 99 | .rev() 100 | .find_map(|(idx, _)| (idx < offset).then_some(idx)) 101 | .unwrap_or(self.text.len()) 102 | } 103 | 104 | fn offset_from_utf16(&self, offset: usize) -> usize { 105 | let mut utf8_offset = 0; 106 | let mut utf16_count = 0; 107 | 108 | for ch in self.text.chars() { 109 | if utf16_count >= offset { 110 | break; 111 | } 112 | utf16_count += ch.len_utf16(); 113 | utf8_offset += ch.len_utf8(); 114 | } 115 | 116 | utf8_offset 117 | } 118 | 119 | fn offset_to_utf16(&self, offset: usize) -> usize { 120 | let mut utf16_offset = 0; 121 | let mut utf8_count = 0; 122 | 123 | for ch in self.text.chars() { 124 | if utf8_count >= offset { 125 | break; 126 | } 127 | utf8_count += ch.len_utf8(); 128 | utf16_offset += ch.len_utf16(); 129 | } 130 | 131 | utf16_offset 132 | } 133 | 134 | fn range_to_utf16(&self, range: &Range) -> Range { 135 | self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) 136 | } 137 | 138 | fn range_from_utf16(&self, range_utf16: &Range) -> Range { 139 | self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) 140 | } 141 | //#endregion 142 | 143 | //#region Actions 144 | fn right(&mut self, _: &actions::Right, window: &mut Window, cx: &mut Context) { 145 | if self.selected_range.is_empty() { 146 | self.move_to(self.next_boundary(self.selected_range.end), window, cx); 147 | } else { 148 | self.move_to(self.selected_range.end, window, cx) 149 | } 150 | } 151 | 152 | fn left(&mut self, _: &actions::Left, window: &mut Window, cx: &mut Context) { 153 | if self.selected_range.is_empty() { 154 | self.move_to(self.prev_boundary(self.selected_range.end), window, cx); 155 | } else { 156 | self.move_to(self.selected_range.start, window, cx) 157 | } 158 | } 159 | 160 | fn up(&mut self, _: &actions::Up, window: &mut Window, cx: &mut Context) { 161 | self.pause_blink_cursor(cx); 162 | self.move_vertical(-1, window, cx); 163 | } 164 | 165 | fn down(&mut self, _: &actions::Down, window: &mut Window, cx: &mut Context) { 166 | self.pause_blink_cursor(cx); 167 | self.move_vertical(1, window, cx); 168 | } 169 | 170 | fn select_right(&mut self, _: &actions::SelectRight, window: &mut Window, cx: &mut Context) { 171 | self.select_to(self.next_boundary(self.cursor_offset()), window, cx); 172 | } 173 | 174 | fn select_left(&mut self, _: &actions::SelectLeft, window: &mut Window, cx: &mut Context) { 175 | self.select_to(self.prev_boundary(self.cursor_offset()), window, cx); 176 | } 177 | 178 | fn select_up(&mut self, _: &actions::SelectUp, window: &mut Window, cx: &mut Context) { 179 | let offset = self.start_of_line(window, cx).saturating_sub(1); 180 | self.select_to(offset, window, cx); 181 | } 182 | 183 | fn select_down(&mut self, _: &actions::SelectDown, window: &mut Window, cx: &mut Context) { 184 | let offset = (self.end_of_line(window, cx) + 1).min(self.text.len()); 185 | self.select_to(self.next_boundary(offset), window, cx); 186 | } 187 | 188 | fn enter(&mut self, _: &actions::Enter, window: &mut Window, cx: &mut Context) { 189 | let is_eof = self.selected_range.end == self.text.len(); 190 | self.replace_text_in_range(None, "\n", window, cx); 191 | 192 | // Move cursor to the start of the next line 193 | let mut new_offset = self.next_boundary(self.cursor_offset()) - 1; 194 | if is_eof { 195 | new_offset += 1; 196 | } 197 | self.move_to(new_offset, window, cx); 198 | } 199 | 200 | pub(crate) fn on_drag_move(&mut self, event: &MouseMoveEvent, window: &mut Window, cx: &mut Context) { 201 | if self.text.is_empty() { 202 | return; 203 | } 204 | 205 | if self.last_layout.is_none() { 206 | return; 207 | } 208 | 209 | if !self.focus_handle.is_focused(window) { 210 | return; 211 | } 212 | 213 | if !self.is_selecting { 214 | return; 215 | } 216 | 217 | let offset = self.index_for_mouse_position(event.position, window, cx); 218 | self.select_to(offset, window, cx); 219 | self.pause_blink_cursor(cx); 220 | } 221 | 222 | fn line_origin_with_y_offset(&self, y_offset: &mut Pixels, line: &WrappedLine, line_height: Pixels) -> Point { 223 | let p = point(px(0.), *y_offset); 224 | let height = line_height + line.wrap_boundaries.len() as f32 * line_height; 225 | *y_offset = *y_offset + height; 226 | p 227 | } 228 | 229 | fn index_for_mouse_position(&self, position: Point, _window: &Window, _cx: &App) -> usize { 230 | // If the text is empty, always return 0 231 | if self.text.is_empty() { 232 | return 0; 233 | } 234 | 235 | let (Some(bounds), Some(lines)) = (self.last_bounds.as_ref(), self.last_layout.as_ref()) 236 | else { 237 | return 0; 238 | }; 239 | 240 | let line_height = self.last_line_height; 241 | 242 | let inner_position = position - bounds.origin; 243 | 244 | let mut index = 0; 245 | let mut y_offset = px(0.); 246 | 247 | for line in lines.iter() { 248 | let line_origin = self.line_origin_with_y_offset(&mut y_offset, &line, line_height); 249 | let pos = inner_position - line_origin; 250 | 251 | let index_result = line.index_for_position(pos, line_height); 252 | if let Ok(v) = index_result { 253 | index += v; 254 | break; 255 | } else if let Ok(_) = line.index_for_position(point(px(0.), pos.y), line_height) { 256 | // Click in the this line but not in the text, move cursor to the end of the line. 257 | // The fallback index is saved in Err from `index_for_position` method. 258 | index += index_result.unwrap_err(); 259 | break; 260 | } else if line.len() == 0 { 261 | // empty line 262 | let line_bounds = Bounds { 263 | origin: line_origin, 264 | size: gpui::size(bounds.size.width, line_height), 265 | }; 266 | let pos = inner_position; 267 | if line_bounds.contains(&pos) { 268 | break; 269 | } 270 | } else { 271 | index += line.len(); 272 | } 273 | 274 | // add 1 for \n 275 | index += 1; 276 | } 277 | 278 | if index > self.text.len() { 279 | self.text.len() 280 | } else { 281 | index 282 | } 283 | } 284 | 285 | fn on_mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { 286 | self.is_selecting = true; 287 | let offset = self.index_for_mouse_position(event.position, window, cx); 288 | // Double click to select word 289 | if event.button == MouseButton::Left && event.click_count == 2 { 290 | self.select_word(offset, window, cx); 291 | return; 292 | } 293 | 294 | if event.modifiers.shift { 295 | self.select_to(offset, window, cx); 296 | } else { 297 | self.move_to(offset, window, cx) 298 | } 299 | } 300 | 301 | fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _cx: &mut Context) { 302 | self.is_selecting = false; 303 | self.selected_word_range = None; 304 | } 305 | //#endregion 306 | 307 | pub(super) fn cursor_offset(&self) -> usize { 308 | if self.selection_reversed { 309 | self.selected_range.start 310 | } else { 311 | self.selected_range.end 312 | } 313 | } 314 | 315 | fn pause_blink_cursor(&mut self, cx: &mut Context) { 316 | self.cursor.update(cx, |cursor, cx| cursor.pause(cx)); 317 | } 318 | 319 | pub(crate) fn show_cursor(&self, _: &Window, cx: &App) -> bool { 320 | self.cursor.read(cx).visible() 321 | } 322 | 323 | fn move_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { 324 | self.selected_range = offset..offset; 325 | self.pause_blink_cursor(cx); 326 | self.update_preferred_x_offset(cx); 327 | cx.notify(); 328 | } 329 | 330 | fn select_word(&mut self, offset: usize, window: &mut Window, cx: &mut Context) { 331 | fn is_word(c: char) -> bool { 332 | c.is_alphanumeric() || matches!(c, '_') 333 | } 334 | 335 | let mut start = self.offset_to_utf16(offset); 336 | let mut end = start; 337 | let prev_text = self 338 | .text_for_range(0..start, &mut None, window, cx) 339 | .unwrap_or_default(); 340 | let next_text = self 341 | .text_for_range(end..self.text.len(), &mut None, window, cx) 342 | .unwrap_or_default(); 343 | 344 | let prev_chars = prev_text.chars().rev().peekable(); 345 | let next_chars = next_text.chars().peekable(); 346 | 347 | for (_, c) in prev_chars.enumerate() { 348 | if !is_word(c) { 349 | break; 350 | } 351 | 352 | start -= c.len_utf16(); 353 | } 354 | 355 | for (_, c) in next_chars.enumerate() { 356 | if !is_word(c) { 357 | break; 358 | } 359 | 360 | end += c.len_utf16(); 361 | } 362 | 363 | self.selected_range = self.range_from_utf16(&(start..end)); 364 | self.selected_word_range = Some(self.selected_range.clone()); 365 | cx.notify() 366 | } 367 | 368 | fn select_to(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { 369 | if self.selection_reversed { 370 | self.selected_range.start = offset 371 | } else { 372 | self.selected_range.end = offset 373 | }; 374 | 375 | if self.selected_range.end < self.selected_range.start { 376 | self.selection_reversed = !self.selection_reversed; 377 | self.selected_range = self.selected_range.end..self.selected_range.start; 378 | } 379 | 380 | // Ensure keep word selected range 381 | if let Some(word_range) = self.selected_word_range.as_ref() { 382 | if self.selected_range.start > word_range.start { 383 | self.selected_range.start = word_range.start; 384 | } 385 | if self.selected_range.end < word_range.end { 386 | self.selected_range.end = word_range.end; 387 | } 388 | } 389 | if self.selected_range.is_empty() { 390 | self.update_preferred_x_offset(cx); 391 | } 392 | cx.notify() 393 | } 394 | 395 | fn update_preferred_x_offset(&mut self, _: &mut Context) { 396 | if let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) { 397 | let offset = self.cursor_offset(); 398 | let line_height = self.last_line_height; 399 | 400 | // Find which line and sub-line the cursor is on and its position 401 | let (_line_index, _sub_line_index, cursor_pos) = 402 | self.line_and_position_for_offset(offset, lines, line_height); 403 | 404 | if let Some(pos) = cursor_pos { 405 | // Adjust by scroll offset 406 | let scroll_offset = bounds.origin; 407 | self.preferred_x_offset = Some(pos.x + scroll_offset.x); 408 | } 409 | } 410 | } 411 | 412 | fn move_vertical(&mut self, direction: i32, window: &mut Window, cx: &mut Context) { 413 | let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { return; }; 414 | 415 | let offset = self.cursor_offset(); 416 | let line_height = self.last_line_height; 417 | let (current_line_index, current_sub_line, current_pos) = 418 | self.line_and_position_for_offset(offset, lines, line_height); 419 | 420 | let Some(current_pos) = current_pos else { 421 | return; 422 | }; 423 | 424 | let current_x = self 425 | .preferred_x_offset 426 | .unwrap_or_else(|| current_pos.x + bounds.origin.x); 427 | 428 | let mut new_line_index = current_line_index; 429 | let mut new_sub_line = current_sub_line as i32; 430 | 431 | new_sub_line += direction; 432 | 433 | // Handle moving above the first line 434 | if direction == -1 && new_line_index == 0 && new_sub_line < 0 { 435 | // Move cursor to the beginning of the text 436 | self.move_to(0, window, cx); 437 | return; 438 | } 439 | 440 | if new_sub_line < 0 { 441 | if new_line_index > 0 { 442 | new_line_index -= 1; 443 | new_sub_line = lines[new_line_index].wrap_boundaries.len() as i32; 444 | } else { 445 | new_sub_line = 0; 446 | } 447 | } else { 448 | let max_sub_line = lines[new_line_index].wrap_boundaries.len() as i32; 449 | if new_sub_line > max_sub_line { 450 | if new_line_index < lines.len() - 1 { 451 | new_line_index += 1; 452 | new_sub_line = 0; 453 | } else { 454 | new_sub_line = max_sub_line; 455 | } 456 | } 457 | } 458 | 459 | // If after adjustment, still at the same position, do not proceed 460 | if new_line_index == current_line_index && new_sub_line == current_sub_line { 461 | return; 462 | } 463 | 464 | let target_line: &WrappedLine = &lines[new_line_index]; 465 | let line_x = current_x - bounds.origin.x; 466 | let target_sub_line = new_sub_line as usize; 467 | 468 | let approx_pos = point(line_x, px(target_sub_line as f32 * line_height.0)); 469 | let index_res = target_line.index_for_position(approx_pos, line_height); 470 | 471 | let new_local_index = match index_res { 472 | Ok(i) => i + 1, 473 | Err(i) => i, 474 | }; 475 | 476 | let mut prev_lines_offset = 0; 477 | for (i, l) in lines.iter().enumerate() { 478 | if i == new_line_index { 479 | break; 480 | } 481 | prev_lines_offset += l.len() + 1; 482 | } 483 | 484 | let new_offset = (prev_lines_offset + new_local_index).min(self.text.len()); 485 | self.selected_range = new_offset..new_offset; 486 | self.pause_blink_cursor(cx); 487 | cx.notify(); 488 | } 489 | 490 | fn start_of_line(&mut self, window: &mut Window, cx: &mut Context) -> usize { 491 | let offset = self.prev_boundary(self.cursor_offset()); 492 | let line = self 493 | .text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) 494 | .unwrap_or_default() 495 | .rfind('\n') 496 | .map(|i| i + 1) 497 | .unwrap_or(0); 498 | line 499 | } 500 | 501 | /// Get end of line 502 | fn end_of_line(&mut self, window: &mut Window, cx: &mut Context) -> usize { 503 | let offset = self.next_boundary(self.cursor_offset()); 504 | // ignore if offset is "\n" 505 | if self 506 | .text_for_range( 507 | self.range_to_utf16(&(offset - 1..offset)), 508 | &mut None, 509 | window, 510 | cx, 511 | ) 512 | .unwrap_or_default() 513 | .eq("\n") 514 | { 515 | return offset; 516 | } 517 | 518 | let line = self 519 | .text_for_range( 520 | self.range_to_utf16(&(offset..self.text.len())), 521 | &mut None, 522 | window, 523 | cx, 524 | ) 525 | .unwrap_or_default() 526 | .find('\n') 527 | .map(|i| i + offset) 528 | .unwrap_or(self.text.len()); 529 | line 530 | } 531 | 532 | fn line_and_position_for_offset(&self, offset: usize, lines: &[WrappedLine], line_height: Pixels) -> (usize, i32, Option>) { 533 | let mut prev_lines_offset = 0; 534 | let mut y_offset = px(0.); 535 | for (line_index, line) in lines.iter().enumerate() { 536 | let local_offset = offset.saturating_sub(prev_lines_offset); 537 | if let Some(pos) = line.position_for_index(local_offset, line_height) { 538 | let sub_line_index = (pos.y.0 / line_height.0) as usize; 539 | let adjusted_pos = point(pos.x, pos.y + y_offset); 540 | return (line_index, sub_line_index as i32, Some(adjusted_pos)); 541 | } 542 | 543 | y_offset += line.size(line_height).height; 544 | prev_lines_offset += line.len() + 1; 545 | } 546 | (0, 0, None) 547 | } 548 | } 549 | 550 | impl Focusable for Editor { 551 | fn focus_handle(&self, _: &App) -> FocusHandle { 552 | self.focus_handle.clone() 553 | } 554 | } 555 | 556 | impl Sizable for Editor { 557 | fn with_size(mut self, size: impl Into) -> Self { 558 | self.size = size.into(); 559 | self 560 | } 561 | } 562 | 563 | impl ViewInputHandler for Editor { 564 | fn text_for_range(&mut self, range_utf16: Range, adjusted_range: &mut Option>, _: &mut Window, _: &mut Context) -> Option { 565 | let range = self.range_from_utf16(&range_utf16); 566 | adjusted_range.replace(self.range_to_utf16(&range)); 567 | Some(self.text[range].to_string()) 568 | } 569 | 570 | fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context) -> Option { 571 | Some(UTF16Selection { 572 | range: self.range_to_utf16(&self.selected_range), 573 | reversed: false, 574 | }) 575 | } 576 | 577 | fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { 578 | self.marked_range 579 | .as_ref() 580 | .map(|range| self.range_to_utf16(range)) 581 | } 582 | 583 | fn unmark_text(&mut self, _: &mut Window, _: &mut Context) { 584 | self.marked_range = None; 585 | } 586 | 587 | fn replace_text_in_range(&mut self, range_utf16: Option>, new_text: &str, _: &mut Window, cx: &mut Context) { 588 | let range = range_utf16 589 | .as_ref() 590 | .map(|range_utf16| self.range_from_utf16(range_utf16)) 591 | .or(self.marked_range.clone()) 592 | .unwrap_or(self.selected_range.clone()); 593 | 594 | let pending_text: SharedString = 595 | (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); 596 | 597 | // self.push_history(&range, new_text, window, cx); TODO 598 | self.text = pending_text; 599 | self.selected_range = range.start + new_text.len()..range.start + new_text.len(); 600 | self.marked_range.take(); 601 | self.update_preferred_x_offset(cx); 602 | cx.emit(EditorEvent::TextChanged(self.text.clone())); 603 | cx.notify(); 604 | } 605 | 606 | fn replace_and_mark_text_in_range(&mut self, range_utf16: Option>, new_text: &str, new_selected_range_utf16: Option>, _: &mut Window, cx: &mut Context) { 607 | let range = range_utf16 608 | .as_ref() 609 | .map(|range_utf16| self.range_from_utf16(range_utf16)) 610 | .or(self.marked_range.clone()) 611 | .unwrap_or(self.selected_range.clone()); 612 | let pending_text: SharedString = 613 | (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); 614 | 615 | // self.push_history(&range, new_text, window, cx); 616 | self.text = pending_text; 617 | self.marked_range = Some(range.start..range.start + new_text.len()); 618 | self.selected_range = new_selected_range_utf16 619 | .as_ref() 620 | .map(|range_utf16| self.range_from_utf16(range_utf16)) 621 | .map(|new_range| new_range.start + range.start..new_range.end + range.end) 622 | .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); 623 | cx.emit(EditorEvent::TextChanged(self.text.clone())); 624 | cx.notify(); 625 | } 626 | 627 | fn bounds_for_range(&mut self, range_utf16: Range, element_bounds: Bounds, _: &mut Window, _: &mut Context) -> Option> { 628 | let line_height = self.last_line_height; 629 | let lines = self.last_layout.as_ref()?; 630 | let range = self.range_from_utf16(&range_utf16); 631 | 632 | let mut start_origin = None; 633 | let mut end_origin = None; 634 | let mut y_offset = px(0.); 635 | let mut index_offset = 0; 636 | 637 | for line in lines.iter() { 638 | if let Some(p) = line.position_for_index(range.start - index_offset, line_height) { 639 | start_origin = Some(p + point(px(0.), y_offset)); 640 | } 641 | if let Some(p) = line.position_for_index(range.end - index_offset, line_height) { 642 | end_origin = Some(p + point(px(0.), y_offset)); 643 | } 644 | 645 | y_offset += line.size(line_height).height; 646 | if start_origin.is_some() && end_origin.is_some() { 647 | break; 648 | } 649 | 650 | index_offset += line.len(); 651 | } 652 | 653 | Some(Bounds::from_corners( 654 | element_bounds.origin + start_origin.unwrap_or_default(), 655 | element_bounds.origin + end_origin.unwrap_or_default(), 656 | )) 657 | } 658 | } 659 | 660 | impl Render for Editor { 661 | fn render(&mut self, _: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { 662 | div() 663 | .flex() 664 | .flex_col() 665 | .id("editor") 666 | .key_context(actions::CONTEXT) 667 | .track_focus(&self.focus_handle) 668 | .cursor(CursorStyle::IBeam) 669 | .on_action(cx.listener(Self::right)) 670 | .on_action(cx.listener(Self::left)) 671 | .on_action(cx.listener(Self::up)) 672 | .on_action(cx.listener(Self::down)) 673 | .on_action(cx.listener(Self::select_right)) 674 | .on_action(cx.listener(Self::select_left)) 675 | .on_action(cx.listener(Self::select_up)) 676 | .on_action(cx.listener(Self::select_down)) 677 | .on_action(cx.listener(Self::enter)) 678 | .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) 679 | .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) 680 | .size_full() 681 | .line_height(LINE_HEIGHT) 682 | .input_py(self.size) 683 | .items_center() 684 | .child( 685 | div() 686 | .id("editor-content") 687 | .flex_grow() 688 | .overflow_x_hidden() 689 | .child(EditorElement::new(cx.model().clone())), 690 | ) 691 | } 692 | } 693 | --------------------------------------------------------------------------------