├── .github └── workflows │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE.md ├── demo.png ├── readme.md ├── rust-toolchain.toml └── src ├── app.rs ├── components ├── editor_panel.rs ├── editor_scroll_view.rs ├── icons.rs ├── mod.rs ├── overlay.rs ├── sidepanel.rs ├── status_bar.rs ├── tab.rs └── text_area.rs ├── constants.rs ├── fs ├── interface.rs ├── local.rs └── mod.rs ├── global_defaults.rs ├── hooks ├── mod.rs ├── use_computed.rs ├── use_debounce.rs ├── use_edit.rs └── use_lsp_status.rs ├── icons ├── logo_disabled.svg └── logo_enabled.svg ├── lsp ├── client.rs └── mod.rs ├── main.rs ├── metrics.rs ├── parser.rs ├── settings.rs ├── state ├── app.rs ├── commands.rs ├── editor.rs ├── keyboard_shortcuts.rs ├── mod.rs ├── panels_tabs.rs ├── settings.rs └── views.rs ├── utils.rs └── views ├── commander ├── commander_ui.rs └── mod.rs ├── file_explorer ├── file_explorer_state.rs ├── file_explorer_ui.rs └── mod.rs ├── mod.rs ├── panels ├── mod.rs └── tabs │ ├── editor │ ├── commands.rs │ ├── editor_data.rs │ ├── editor_line.rs │ ├── editor_tab.rs │ ├── editor_ui.rs │ ├── hover_box.rs │ ├── mod.rs │ └── utils.rs │ ├── mod.rs │ ├── settings.rs │ └── welcome.rs └── search ├── mod.rs └── search_ui.rs /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - v[0-9]+.* 10 | 11 | jobs: 12 | create-release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: taiki-e/create-gh-release-action@v1 17 | with: 18 | draft: true 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | upload-assets: 22 | needs: create-release 23 | strategy: 24 | matrix: 25 | include: 26 | - target: x86_64-unknown-linux-gnu 27 | os: ubuntu-22.04 28 | - target: x86_64-apple-darwin 29 | os: macos-latest 30 | - target: x86_64-pc-windows-msvc 31 | os: windows-2022 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Install linux dependencies 36 | if: runner.os == 'Linux' 37 | run: | 38 | sudo apt update && sudo apt install build-essential libssl-dev pkg-config libglib2.0-dev libgtk-3-dev 39 | - uses: taiki-e/upload-rust-binary-action@v1 40 | with: 41 | bin: valin 42 | target: ${{ matrix.target }} 43 | tar: unix 44 | zip: windows 45 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "valin" 3 | version = "0.23.0" 4 | edition = "2021" 5 | 6 | [patch.crates-io] 7 | # dioxus = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 8 | # dioxus-rsx = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 9 | # dioxus-core-macro = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 10 | # dioxus-hooks = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 11 | # dioxus-signals = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 12 | # dioxus-core = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 13 | # dioxus-router = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 14 | # generational-box = { git = "https://github.com/marc2332/dioxus", rev = "17b8b22d9b6c31a9ad5a8e291627776fa3c1ec49" } 15 | 16 | [dependencies] 17 | freya = { version = "0.3.0-rc.4" } 18 | freya-hooks = { version = "0.3.0-rc.4" } 19 | 20 | dioxus-radio = { version = "0.5", features = ["tracing"] } 21 | dioxus = { version = "0.6", default-features = false } 22 | dioxus-clipboard = "*" 23 | 24 | tokio = { version = "1.33.0", features = ["fs", "process"]} 25 | winit = "0.30.1" 26 | skia-safe = { version = "0.81.0", features = ["gl", "textlayout", "svg"] } 27 | 28 | ropey = "1.6.0" 29 | smallvec = "1.10.0" 30 | uuid = { version = "1.2.2", features = ["v4"]} 31 | rfd = "0.14.1" 32 | tokio-stream = { version = "0.1.14", features = ["fs"] } 33 | tower = "0.4.13" 34 | lsp-types = "0.95.0" 35 | async-lsp = "0.2.2" 36 | futures = "0.3.28" 37 | tokio-util = { version = "0.7.11", features = ["compat"] } 38 | clap = { version = "4.5.4", features = ["derive"]} 39 | async-trait = "0.1.80" 40 | toml = "0.8.12" 41 | serde = "1.0.200" 42 | home = "0.5.9" 43 | 44 | tracing = "0.1.40" 45 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 46 | notify = "6.1.1" 47 | fxhash = "0.2.1" 48 | grep = "0.3.2" 49 | 50 | 51 | [profile.release] 52 | panic = "abort" 53 | lto = true 54 | codegen-units = 1 55 | strip = true 56 | rpath = false 57 | debug = false 58 | debug-assertions = false 59 | overflow-checks = false 60 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Marc Espín Sanz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marc2332/valin/27a91b214c20264d8511a028696bd2a6f80f2023/demo.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Discord Server](https://img.shields.io/discord/1015005816094478347.svg?logo=discord&style=flat-square)](https://discord.gg/SNcFbYmzbq) 2 | 3 | # Valin ⚒️ 4 | 5 | **Valin** ⚒️ is a **Work-In-Progress** cross-platform code editor, made with [Freya 🦀](https://github.com/marc2332/freya) and Rust. 6 | 7 | > **Valin** name is derived from [Dvalinn](https://en.wikipedia.org/wiki/Dvalinn) and it was previously known as `freya-editor`. 8 | 9 | ![Demo](./demo.png) 10 | 11 | You can download it from the [Releases](https://github.com/marc2332/valin/releases) page or run it from source code, with `--release` mode if you want max performance. 12 | 13 | ## Notes 14 | - It currently uses Jetbrains Mono for the text editor, you must have it installed. 15 | - The syntax highlighter is still very generic and is targeted to Rust code at the moment. 16 | 17 | ## Features 18 | 19 | - [x] Open folders 20 | - [x] Open files 21 | - [x] Save files 22 | - [x] Generic Syntax highlighting 23 | - [x] Text editing 24 | - [x] Text selection 25 | - [x] Copy 26 | - [x] Paste 27 | - [x] Undo 28 | - [x] Redo 29 | - [x] Files explorer 30 | - [x] Settings 31 | - [ ] Intellisense (Enable with `--lsp`) 32 | - [x] Hover (exprimental, only rust-analyzer atm) 33 | - [ ] Autocomplete 34 | - [ ] Code actions 35 | 36 | # Shortcuts 37 | - `Alt E`: Toggle focus between the files explorer and the code editors 38 | - `Alt .`: Increase font size 39 | - `Alt ,`: Decrease font size 40 | - `Alt +`: Split Panel 41 | - `Alt -`: Close Panel 42 | - `Alt ArrowsLeft/Right`: Focus the previous/next panels 43 | - `Ctrl W`: Close Tab 44 | - `Esc`: Open Commander 45 | - `Arrows`: Navigate the files explorer when focused 46 | - `Alt ArrowsUp/Down`: Scroll the editor and the cursor with increased speed 47 | - `Ctrl ArrowsUp/Down`: Scroll the cursor with increased speed 48 | - `Ctrl/Meta Z`: Undo 49 | - `Ctrl/Meta Y`: Redo 50 | - `Ctrl/Meta X`: Cut 51 | - `Ctrl/Meta C`: Copy 52 | - `Ctrl/Meta V`: paste 53 | - `Ctrl/Meta S`: Save 54 | 55 | [MIT License](./LICENSE.md) 56 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.82.0" 3 | profile = "default" -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::views::commander::commander_ui::Commander; 2 | use crate::views::file_explorer::file_explorer_ui::{ 3 | read_folder_as_items, ExplorerItem, FileExplorer, FolderState, 4 | }; 5 | use crate::views::search::search_ui::Search; 6 | use crate::Args; 7 | use crate::{ 8 | components::*, 9 | fs::{FSLocal, FSTransport}, 10 | state::EditorCommands, 11 | views::panels::tabs::welcome::WelcomeTab, 12 | }; 13 | use crate::{global_defaults::GlobalDefaults, state::KeyboardShortcuts}; 14 | use crate::{hooks::*, settings::watch_settings}; 15 | use crate::{utils::*, views::panels::tabs::editor::EditorTab}; 16 | use dioxus_clipboard::prelude::use_clipboard; 17 | use dioxus_radio::prelude::*; 18 | use freya::prelude::*; 19 | use std::sync::Arc; 20 | use tracing::info; 21 | 22 | use crate::state::{AppState, Channel}; 23 | use crate::state::{EditorSidePanel, EditorView}; 24 | 25 | #[allow(non_snake_case)] 26 | pub fn App() -> Element { 27 | // Initialize the Language Server Status reporters 28 | let (lsp_statuses, lsp_sender) = use_lsp_status(); 29 | 30 | // Initilize the clipboard context 31 | let clipboard = use_clipboard(); 32 | 33 | // Initialize the State Manager 34 | use_init_radio_station::(move || { 35 | let args = consume_context::>(); 36 | let default_transport: FSTransport = Arc::new(Box::new(FSLocal)); 37 | 38 | let mut app_state = AppState::new(lsp_sender, default_transport, clipboard); 39 | 40 | if args.paths.is_empty() { 41 | // Default tab 42 | WelcomeTab::open_with(&mut app_state); 43 | } 44 | 45 | app_state 46 | }); 47 | 48 | // Subscribe to the State Manager 49 | let mut radio_app_state = use_radio::(Channel::Global); 50 | 51 | // Load specified files and folders asynchronously 52 | use_hook(move || { 53 | let args = consume_context::>(); 54 | spawn(async move { 55 | for path in &args.paths { 56 | // Files 57 | if path.is_file() { 58 | let root_path = path.parent().unwrap_or(path).to_path_buf(); 59 | let transport = radio_app_state.read().default_transport.clone(); 60 | 61 | let mut app_state = radio_app_state.write(); 62 | EditorTab::open_with( 63 | radio_app_state, 64 | &mut app_state, 65 | path.clone(), 66 | root_path, 67 | transport.as_read(), 68 | ) 69 | } 70 | // Folders 71 | else if path.is_dir() { 72 | let mut app_state = radio_app_state.write_channel(Channel::FileExplorer); 73 | let folder_path = app_state 74 | .default_transport 75 | .canonicalize(path) 76 | .await 77 | .unwrap(); 78 | 79 | let items = 80 | read_folder_as_items(&folder_path, &app_state.default_transport).await; 81 | if let Ok(items) = items { 82 | app_state.file_explorer.open_folder(ExplorerItem::Folder { 83 | path: folder_path, 84 | state: FolderState::Opened(items), 85 | }); 86 | } 87 | } 88 | } 89 | }); 90 | }); 91 | 92 | use_hook(|| { 93 | spawn(async move { 94 | let res = watch_settings(radio_app_state).await; 95 | if res.is_none() { 96 | info!("Failed to watch the settings in background."); 97 | } 98 | }) 99 | }); 100 | 101 | // Initialize the Commands 102 | let mut editor_commands = use_hook(|| Signal::new(EditorCommands::default())); 103 | 104 | // Initialize the Shorcuts 105 | let mut keyboard_shorcuts = use_hook(|| Signal::new(KeyboardShortcuts::default())); 106 | 107 | // Register Commands and Shortcuts 108 | #[allow(clippy::explicit_auto_deref)] 109 | use_hook(|| { 110 | GlobalDefaults::init( 111 | &mut *keyboard_shorcuts.write(), 112 | &mut *editor_commands.write(), 113 | radio_app_state, 114 | ); 115 | EditorTab::init( 116 | &mut *keyboard_shorcuts.write(), 117 | &mut *editor_commands.write(), 118 | radio_app_state, 119 | ); 120 | }); 121 | 122 | // Trigger Shortcuts 123 | let onglobalkeydown = move |e: KeyboardEvent| { 124 | keyboard_shorcuts 125 | .write() 126 | .run(&e.data, &mut editor_commands.write(), radio_app_state); 127 | }; 128 | 129 | let focused_view = radio_app_state.read().focused_view; 130 | let panels_len = radio_app_state.read().panels().len(); 131 | let panes_width = 100.0 / panels_len as f32; 132 | 133 | rsx!( 134 | rect { 135 | font_size: "14", 136 | color: "white", 137 | background: "rgb(17, 20, 21)", 138 | width: "100%", 139 | height: "100%", 140 | onglobalkeydown, 141 | if focused_view == EditorView::Commander { 142 | Commander { 143 | editor_commands 144 | } 145 | } else if focused_view == EditorView::Search { 146 | Search { } 147 | } 148 | rect { 149 | height: "calc(100% - 35)", 150 | direction: "horizontal", 151 | if let Some(side_panel) = radio_app_state.read().side_panel { 152 | Sidepanel { 153 | match side_panel { 154 | EditorSidePanel::FileExplorer => { 155 | rsx!( 156 | FileExplorer { } 157 | ) 158 | } 159 | } 160 | } 161 | Divider {} 162 | } 163 | rect { 164 | width: "fill", 165 | height: "fill", 166 | direction: "horizontal", 167 | {radio_app_state.read().panels().iter().enumerate().map(|(panel_index, _)| { 168 | rsx!( 169 | EditorPanel { 170 | key: "{panel_index}", 171 | panel_index, 172 | width: "{panes_width}%" 173 | } 174 | ) 175 | })} 176 | } 177 | } 178 | VerticalDivider {} 179 | StatusBar { 180 | lsp_statuses, 181 | focused_view 182 | } 183 | } 184 | ) 185 | } 186 | -------------------------------------------------------------------------------- /src/components/editor_panel.rs: -------------------------------------------------------------------------------- 1 | use super::icons::*; 2 | use super::tab::*; 3 | use crate::state::EditorView; 4 | use crate::state::TabId; 5 | use crate::state::{AppState, Channel, Panel}; 6 | use crate::utils::*; 7 | use dioxus_radio::prelude::use_radio; 8 | use freya::prelude::*; 9 | 10 | #[derive(Props, Clone, PartialEq)] 11 | pub struct EditorPanelProps { 12 | panel_index: usize, 13 | #[props(into)] 14 | width: String, 15 | } 16 | 17 | #[allow(non_snake_case)] 18 | pub fn EditorPanel(EditorPanelProps { panel_index, width }: EditorPanelProps) -> Element { 19 | let mut radio_app_state = use_radio::(Channel::AllTabs); 20 | 21 | let app_state = radio_app_state.read(); 22 | let panels_len = app_state.panels().len(); 23 | let is_last_panel = app_state.panels().len() - 1 == panel_index; 24 | let is_focused = app_state.focused_panel() == panel_index; 25 | let panel = app_state.panel(panel_index); 26 | let active_tab = panel.active_tab(); 27 | 28 | let close_panel = move |_| { 29 | radio_app_state 30 | .write_channel(Channel::Global) 31 | .close_panel(panel_index); 32 | }; 33 | 34 | let split_panel = move |_| { 35 | let mut app_state = radio_app_state.write_channel(Channel::Global); 36 | app_state.push_panel(Panel::new()); 37 | app_state.focus_next_panel(); 38 | }; 39 | 40 | let onclickpanel = move |_| { 41 | let is_panel_focused = radio_app_state.read().focused_panel() == panel_index; 42 | let is_panels_view_focused = radio_app_state.read().focused_view() == EditorView::Panels; 43 | 44 | if !is_panel_focused { 45 | radio_app_state 46 | .write_channel(Channel::AllTabs) 47 | .focus_panel(panel_index); 48 | } 49 | 50 | if !is_panels_view_focused { 51 | radio_app_state 52 | .write_channel(Channel::Global) 53 | .focus_view(EditorView::Panels); 54 | } 55 | }; 56 | 57 | let show_close_panel = panels_len > 1; 58 | let tabsbar_tools_width = if show_close_panel { 115 } else { 60 }; 59 | let extra_container_width = if is_last_panel { 0 } else { 1 }; 60 | 61 | rsx!( 62 | rect { 63 | direction: "horizontal", 64 | height: "100%", 65 | width: "{width}", 66 | rect { 67 | width: "calc(100% - {extra_container_width})", 68 | height: "100%", 69 | overflow: "clip", 70 | rect { 71 | direction: "horizontal", 72 | height: "34", 73 | width: "100%", 74 | cross_align: "center", 75 | ScrollView { 76 | direction: "horizontal", 77 | width: "calc(100% - {tabsbar_tools_width})", 78 | show_scrollbar: false, 79 | {panel.tabs.iter().map(|tab_id| { 80 | let is_selected = active_tab == Some(*tab_id); 81 | rsx!( 82 | PanelTab { 83 | panel_index, 84 | tab_id: *tab_id, 85 | is_selected, 86 | } 87 | ) 88 | })} 89 | } 90 | rect { 91 | width: "{tabsbar_tools_width}", 92 | direction: "horizontal", 93 | cross_align: "center", 94 | main_align: "end", 95 | height: "100%", 96 | spacing: "4", 97 | padding: "4", 98 | if show_close_panel { 99 | Button { 100 | theme: theme_with!(ButtonTheme { 101 | height: "fill".into(), 102 | padding: "0 8".into(), 103 | }), 104 | onpress: close_panel, 105 | label { 106 | "Close" 107 | } 108 | } 109 | } 110 | Button { 111 | theme: theme_with!(ButtonTheme { 112 | height: "fill".into(), 113 | padding: "0 8".into(), 114 | }), 115 | onpress: split_panel, 116 | label { 117 | "Split" 118 | } 119 | } 120 | } 121 | } 122 | rect { 123 | height: "fill", 124 | width: "100%", 125 | onclick: onclickpanel, 126 | if let Some(tab_id) = active_tab { 127 | { 128 | let active_tab = app_state.tab(&tab_id); 129 | let Render = active_tab.render(); 130 | rsx!( 131 | Render { 132 | key: "{tab_id:?}", 133 | tab_id, 134 | } 135 | ) 136 | } 137 | } else { 138 | rect { 139 | main_align: "center", 140 | cross_align: "center", 141 | width: "100%", 142 | height: "100%", 143 | background: "rgb(17, 20, 21)", 144 | ExpandedIcon { 145 | Logo { 146 | enabled: is_focused, 147 | width: "200", 148 | height: "200" 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | if !is_last_panel { 156 | Divider { } 157 | } 158 | } 159 | ) 160 | } 161 | 162 | #[derive(Props, Clone, PartialEq)] 163 | pub struct PanelTabProps { 164 | panel_index: usize, 165 | tab_id: TabId, 166 | is_selected: bool, 167 | } 168 | 169 | #[allow(non_snake_case)] 170 | fn PanelTab( 171 | PanelTabProps { 172 | panel_index, 173 | tab_id, 174 | is_selected, 175 | }: PanelTabProps, 176 | ) -> Element { 177 | let mut radio_app_state = use_radio::(Channel::follow_tab(tab_id)); 178 | 179 | let app_state = radio_app_state.read(); 180 | let tab = app_state.tab(&tab_id); 181 | let tab_data = tab.get_data(); 182 | 183 | let onclick = move |_| { 184 | let mut app_state = radio_app_state.write_channel(Channel::Global); 185 | app_state.focus_panel(panel_index); 186 | app_state.panel_mut(panel_index).set_active_tab(tab_id); 187 | }; 188 | 189 | let onclickaction = move |_| { 190 | if tab_data.edited { 191 | println!("save...") 192 | } else { 193 | radio_app_state 194 | .write_channel(Channel::Global) 195 | .close_tab(tab_id); 196 | } 197 | }; 198 | 199 | rsx!(EditorTab { 200 | onclick, 201 | onclickaction, 202 | value: tab_data.title, 203 | is_edited: tab_data.edited, 204 | is_selected 205 | }) 206 | } 207 | -------------------------------------------------------------------------------- /src/components/editor_scroll_view.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Range; 2 | 3 | use freya::prelude::*; 4 | use freya::prelude::{dioxus_elements, use_applied_theme}; 5 | 6 | use crate::hooks::use_computed; 7 | use crate::{ 8 | get_container_size, get_corrected_scroll_position, get_scroll_position_from_cursor, 9 | get_scrollbar_pos_and_size, is_scrollbar_visible, Axis, 10 | }; 11 | 12 | pub fn get_scroll_position_from_wheel( 13 | wheel_movement: f32, 14 | inner_size: f32, 15 | viewport_size: f32, 16 | scroll_position: f32, 17 | ) -> i32 { 18 | if viewport_size >= inner_size { 19 | return 0; 20 | } 21 | 22 | let new_position = scroll_position + (wheel_movement * 2.0); 23 | 24 | if new_position >= 0.0 && wheel_movement > 0.0 { 25 | return 0; 26 | } 27 | 28 | if new_position <= -(inner_size - viewport_size) && wheel_movement < 0.0 { 29 | return -(inner_size - viewport_size) as i32; 30 | } 31 | 32 | new_position as i32 33 | } 34 | 35 | /// Indicates the current focus status of the EditorScrollView. 36 | #[derive(Debug, Default, PartialEq, Clone, Copy)] 37 | pub enum EditorScrollViewStatus { 38 | /// Default state. 39 | #[default] 40 | Idle, 41 | /// Mouse is hovering the EditorScrollView. 42 | Hovering, 43 | } 44 | 45 | /// Properties for the EditorScrollView component. 46 | #[derive(Props, Clone)] 47 | pub struct EditorScrollViewProps< 48 | Builder: 'static + Clone + Fn(usize, &BuilderArgs) -> Element, 49 | BuilderArgs: Clone + 'static + PartialEq = (), 50 | > { 51 | length: usize, 52 | item_size: f32, 53 | #[props(default = "100%".to_string(), into)] 54 | pub height: String, 55 | #[props(default = "100%".to_string(), into)] 56 | pub width: String, 57 | #[props(default = "0".to_string(), into)] 58 | pub padding: String, 59 | #[props(default = true, into)] 60 | pub show_scrollbar: bool, 61 | pub offset_y: i32, 62 | pub offset_x: i32, 63 | pub onscroll: EventHandler<(Axis, i32)>, 64 | pub pressing_shift: ReadOnlySignal, 65 | pub pressing_alt: ReadOnlySignal, 66 | 67 | builder_args: BuilderArgs, 68 | builder: Builder, 69 | } 70 | 71 | impl Element> PartialEq 72 | for EditorScrollViewProps 73 | { 74 | fn eq(&self, other: &Self) -> bool { 75 | self.length == other.length 76 | && self.item_size == other.item_size 77 | && self.width == other.width 78 | && self.height == other.height 79 | && self.padding == other.padding 80 | && self.show_scrollbar == other.show_scrollbar 81 | && self.offset_y == other.offset_y 82 | && self.offset_x == other.offset_x 83 | && self.onscroll == other.onscroll 84 | && self.pressing_shift == other.pressing_shift 85 | && self.pressing_alt == other.pressing_alt 86 | && self.builder_args == other.builder_args 87 | } 88 | } 89 | 90 | fn get_render_range( 91 | viewport_size: f32, 92 | scroll_position: f32, 93 | item_size: f32, 94 | item_length: f32, 95 | ) -> Range { 96 | let render_index_start = (-scroll_position) / item_size; 97 | let potentially_visible_length = (viewport_size / item_size) + 1.0; 98 | let remaining_length = item_length - render_index_start; 99 | 100 | let render_index_end = if remaining_length <= potentially_visible_length { 101 | item_length 102 | } else { 103 | render_index_start + potentially_visible_length 104 | }; 105 | 106 | render_index_start as usize..(render_index_end as usize) 107 | } 108 | 109 | /// A controlled ScrollView with virtual scrolling. 110 | #[allow(non_snake_case)] 111 | pub fn EditorScrollView< 112 | Builder: Clone + Fn(usize, &BuilderArgs) -> Element, 113 | BuilderArgs: Clone + PartialEq, 114 | >( 115 | EditorScrollViewProps { 116 | length, 117 | item_size, 118 | height, 119 | width, 120 | padding, 121 | show_scrollbar, 122 | offset_x, 123 | offset_y, 124 | onscroll, 125 | pressing_alt, 126 | pressing_shift, 127 | builder, 128 | builder_args, 129 | }: EditorScrollViewProps, 130 | ) -> Element { 131 | let mut clicking_scrollbar = use_signal::>(|| None); 132 | let (node_ref, size) = use_node(); 133 | let scrollbar_theme = use_applied_theme!(&None, scroll_bar); 134 | let platform = use_platform(); 135 | let mut status = use_signal(EditorScrollViewStatus::default); 136 | 137 | use_drop(move || { 138 | if *status.read() == EditorScrollViewStatus::Hovering { 139 | platform.set_cursor(CursorIcon::default()); 140 | } 141 | }); 142 | 143 | let inner_size = item_size + (item_size * length as f32); 144 | 145 | let vertical_scrollbar_is_visible = 146 | is_scrollbar_visible(show_scrollbar, inner_size, size.area.height()); 147 | let horizontal_scrollbar_is_visible = 148 | is_scrollbar_visible(show_scrollbar, size.inner.width, size.area.width()); 149 | 150 | let (container_width, content_width) = get_container_size(&width, true, Axis::X); 151 | let (container_height, content_height) = get_container_size(&height, true, Axis::Y); 152 | 153 | let corrected_scrolled_y = 154 | get_corrected_scroll_position(inner_size, size.area.height(), offset_y as f32); 155 | let corrected_scrolled_x = 156 | get_corrected_scroll_position(size.inner.width, size.area.width(), offset_x as f32); 157 | 158 | let (scrollbar_y, scrollbar_height) = 159 | get_scrollbar_pos_and_size(inner_size, size.area.height(), corrected_scrolled_y); 160 | let (scrollbar_x, scrollbar_width) = 161 | get_scrollbar_pos_and_size(size.inner.width, size.area.width(), corrected_scrolled_x); 162 | 163 | // Moves the Y axis when the user scrolls in the container 164 | let onwheel = move |e: WheelEvent| { 165 | let speed_multiplier = if pressing_alt() { 166 | SCROLL_SPEED_MULTIPLIER 167 | } else { 168 | 1.0 169 | }; 170 | 171 | let invert_direction = pressing_shift(); 172 | 173 | let (x_movement, y_movement) = if invert_direction { 174 | ( 175 | e.get_delta_y() as f32 * speed_multiplier, 176 | e.get_delta_x() as f32 * speed_multiplier, 177 | ) 178 | } else { 179 | ( 180 | e.get_delta_x() as f32 * speed_multiplier, 181 | e.get_delta_y() as f32 * speed_multiplier, 182 | ) 183 | }; 184 | 185 | let scroll_position_y = get_scroll_position_from_wheel( 186 | y_movement, 187 | inner_size, 188 | size.area.height(), 189 | offset_y as f32, 190 | ); 191 | 192 | onscroll.call((Axis::Y, scroll_position_y)); 193 | 194 | let scroll_position_x = get_scroll_position_from_wheel( 195 | x_movement, 196 | size.inner.width, 197 | size.area.width(), 198 | corrected_scrolled_x, 199 | ); 200 | 201 | onscroll.call((Axis::X, scroll_position_x)); 202 | }; 203 | 204 | // Drag the scrollbars 205 | let onmousemove = move |e: MouseEvent| { 206 | let clicking_scrollbar = clicking_scrollbar.read(); 207 | 208 | if let Some((Axis::Y, y)) = *clicking_scrollbar { 209 | let coordinates = e.get_element_coordinates(); 210 | let cursor_y = coordinates.y - y - size.area.min_y() as f64; 211 | 212 | let scroll_position = 213 | get_scroll_position_from_cursor(cursor_y as f32, inner_size, size.area.height()); 214 | 215 | onscroll.call((Axis::Y, scroll_position)) 216 | } else if let Some((Axis::X, x)) = *clicking_scrollbar { 217 | let coordinates = e.get_element_coordinates(); 218 | let cursor_x = coordinates.x - x - size.area.min_x() as f64; 219 | 220 | let scroll_position = get_scroll_position_from_cursor( 221 | cursor_x as f32, 222 | size.inner.width, 223 | size.area.width(), 224 | ); 225 | 226 | onscroll.call((Axis::X, scroll_position)) 227 | } 228 | }; 229 | 230 | // Mark the Y axis scrollbar as the one being dragged 231 | let onmousedown_y = move |e: MouseEvent| { 232 | let coordinates = e.get_element_coordinates(); 233 | *clicking_scrollbar.write() = Some((Axis::Y, coordinates.y)); 234 | }; 235 | 236 | // Mark the X axis scrollbar as the one being dragged 237 | let onmousedown_x = move |e: MouseEvent| { 238 | let coordinates = e.get_element_coordinates(); 239 | *clicking_scrollbar.write() = Some((Axis::X, coordinates.x)); 240 | }; 241 | 242 | // Unmark any scrollbar 243 | let onclick = move |_: MouseEvent| { 244 | if clicking_scrollbar.read().is_some() { 245 | *clicking_scrollbar.write() = None; 246 | } 247 | }; 248 | 249 | let onmouseenter_children = move |_| { 250 | platform.set_cursor(CursorIcon::Text); 251 | status.set(EditorScrollViewStatus::Hovering); 252 | }; 253 | 254 | let onmouseleave_children = move |_| { 255 | platform.set_cursor(CursorIcon::default()); 256 | status.set(EditorScrollViewStatus::default()); 257 | }; 258 | 259 | // Calculate from what to what items must be rendered 260 | let render_range = get_render_range( 261 | size.area.height(), 262 | corrected_scrolled_y, 263 | item_size, 264 | length as f32, 265 | ); 266 | 267 | let children = use_computed( 268 | &(render_range, builder_args), 269 | move |(render_range, builder_args)| { 270 | rsx!({ render_range.clone().map(|i| (builder)(i, builder_args)) }) 271 | }, 272 | ); 273 | let children = &children.borrow(); 274 | let children = &children.value; 275 | 276 | let is_scrolling_x = clicking_scrollbar 277 | .read() 278 | .as_ref() 279 | .map(|f| f.0 == Axis::X) 280 | .unwrap_or_default(); 281 | let is_scrolling_y = clicking_scrollbar 282 | .read() 283 | .as_ref() 284 | .map(|f| f.0 == Axis::Y) 285 | .unwrap_or_default(); 286 | 287 | let offset_y_min = (-corrected_scrolled_y / item_size).floor() * item_size; 288 | let offset_y = -corrected_scrolled_y - offset_y_min; 289 | 290 | rsx!( 291 | rect { 292 | overflow: "clip", 293 | direction: "horizontal", 294 | width: "{width}", 295 | height: "{height}", 296 | onclick, 297 | onglobalmousemove: onmousemove, 298 | rect { 299 | direction: "vertical", 300 | width: "{container_width}", 301 | height: "{container_height}", 302 | rect { 303 | overflow: "clip", 304 | padding: "{padding}", 305 | height: "{content_height}", 306 | width: "{content_width}", 307 | direction: "vertical", 308 | offset_x: "{corrected_scrolled_x}", 309 | reference: node_ref, 310 | onwheel, 311 | onmouseenter: onmouseenter_children, 312 | onmouseleave: onmouseleave_children, 313 | offset_y: "{-offset_y}", 314 | {children} 315 | } 316 | if show_scrollbar && horizontal_scrollbar_is_visible { 317 | ScrollBar { 318 | size: &scrollbar_theme.size, 319 | offset_x: scrollbar_x, 320 | clicking_scrollbar: is_scrolling_x, 321 | ScrollThumb { 322 | clicking_scrollbar: is_scrolling_x, 323 | onmousedown: onmousedown_x, 324 | width: "{scrollbar_width}", 325 | height: "100%", 326 | }, 327 | } 328 | } 329 | } 330 | if show_scrollbar && vertical_scrollbar_is_visible { 331 | ScrollBar { 332 | is_vertical: true, 333 | size: &scrollbar_theme.size, 334 | offset_y: scrollbar_y, 335 | clicking_scrollbar: is_scrolling_y, 336 | ScrollThumb { 337 | clicking_scrollbar: is_scrolling_y, 338 | onmousedown: onmousedown_y, 339 | width: "100%", 340 | height: "{scrollbar_height}", 341 | } 342 | } 343 | } 344 | } 345 | ) 346 | } 347 | -------------------------------------------------------------------------------- /src/components/icons.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | 3 | static LOGO_ENABLED: &str = include_str!("../icons/logo_enabled.svg"); 4 | static LOGO_DISABLED: &str = include_str!("../icons/logo_disabled.svg"); 5 | 6 | #[derive(Props, PartialEq, Clone)] 7 | pub struct IconProps { 8 | #[props(default = "auto".to_string(), into)] 9 | width: String, 10 | #[props(default = "auto".to_string(), into)] 11 | height: String, 12 | enabled: bool, 13 | } 14 | 15 | #[allow(non_snake_case)] 16 | pub fn Logo(props: IconProps) -> Element { 17 | let width = &props.width; 18 | let height = &props.height; 19 | 20 | let logo = if props.enabled { 21 | LOGO_ENABLED 22 | } else { 23 | LOGO_DISABLED 24 | }; 25 | 26 | rsx!(svg { 27 | width: "{width}", 28 | height: "{height}", 29 | svg_content: logo, 30 | }) 31 | } 32 | 33 | #[derive(Props, Clone, PartialEq)] 34 | pub struct ExpandedIconProps { 35 | children: Element, 36 | } 37 | 38 | #[allow(non_snake_case)] 39 | pub fn ExpandedIcon(props: ExpandedIconProps) -> Element { 40 | rsx!( 41 | rect { 42 | main_align: "center", 43 | cross_align: "center", 44 | width: "100%", 45 | height: "100%", 46 | {props.children} 47 | } 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/components/mod.rs: -------------------------------------------------------------------------------- 1 | mod editor_panel; 2 | mod editor_scroll_view; 3 | mod icons; 4 | mod overlay; 5 | mod sidepanel; 6 | mod status_bar; 7 | mod tab; 8 | mod text_area; 9 | 10 | pub use editor_panel::*; 11 | pub use editor_scroll_view::*; 12 | pub use overlay::*; 13 | pub use sidepanel::*; 14 | pub use status_bar::*; 15 | pub use text_area::*; 16 | -------------------------------------------------------------------------------- /src/components/overlay.rs: -------------------------------------------------------------------------------- 1 | use crate::state::Channel; 2 | use dioxus_radio::prelude::use_radio; 3 | use freya::prelude::*; 4 | 5 | #[component] 6 | pub fn Overlay(children: Element) -> Element { 7 | let mut radio_app_state = use_radio(Channel::Global); 8 | 9 | let onglobalmousedown = move |_| { 10 | let mut app_state = radio_app_state.write_channel(Channel::Global); 11 | app_state.focus_previous_view(); 12 | }; 13 | 14 | rsx!( 15 | rect { 16 | width: "100%", 17 | height: "0", 18 | layer: "-9999", 19 | onglobalmousedown, 20 | rect { 21 | width: "100%", 22 | height: "100v", 23 | main_align: "center", 24 | cross_align: "center", 25 | rect { 26 | background: "rgb(35, 38, 39)", 27 | shadow: "0 4 15 8 rgb(0, 0, 0, 0.3)", 28 | corner_radius: "12", 29 | onmousedown: |e| { 30 | e.stop_propagation(); 31 | }, 32 | width: "500", 33 | padding: "5", 34 | {children} 35 | } 36 | } 37 | } 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/components/sidepanel.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | 3 | #[component] 4 | #[allow(non_snake_case)] 5 | pub fn Sidepanel(children: Element) -> Element { 6 | rsx!(rect { 7 | width: "270", 8 | height: "100%", 9 | direction: "vertical", 10 | {children} 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/components/status_bar.rs: -------------------------------------------------------------------------------- 1 | use dioxus_radio::prelude::use_radio; 2 | use freya::prelude::*; 3 | 4 | use crate::{ 5 | state::{Channel, EditorSidePanel, EditorView}, 6 | views::panels::tabs::{editor::TabEditorUtils, settings::Settings}, 7 | LspStatuses, 8 | }; 9 | 10 | #[derive(Props, Clone, PartialEq)] 11 | pub struct StatusBarProps { 12 | lsp_statuses: LspStatuses, 13 | focused_view: EditorView, 14 | } 15 | 16 | #[allow(non_snake_case)] 17 | pub fn StatusBar(props: StatusBarProps) -> Element { 18 | let mut radio_app_state = use_radio(Channel::ActiveTab); 19 | 20 | let open_settings = move |_| { 21 | let mut app_state = radio_app_state.write_channel(Channel::Global); 22 | Settings::open_with(radio_app_state, &mut app_state); 23 | }; 24 | 25 | let toggle_file_explorer = move |_| { 26 | let mut app_state = radio_app_state.write_channel(Channel::Global); 27 | app_state.toggle_side_panel(EditorSidePanel::FileExplorer); 28 | }; 29 | 30 | let app_state = radio_app_state.read(); 31 | let panel = app_state.panel(app_state.focused_panel); 32 | let tab_data = if let Some(active_tab) = panel.active_tab() { 33 | app_state 34 | .tab(&active_tab) 35 | .as_text_editor() 36 | .map(|editor_tab| { 37 | ( 38 | editor_tab.editor.cursor_row_and_col(), 39 | editor_tab.editor.editor_type(), 40 | ) 41 | }) 42 | } else { 43 | None 44 | }; 45 | 46 | rsx!( 47 | rect { 48 | width: "100%", 49 | height: "fill", 50 | background: "rgb(17, 20, 21)", 51 | direction: "horizontal", 52 | cross_align: "center", 53 | padding: "0 2", 54 | color: "rgb(220, 220, 220)", 55 | rect { 56 | width: "50%", 57 | direction: "horizontal", 58 | StatusBarItem { 59 | onclick: toggle_file_explorer, 60 | label { 61 | "📁" 62 | } 63 | } 64 | StatusBarItem { 65 | onclick: open_settings, 66 | label { 67 | "⚙️" 68 | } 69 | } 70 | StatusBarItem { 71 | label { 72 | "{props.focused_view}" 73 | } 74 | } 75 | for (name, msg) in props.lsp_statuses.read().iter() { 76 | StatusBarItem { 77 | label { 78 | "{name} {msg}" 79 | } 80 | } 81 | } 82 | } 83 | rect { 84 | width: "50%", 85 | direction: "horizontal", 86 | main_align: "end", 87 | if let Some(((row, col), editor_type)) = tab_data { 88 | StatusBarItem { 89 | label { 90 | "Ln {row + 1}, Col {col + 1}" 91 | } 92 | } 93 | StatusBarItem { 94 | label { 95 | "{editor_type.language_id()}" 96 | } 97 | } 98 | } 99 | } 100 | } 101 | ) 102 | } 103 | 104 | #[allow(non_snake_case)] 105 | #[component] 106 | fn StatusBarItem(children: Element, onclick: Option>) -> Element { 107 | rsx!( 108 | Button { 109 | onpress: move |_| { 110 | if let Some(onclick) = onclick { 111 | onclick.call(()); 112 | } 113 | }, 114 | theme: theme_with!(ButtonTheme { 115 | margin: "1 2".into(), 116 | padding: "4 6".into(), 117 | background: "none".into(), 118 | border_fill: "none".into(), 119 | }), 120 | {children} 121 | } 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /src/components/tab.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | use winit::window::CursorIcon; 3 | 4 | #[allow(non_snake_case)] 5 | #[component] 6 | pub fn EditorTab( 7 | value: String, 8 | onclick: EventHandler<()>, 9 | onclickaction: EventHandler<()>, 10 | is_selected: bool, 11 | is_edited: bool, 12 | ) -> Element { 13 | let mut status = use_signal(ButtonStatus::default); 14 | let theme = use_applied_theme!(None, button); 15 | let platform = use_platform(); 16 | 17 | use_drop(move || { 18 | if *status.read() == ButtonStatus::Hovering { 19 | platform.set_cursor(CursorIcon::default()); 20 | } 21 | }); 22 | 23 | let onmouseenter = move |_| { 24 | platform.set_cursor(CursorIcon::Pointer); 25 | status.set(ButtonStatus::Hovering); 26 | }; 27 | 28 | let onmouseleave = move |_| { 29 | platform.set_cursor(CursorIcon::default()); 30 | status.set(ButtonStatus::default()); 31 | }; 32 | 33 | let background = match *status.read() { 34 | _ if is_selected => "rgb(29, 32, 33)", 35 | ButtonStatus::Hovering => "rgb(25, 28, 29)", 36 | ButtonStatus::Idle => "transparent", 37 | }; 38 | let color = theme.font_theme.color; 39 | let selected_color = if is_selected { 40 | "rgb(60, 60, 60)" 41 | } else { 42 | background 43 | }; 44 | let is_hovering = *status.read() == ButtonStatus::Hovering; 45 | 46 | rsx!( 47 | rect { 48 | width: "140", 49 | height: "100%", 50 | rect { 51 | height: "2", 52 | width: "100%", 53 | background: selected_color 54 | } 55 | rect { 56 | color: "{color}", 57 | background, 58 | onclick: move |_| onclick.call(()), 59 | onmouseenter, 60 | onmouseleave, 61 | height: "fill", 62 | width: "fill", 63 | cross_align: "center", 64 | direction: "horizontal", 65 | padding: "0 0 0 10", 66 | label { 67 | width: "calc(100% - 24)", 68 | max_lines: "1", 69 | text_overflow: "ellipsis", 70 | text_align: "center", 71 | "{value}" 72 | } 73 | rect { 74 | width: "24", 75 | onclick: move |e| { 76 | e.stop_propagation(); 77 | onclickaction.call(()); 78 | }, 79 | if is_edited { 80 | IndicatorButton { 81 | rect { 82 | background: "rgb(180, 180, 180)", 83 | width: "10", 84 | height: "10", 85 | corner_radius: "100", 86 | } 87 | } 88 | } else if is_hovering || is_selected { 89 | IndicatorButton { 90 | CrossIcon { 91 | fill: "rgb(150, 150, 150)", 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | ) 99 | } 100 | 101 | #[allow(non_snake_case)] 102 | #[component] 103 | fn IndicatorButton(children: Element) -> Element { 104 | rsx!(Button { 105 | theme: theme_with!(ButtonTheme { 106 | margin: "0".into(), 107 | corner_radius: "999".into(), 108 | shadow: "none".into(), 109 | border_fill: "none".into(), 110 | width: "20".into(), 111 | height: "20".into(), 112 | padding: "0".into(), 113 | }), 114 | children 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/components/text_area.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::{keyboard::Key, *}; 2 | 3 | /// [`TextArea`] component properties. 4 | #[derive(Props, Clone, PartialEq)] 5 | pub struct TextAreaProps { 6 | /// Placerholder text for when there is no text. 7 | pub placeholder: &'static str, 8 | /// Current value of the TextArea 9 | pub value: String, 10 | /// Handler for the `onchange` event. 11 | pub onchange: EventHandler, 12 | /// Handler for the `onsubmit` event. 13 | pub onsubmit: EventHandler, 14 | } 15 | 16 | #[allow(non_snake_case)] 17 | pub fn TextArea(props: TextAreaProps) -> Element { 18 | let theme = use_applied_theme!(&None, input); 19 | let platform = use_platform(); 20 | let mut status = use_signal(InputStatus::default); 21 | let mut editable = use_editable( 22 | || EditableConfig::new(props.value.to_string()), 23 | EditableMode::MultipleLinesSingleEditor, 24 | ); 25 | let focus = use_focus(); 26 | 27 | if &props.value != editable.editor().read().rope() { 28 | editable.editor_mut().write().set(&props.value); 29 | } 30 | 31 | let onkeydown = move |e: Event| { 32 | if focus.is_focused() { 33 | if let Key::Enter = e.data.key { 34 | props.onsubmit.call(editable.editor().peek().to_string()); 35 | } else { 36 | editable.process_event(&EditableEvent::KeyDown(e.data)); 37 | props.onchange.call(editable.editor().peek().to_string()); 38 | } 39 | } 40 | }; 41 | 42 | let onmousemove = move |e: MouseEvent| { 43 | editable.process_event(&EditableEvent::MouseMove(e.data, 0)); 44 | }; 45 | 46 | let onmouseenter = move |_| { 47 | platform.set_cursor(CursorIcon::Text); 48 | *status.write() = InputStatus::Hovering; 49 | }; 50 | 51 | let onmouseleave = move |_| { 52 | platform.set_cursor(CursorIcon::default()); 53 | *status.write() = InputStatus::default(); 54 | }; 55 | 56 | let cursor_reference = editable.cursor_attr(); 57 | let highlights = editable.highlights_attr(0); 58 | let cursor_char = editable.editor().read().cursor_pos().to_string(); 59 | 60 | let InputTheme { 61 | border_fill, 62 | background, 63 | font_theme: FontTheme { color }, 64 | .. 65 | } = theme; 66 | 67 | let (color, text) = if props.value.is_empty() { 68 | ("rgb(210, 210, 210)", props.placeholder) 69 | } else { 70 | (color.as_ref(), props.value.as_str()) 71 | }; 72 | 73 | rsx!( 74 | rect { 75 | overflow: "clip", 76 | width: "100%", 77 | color: "{color}", 78 | background: "{background}", 79 | corner_radius: "8", 80 | border: "1 solid {border_fill}", 81 | padding: "8 6", 82 | margin: "0 0 2 0", 83 | a11y_id: focus.attribute(), 84 | a11y_role: "text-input", 85 | a11y_auto_focus: "true", 86 | onkeydown, 87 | paragraph { 88 | margin: "6 10", 89 | onmouseenter, 90 | onmouseleave, 91 | onmousemove, 92 | width: "100%", 93 | cursor_reference, 94 | cursor_id: "0", 95 | cursor_index: "{cursor_char}", 96 | cursor_mode: "editable", 97 | cursor_color: "{color}", 98 | max_lines: "1", 99 | highlights, 100 | text { 101 | "{text}" 102 | } 103 | } 104 | } 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub static BASE_FONT_SIZE: f32 = 5.0; 2 | pub static MAX_FONT_SIZE: f32 = 150.0; 3 | -------------------------------------------------------------------------------- /src/fs/interface.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | sync::Arc, 4 | }; 5 | 6 | use async_trait::async_trait; 7 | use tokio::fs::{File, OpenOptions}; 8 | 9 | pub type FSTransport = Arc>; 10 | 11 | #[async_trait] 12 | pub trait FSReadTransportInterface { 13 | async fn read_to_string(&self, path: &Path) -> tokio::io::Result; 14 | } 15 | 16 | #[async_trait] 17 | pub trait FSTransportInterface: FSReadTransportInterface { 18 | fn as_read(&self) -> Box; 19 | 20 | async fn open(&self, path: &Path, open_options: &mut OpenOptions) -> tokio::io::Result; 21 | 22 | async fn read_dir(&self, path: &Path) -> tokio::io::Result; 23 | 24 | async fn canonicalize(&self, path: &Path) -> tokio::io::Result; 25 | } 26 | -------------------------------------------------------------------------------- /src/fs/local.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use tokio::fs::OpenOptions; 3 | 4 | use super::{FSReadTransportInterface, FSTransportInterface}; 5 | 6 | pub struct FSLocal; 7 | 8 | #[async_trait] 9 | impl FSReadTransportInterface for FSLocal { 10 | async fn read_to_string(&self, path: &std::path::Path) -> tokio::io::Result { 11 | tokio::fs::read_to_string(path).await 12 | } 13 | } 14 | 15 | #[async_trait] 16 | impl FSTransportInterface for FSLocal { 17 | fn as_read(&self) -> Box { 18 | Box::new(FSLocal) 19 | } 20 | 21 | async fn open( 22 | &self, 23 | path: &std::path::Path, 24 | open_options: &mut OpenOptions, 25 | ) -> tokio::io::Result { 26 | open_options.open(path).await 27 | } 28 | 29 | async fn read_dir(&self, path: &std::path::Path) -> tokio::io::Result { 30 | tokio::fs::read_dir(path).await 31 | } 32 | 33 | async fn canonicalize(&self, path: &std::path::Path) -> tokio::io::Result { 34 | tokio::fs::canonicalize(path).await 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/fs/mod.rs: -------------------------------------------------------------------------------- 1 | mod interface; 2 | mod local; 3 | 4 | pub use interface::*; 5 | pub use local::*; 6 | -------------------------------------------------------------------------------- /src/global_defaults.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | state::{Channel, CommandRunContext, EditorCommand, EditorView, Panel, RadioAppState}, 3 | views::panels::tabs::settings::Settings, 4 | }; 5 | 6 | #[allow(non_snake_case)] 7 | pub mod GlobalDefaults { 8 | use freya::events::{Code, Key, KeyboardData, Modifiers}; 9 | 10 | use crate::state::{Channel, EditorCommands, EditorView, KeyboardShortcuts, RadioAppState}; 11 | 12 | use super::{ 13 | ClosePanelCommand, CloseTabCommand, FocusNextPanelCommand, FocusPreviousPanelCommand, 14 | OpenSearchCommand, OpenSettingsCommand, SplitPanelCommand, ToggleCommanderCommand, 15 | }; 16 | 17 | pub fn init( 18 | keyboard_shorcuts: &mut KeyboardShortcuts, 19 | commands: &mut EditorCommands, 20 | radio_app_state: RadioAppState, 21 | ) { 22 | // Register Commands 23 | commands.register(SplitPanelCommand(radio_app_state)); 24 | commands.register(ClosePanelCommand(radio_app_state)); 25 | commands.register(ToggleCommanderCommand(radio_app_state)); 26 | commands.register(OpenSettingsCommand(radio_app_state)); 27 | commands.register(OpenSearchCommand(radio_app_state)); 28 | commands.register(CloseTabCommand(radio_app_state)); 29 | commands.register(FocusNextPanelCommand(radio_app_state)); 30 | commands.register(FocusPreviousPanelCommand(radio_app_state)); 31 | 32 | // Register Shortcuts 33 | keyboard_shorcuts.register( 34 | |data: &KeyboardData, 35 | commands: &mut EditorCommands, 36 | mut radio_app_state: RadioAppState| { 37 | let is_pressing_alt = data.modifiers == Modifiers::ALT; 38 | let is_pressing_ctrl = data.modifiers == Modifiers::CONTROL; 39 | 40 | match data.code { 41 | // Pressing `Esc` 42 | Code::Escape => { 43 | commands.trigger(ToggleCommanderCommand::id()); 44 | } 45 | // Pressing `Alt E` 46 | Code::KeyE if is_pressing_alt => { 47 | let mut app_state = radio_app_state.write_channel(Channel::Global); 48 | if app_state.focused_view() == EditorView::FilesExplorer { 49 | app_state.focus_view(EditorView::Panels) 50 | } else { 51 | app_state.focus_view(EditorView::FilesExplorer) 52 | } 53 | } 54 | // Pressing `Ctrl W` 55 | Code::KeyW if is_pressing_ctrl => { 56 | commands.trigger(CloseTabCommand::id()); 57 | } 58 | // Pressing `Alt +` 59 | _ if is_pressing_alt && data.key == Key::Character("+".to_string()) => { 60 | commands.trigger(SplitPanelCommand::id()); 61 | } 62 | // Pressing `Alt -` 63 | _ if is_pressing_alt && data.key == Key::Character("-".to_string()) => { 64 | commands.trigger(ClosePanelCommand::id()); 65 | } 66 | // Pressing `Alt ArrowRight` 67 | _ if is_pressing_alt && data.key == Key::ArrowRight => { 68 | commands.trigger(FocusNextPanelCommand::id()); 69 | } 70 | // Pressing `Alt ArrowLeft` 71 | _ if is_pressing_alt && data.key == Key::ArrowLeft => { 72 | commands.trigger(FocusPreviousPanelCommand::id()); 73 | } 74 | _ => return false, 75 | } 76 | true 77 | }, 78 | ); 79 | } 80 | } 81 | 82 | #[derive(Clone)] 83 | pub struct SplitPanelCommand(pub RadioAppState); 84 | 85 | impl SplitPanelCommand { 86 | pub fn id() -> &'static str { 87 | "split-panel" 88 | } 89 | } 90 | 91 | impl EditorCommand for SplitPanelCommand { 92 | fn matches(&self, input: &str) -> bool { 93 | self.text().to_lowercase().contains(input) 94 | } 95 | 96 | fn id(&self) -> &str { 97 | Self::id() 98 | } 99 | 100 | fn text(&self) -> &str { 101 | "Split Panel" 102 | } 103 | 104 | fn run(&self, _ctx: &mut CommandRunContext) { 105 | let mut radio_app_state = self.0; 106 | 107 | let mut app_state = radio_app_state.write_channel(Channel::Global); 108 | app_state.push_panel(Panel::new()); 109 | let len_panels = app_state.panels().len(); 110 | app_state.focus_panel(len_panels - 1); 111 | } 112 | } 113 | 114 | #[derive(Clone)] 115 | pub struct ClosePanelCommand(pub RadioAppState); 116 | 117 | impl ClosePanelCommand { 118 | pub fn id() -> &'static str { 119 | "cllose-panel" 120 | } 121 | } 122 | 123 | impl EditorCommand for ClosePanelCommand { 124 | fn matches(&self, input: &str) -> bool { 125 | self.text().to_lowercase().contains(input) 126 | } 127 | 128 | fn id(&self) -> &str { 129 | Self::id() 130 | } 131 | 132 | fn text(&self) -> &str { 133 | "Close Panel" 134 | } 135 | 136 | fn run(&self, _ctx: &mut CommandRunContext) { 137 | let mut radio_app_state = self.0; 138 | let mut app_state = radio_app_state.write_channel(Channel::Global); 139 | app_state.close_active_panel(); 140 | } 141 | } 142 | 143 | #[derive(Clone)] 144 | pub struct ToggleCommanderCommand(pub RadioAppState); 145 | 146 | impl ToggleCommanderCommand { 147 | pub fn id() -> &'static str { 148 | "toggle-commander" 149 | } 150 | } 151 | 152 | impl EditorCommand for ToggleCommanderCommand { 153 | fn is_visible(&self) -> bool { 154 | // It doesn't make sense to show this command in the Commander. 155 | false 156 | } 157 | 158 | fn matches(&self, _input: &str) -> bool { 159 | false 160 | } 161 | 162 | fn id(&self) -> &str { 163 | Self::id() 164 | } 165 | 166 | fn text(&self) -> &str { 167 | "Toggle Commander" 168 | } 169 | 170 | fn run(&self, _ctx: &mut CommandRunContext) { 171 | let mut radio_app_state = self.0; 172 | let mut app_state = radio_app_state.write_channel(Channel::Global); 173 | if app_state.focused_view == EditorView::Commander { 174 | app_state.focus_previous_view(); 175 | } else { 176 | app_state.focus_view(EditorView::Commander); 177 | } 178 | } 179 | } 180 | 181 | #[derive(Clone)] 182 | pub struct OpenSettingsCommand(pub RadioAppState); 183 | 184 | impl OpenSettingsCommand { 185 | pub fn id() -> &'static str { 186 | "open-settings" 187 | } 188 | } 189 | 190 | impl EditorCommand for OpenSettingsCommand { 191 | fn matches(&self, input: &str) -> bool { 192 | self.text().to_lowercase().contains(&input.to_lowercase()) 193 | } 194 | 195 | fn id(&self) -> &str { 196 | Self::id() 197 | } 198 | 199 | fn text(&self) -> &str { 200 | "Open Settings" 201 | } 202 | 203 | fn run(&self, _ctx: &mut CommandRunContext) { 204 | let mut radio_app_state = self.0; 205 | let mut app_state = radio_app_state.write_channel(Channel::Global); 206 | Settings::open_with(radio_app_state, &mut app_state); 207 | } 208 | } 209 | 210 | #[derive(Clone)] 211 | pub struct OpenSearchCommand(pub RadioAppState); 212 | 213 | impl OpenSearchCommand { 214 | pub fn id() -> &'static str { 215 | "open-search" 216 | } 217 | } 218 | 219 | impl EditorCommand for OpenSearchCommand { 220 | fn matches(&self, input: &str) -> bool { 221 | self.text().to_lowercase().contains(&input.to_lowercase()) 222 | } 223 | 224 | fn id(&self) -> &str { 225 | Self::id() 226 | } 227 | 228 | fn text(&self) -> &str { 229 | "Open Search" 230 | } 231 | 232 | fn run(&self, ctx: &mut CommandRunContext) { 233 | ctx.focus_previous_view = false; 234 | 235 | let mut radio_app_state = self.0; 236 | let mut app_state = radio_app_state.write_channel(Channel::Global); 237 | app_state.focus_view(EditorView::Search); 238 | } 239 | } 240 | 241 | #[derive(Clone)] 242 | pub struct CloseTabCommand(pub RadioAppState); 243 | 244 | impl CloseTabCommand { 245 | pub fn id() -> &'static str { 246 | "close-tab" 247 | } 248 | } 249 | 250 | impl EditorCommand for CloseTabCommand { 251 | fn matches(&self, input: &str) -> bool { 252 | self.text().to_lowercase().contains(&input.to_lowercase()) 253 | } 254 | 255 | fn id(&self) -> &str { 256 | Self::id() 257 | } 258 | 259 | fn text(&self) -> &str { 260 | "Close Tab" 261 | } 262 | 263 | fn run(&self, _ctx: &mut CommandRunContext) { 264 | let mut radio_app_state = self.0; 265 | let mut app_state = radio_app_state.write_channel(Channel::Global); 266 | app_state.close_active_tab(); 267 | } 268 | } 269 | 270 | #[derive(Clone)] 271 | pub struct FocusNextPanelCommand(pub RadioAppState); 272 | 273 | impl FocusNextPanelCommand { 274 | pub fn id() -> &'static str { 275 | "focus-next-panel" 276 | } 277 | } 278 | 279 | impl EditorCommand for FocusNextPanelCommand { 280 | fn matches(&self, input: &str) -> bool { 281 | self.text().to_lowercase().contains(&input.to_lowercase()) 282 | } 283 | 284 | fn id(&self) -> &str { 285 | Self::id() 286 | } 287 | 288 | fn text(&self) -> &str { 289 | "Focus Next Panel" 290 | } 291 | 292 | fn run(&self, _ctx: &mut CommandRunContext) { 293 | let mut radio_app_state = self.0; 294 | let mut app_state = radio_app_state.write_channel(Channel::Global); 295 | app_state.focus_next_panel(); 296 | } 297 | } 298 | 299 | #[derive(Clone)] 300 | pub struct FocusPreviousPanelCommand(pub RadioAppState); 301 | 302 | impl FocusPreviousPanelCommand { 303 | pub fn id() -> &'static str { 304 | "focus-previous-panel" 305 | } 306 | } 307 | 308 | impl EditorCommand for FocusPreviousPanelCommand { 309 | fn matches(&self, input: &str) -> bool { 310 | self.text().to_lowercase().contains(&input.to_lowercase()) 311 | } 312 | 313 | fn id(&self) -> &str { 314 | Self::id() 315 | } 316 | 317 | fn text(&self) -> &str { 318 | "Focus Previous Panel" 319 | } 320 | 321 | fn run(&self, _ctx: &mut CommandRunContext) { 322 | let mut radio_app_state = self.0; 323 | let mut app_state = radio_app_state.write_channel(Channel::Global); 324 | app_state.focus_previous_panel(); 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/hooks/mod.rs: -------------------------------------------------------------------------------- 1 | mod use_computed; 2 | mod use_debounce; 3 | mod use_edit; 4 | mod use_lsp_status; 5 | 6 | pub use use_computed::*; 7 | pub use use_debounce::*; 8 | pub use use_edit::*; 9 | pub use use_lsp_status::*; 10 | -------------------------------------------------------------------------------- /src/hooks/use_computed.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use freya::prelude::use_hook; 4 | 5 | pub struct Memoized { 6 | pub value: T, 7 | pub deps: D, 8 | } 9 | 10 | pub fn use_computed(deps: &D, init: impl Fn(&D) -> T) -> Rc>> 11 | where 12 | D: PartialEq + 'static + ToOwned, 13 | D::Owned: PartialEq, 14 | { 15 | let memo = use_hook(|| { 16 | Rc::new(RefCell::new(Memoized { 17 | value: init(deps), 18 | deps: deps.to_owned(), 19 | })) 20 | }); 21 | 22 | let deps_have_changed = &memo.borrow().deps != deps; 23 | 24 | if deps_have_changed { 25 | memo.borrow_mut().value = init(deps); 26 | memo.borrow_mut().deps = deps.to_owned(); 27 | } 28 | 29 | memo 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/use_debounce.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use futures::{ 3 | channel::mpsc::{self, UnboundedSender as Sender}, 4 | StreamExt, 5 | }; 6 | use std::time::Duration; 7 | 8 | /// The interface for calling a debounce. 9 | /// 10 | /// See [`use_debounce`] for more information. 11 | pub struct UseDebounce { 12 | sender: Signal>, 13 | cancel: Signal, 14 | } 15 | 16 | impl UseDebounce { 17 | /// Will start the debounce countdown, resetting it if already started. 18 | pub fn action(&mut self, data: T) { 19 | self.cancel.set(false); 20 | self.sender.write().unbounded_send(data).ok(); 21 | } 22 | 23 | pub fn cancel(&mut self) { 24 | self.cancel.set(true); 25 | } 26 | } 27 | 28 | // Manually implement Clone, Copy, and PartialEq as #[derive] thinks that T needs to implement these (it doesn't). 29 | 30 | impl Clone for UseDebounce { 31 | fn clone(&self) -> Self { 32 | *self 33 | } 34 | } 35 | 36 | impl Copy for UseDebounce {} 37 | 38 | impl PartialEq for UseDebounce { 39 | fn eq(&self, other: &Self) -> bool { 40 | self.sender == other.sender 41 | } 42 | } 43 | 44 | pub fn use_debounce(time: Duration, cb: impl FnOnce(T) + Copy + 'static) -> UseDebounce { 45 | use_hook(|| { 46 | let (sender, mut receiver) = mpsc::unbounded(); 47 | let mut cancel = Signal::new(false); 48 | let debouncer = UseDebounce { 49 | sender: Signal::new(sender), 50 | cancel, 51 | }; 52 | 53 | spawn(async move { 54 | let mut current_task: Option = None; 55 | 56 | loop { 57 | if let Some(data) = receiver.next().await { 58 | if let Some(task) = current_task.take() { 59 | task.cancel(); 60 | } 61 | 62 | current_task = Some(spawn(async move { 63 | #[cfg(not(target_family = "wasm"))] 64 | tokio::time::sleep(time).await; 65 | 66 | #[cfg(target_family = "wasm")] 67 | gloo_timers::future::sleep(time).await; 68 | 69 | if *cancel.peek() { 70 | cancel.set(false); 71 | return; 72 | } 73 | 74 | cb(data); 75 | })); 76 | } 77 | } 78 | }); 79 | 80 | debouncer 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /src/hooks/use_edit.rs: -------------------------------------------------------------------------------- 1 | use dioxus::dioxus_core::AttributeValue; 2 | use dioxus_radio::prelude::ChannelSelection; 3 | use uuid::Uuid; 4 | 5 | use crate::{ 6 | state::TabId, 7 | views::panels::tabs::editor::{AppStateEditorUtils, EditorTab}, 8 | }; 9 | use freya::{ 10 | core::custom_attributes::{CursorLayoutResponse, CursorReference}, 11 | prelude::*, 12 | }; 13 | use tokio::sync::mpsc::unbounded_channel; 14 | 15 | use crate::state::RadioAppState; 16 | 17 | /// Manage an editable content. 18 | #[derive(Clone, Copy, PartialEq)] 19 | pub struct UseEdit { 20 | pub(crate) cursor_reference: CopyValue, 21 | } 22 | 23 | impl UseEdit { 24 | /// Create a cursor attribute. 25 | pub fn cursor_attr(&self) -> AttributeValue { 26 | AttributeValue::any_value(CustomAttributeValues::CursorReference( 27 | self.cursor_reference.read().clone(), 28 | )) 29 | } 30 | 31 | /// Check if there is any highlight at all. 32 | pub fn has_any_highlight(&self, editor_tab: &EditorTab) -> bool { 33 | editor_tab 34 | .editor 35 | .selected 36 | .map(|highlight| highlight.0 != highlight.1) 37 | .unwrap_or_default() 38 | } 39 | 40 | /// Create a highlights attribute. 41 | pub fn highlights_attr(&self, editor_id: usize, editor_tab: &EditorTab) -> AttributeValue { 42 | AttributeValue::any_value(CustomAttributeValues::TextHighlights( 43 | editor_tab 44 | .editor 45 | .get_visible_selection(editor_id) 46 | .map(|v| vec![v]) 47 | .unwrap_or_default(), 48 | )) 49 | } 50 | } 51 | 52 | pub fn use_edit(mut radio: RadioAppState, tab_id: TabId, text_id: Uuid) -> UseEdit { 53 | use_hook(|| { 54 | let (cursor_sender, mut cursor_receiver) = unbounded_channel::(); 55 | 56 | let cursor_reference = CopyValue::new(CursorReference { 57 | text_id, 58 | cursor_sender, 59 | }); 60 | 61 | spawn(async move { 62 | while let Some(message) = cursor_receiver.recv().await { 63 | match message { 64 | // Update the cursor position calculated by the layout 65 | CursorLayoutResponse::CursorPosition { position, id } => { 66 | radio.write_with_channel_selection(|app_state| { 67 | let editor_tab = app_state.editor_tab(tab_id); 68 | 69 | let new_cursor = editor_tab.editor.measure_new_cursor( 70 | editor_tab.editor.utf16_cu_to_char(position), 71 | id, 72 | ); 73 | 74 | // Only update and clear the selection if the cursor has changed 75 | if editor_tab.editor.cursor() != new_cursor { 76 | let editor_tab = app_state.editor_tab_mut(tab_id); 77 | *editor_tab.editor.cursor_mut() = new_cursor; 78 | if let TextDragging::FromCursorToPoint { cursor: from, .. } = 79 | &editor_tab.editor.dragging 80 | { 81 | let to = editor_tab.editor.cursor_pos(); 82 | editor_tab.editor.set_selection((*from, to)); 83 | } else { 84 | editor_tab.editor.clear_selection(); 85 | } 86 | ChannelSelection::Current 87 | } else { 88 | ChannelSelection::Silence 89 | } 90 | }); 91 | } 92 | // Update the text selections calculated by the layout 93 | CursorLayoutResponse::TextSelection { from, to, id } => { 94 | radio.write_with_channel_selection(|app_state| { 95 | let mut channel = ChannelSelection::Silence; 96 | 97 | let editor_tab = app_state.editor_tab_mut(tab_id); 98 | 99 | let current_cursor = editor_tab.editor.cursor(); 100 | let current_selection = editor_tab.editor.get_selection(); 101 | 102 | let maybe_new_cursor = editor_tab.editor.measure_new_cursor(to, id); 103 | let maybe_new_selection = 104 | editor_tab.editor.measure_new_selection(from, to, id); 105 | 106 | // Update the text selection if it has changed 107 | if let Some(current_selection) = current_selection { 108 | if current_selection != maybe_new_selection { 109 | editor_tab.editor.set_selection(maybe_new_selection); 110 | channel.current(); 111 | } 112 | } else { 113 | editor_tab.editor.set_selection(maybe_new_selection); 114 | channel.current(); 115 | } 116 | 117 | // Update the cursor if it has changed 118 | if current_cursor != maybe_new_cursor { 119 | *editor_tab.editor.cursor_mut() = maybe_new_cursor; 120 | channel.current(); 121 | } 122 | 123 | channel 124 | }); 125 | } 126 | } 127 | } 128 | }); 129 | 130 | UseEdit { cursor_reference } 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /src/hooks/use_lsp_status.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use freya::prelude::*; 4 | use tokio::sync::mpsc; 5 | 6 | pub type LspStatuses = Signal>; 7 | pub type LspStatusSender = mpsc::UnboundedSender<(String, String)>; 8 | 9 | pub fn use_lsp_status() -> (LspStatuses, LspStatusSender) { 10 | let mut statuses = use_signal::>(HashMap::default); 11 | 12 | let sender = use_hook(move || { 13 | let (tx, mut rx) = mpsc::unbounded_channel(); 14 | 15 | spawn(async move { 16 | while let Some((name, val)) = rx.recv().await { 17 | statuses.write().insert(name, val); 18 | } 19 | }); 20 | 21 | tx 22 | }); 23 | 24 | (statuses, sender) 25 | } 26 | -------------------------------------------------------------------------------- /src/icons/logo_disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/icons/logo_enabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/lsp/client.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::process::Stdio; 3 | use std::sync::{Arc, Mutex}; 4 | use std::{fmt::Display, ops::ControlFlow}; 5 | 6 | use async_lsp::concurrency::ConcurrencyLayer; 7 | use async_lsp::panic::CatchUnwindLayer; 8 | use async_lsp::router::Router; 9 | use async_lsp::tracing::TracingLayer; 10 | use async_lsp::{LanguageServer, ServerSocket}; 11 | use freya::prelude::spawn_forever; 12 | use lsp_types::{ 13 | notification::{Progress, PublishDiagnostics, ShowMessage}, 14 | DidCloseTextDocumentParams, DidOpenTextDocumentParams, HoverParams, TextDocumentIdentifier, 15 | TextDocumentItem, 16 | }; 17 | use lsp_types::{ 18 | ClientCapabilities, HoverContents, InitializeParams, InitializedParams, MarkedString, 19 | NumberOrString, Position, ProgressParamsValue, TextDocumentPositionParams, Url, 20 | WindowClientCapabilities, WorkDoneProgress, WorkDoneProgressParams, WorkspaceFolder, 21 | }; 22 | use tokio::process::Command; 23 | use tokio::sync::mpsc::{self, UnboundedSender}; 24 | use tower::ServiceBuilder; 25 | use tracing::info; 26 | 27 | use crate::state::{AppState, Channel, RadioAppState, TabId}; 28 | use crate::views::panels::tabs::editor::{Diagnostics, TabEditorUtils}; 29 | use crate::{views::panels::tabs::editor::EditorType, LspStatusSender}; 30 | 31 | struct RouterState { 32 | pub(crate) indexed: Arc>, 33 | pub(crate) lsp_sender: LspStatusSender, 34 | pub(crate) language_server: String, 35 | } 36 | 37 | #[derive(PartialEq, Debug)] 38 | pub struct LspAction { 39 | pub tab_id: TabId, 40 | pub action: LspActionData, 41 | } 42 | 43 | #[derive(PartialEq, Debug)] 44 | pub enum LspActionData { 45 | Initialize(PathBuf), 46 | OpenFile, 47 | CloseFile { file_uri: Url }, 48 | Hover { position: Position }, 49 | Clear, 50 | } 51 | 52 | struct Stop; 53 | 54 | #[derive(Clone)] 55 | pub struct LSPClient { 56 | pub(crate) tx: UnboundedSender, 57 | } 58 | 59 | impl LSPClient { 60 | pub fn send(&self, action: LspAction) { 61 | self.tx.send(action).unwrap(); 62 | } 63 | 64 | pub fn open_with( 65 | mut radio_app_state: RadioAppState, 66 | app_state: &mut AppState, 67 | lsp_config: &LspConfig, 68 | ) -> (Self, bool) { 69 | let server = app_state.lsp(lsp_config).cloned(); 70 | match server { 71 | Some(server) => (server, false), 72 | None => { 73 | let lsp_sender = app_state.lsp_sender.clone(); 74 | let (tx, mut rx) = mpsc::unbounded_channel::(); 75 | let (client, indexed, mut server) = Self::new(lsp_config, lsp_sender, tx); 76 | let language_id = lsp_config.editor_type.language_id(); 77 | spawn_forever({ 78 | async move { 79 | while let Some(action) = rx.recv().await { 80 | let is_indexed = *indexed.lock().unwrap(); 81 | match action.action { 82 | LspActionData::Initialize(root_path) if !is_indexed => { 83 | let root_uri = Url::from_file_path(&root_path).unwrap(); 84 | let _init_ret = server 85 | .initialize(InitializeParams { 86 | workspace_folders: Some(vec![WorkspaceFolder { 87 | uri: root_uri, 88 | name: root_path.display().to_string(), 89 | }]), 90 | capabilities: ClientCapabilities { 91 | window: Some(WindowClientCapabilities { 92 | work_done_progress: Some(true), 93 | ..WindowClientCapabilities::default() 94 | }), 95 | ..ClientCapabilities::default() 96 | }, 97 | ..InitializeParams::default() 98 | }) 99 | .await 100 | .unwrap(); 101 | 102 | server.initialized(InitializedParams {}).unwrap(); 103 | } 104 | LspActionData::OpenFile => { 105 | let app_state = radio_app_state.read(); 106 | let tab = app_state.tab(&action.tab_id); 107 | let editor_tab = tab.as_text_editor().unwrap(); 108 | let Some(file_uri) = editor_tab.editor.uri() else { 109 | return; 110 | }; 111 | let file_content = editor_tab.editor.content(); 112 | info!("Opened document [uri={file_uri}]",); 113 | server 114 | .did_open(DidOpenTextDocumentParams { 115 | text_document: TextDocumentItem { 116 | uri: file_uri, 117 | language_id: language_id.to_string(), 118 | version: 0, 119 | text: file_content, 120 | }, 121 | }) 122 | .unwrap(); 123 | } 124 | 125 | LspActionData::CloseFile { file_uri } => { 126 | info!("Closed document [uri={file_uri}] from LSP"); 127 | server 128 | .did_close(DidCloseTextDocumentParams { 129 | text_document: TextDocumentIdentifier { uri: file_uri }, 130 | }) 131 | .unwrap(); 132 | } 133 | LspActionData::Hover { position } if is_indexed => { 134 | let Some(file_uri) = ({ 135 | let app_state = radio_app_state.read(); 136 | let tab = app_state.tab(&action.tab_id); 137 | let editor_tab = tab.as_text_editor().unwrap(); 138 | editor_tab.editor.uri() 139 | }) else { 140 | return; 141 | }; 142 | let response = server 143 | .hover(HoverParams { 144 | text_document_position_params: 145 | TextDocumentPositionParams { 146 | text_document: TextDocumentIdentifier { 147 | uri: file_uri, 148 | }, 149 | position, 150 | }, 151 | work_done_progress_params: 152 | WorkDoneProgressParams::default(), 153 | }) 154 | .await; 155 | if let Ok(Some(response)) = response { 156 | let content = match response.contents { 157 | HoverContents::Markup(contents) => { 158 | contents.value.to_owned() 159 | } 160 | HoverContents::Array(contents) => contents 161 | .iter() 162 | .map(|v| match v { 163 | MarkedString::String(v) => v.to_owned(), 164 | MarkedString::LanguageString(text) => { 165 | text.value.to_owned() 166 | } 167 | }) 168 | .collect::>() 169 | .join("\n"), 170 | HoverContents::Scalar(v) => match v { 171 | MarkedString::String(v) => v.to_owned(), 172 | MarkedString::LanguageString(text) => { 173 | text.value.to_owned() 174 | } 175 | }, 176 | }; 177 | if content != "()" { 178 | let mut app_state = radio_app_state 179 | .write_channel(Channel::follow_tab(action.tab_id)); 180 | let tab = app_state.tab_mut(&action.tab_id); 181 | let editor_tab = tab.as_text_editor_mut().unwrap(); 182 | editor_tab.editor.diagnostics = Some(Diagnostics { 183 | range: response.range.unwrap_or_default(), 184 | content, 185 | line: position.line, 186 | }) 187 | } 188 | } 189 | } 190 | LspActionData::Clear => { 191 | let mut app_state = radio_app_state 192 | .write_channel(Channel::follow_tab(action.tab_id)); 193 | let tab = app_state.tab_mut(&action.tab_id); 194 | let editor_tab = tab.as_text_editor_mut().unwrap(); 195 | editor_tab.editor.diagnostics.take(); 196 | } 197 | _ if is_indexed => { 198 | info!("Language Server is indexing."); 199 | } 200 | _ => {} 201 | } 202 | } 203 | } 204 | }); 205 | (client, true) 206 | } 207 | } 208 | } 209 | 210 | pub fn new( 211 | config: &LspConfig, 212 | lsp_sender: LspStatusSender, 213 | tx: UnboundedSender, 214 | ) -> (Self, Arc>, ServerSocket) { 215 | let indexed = Arc::new(Mutex::new(false)); 216 | let (_, root_path) = config.editor_type.paths().expect("Something went wrong."); 217 | 218 | let (mainloop, server) = async_lsp::MainLoop::new_client(|_server| { 219 | let mut router = Router::new(RouterState { 220 | indexed: indexed.clone(), 221 | lsp_sender, 222 | language_server: config.language_server.clone(), 223 | }); 224 | router 225 | .notification::(|client_state, prog| { 226 | if matches!(prog.token, NumberOrString::String(s) if s == "rustAnalyzer/Indexing") { 227 | match prog.value { 228 | ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(begin)) => { 229 | *client_state.indexed.lock().unwrap() = false; 230 | 231 | let mut content = begin.title; 232 | 233 | if let Some(message) = begin.message { 234 | content.push(' '); 235 | content.push_str(&message); 236 | } 237 | 238 | client_state.lsp_sender.send(( 239 | client_state.language_server.clone(), 240 | content 241 | )).ok(); 242 | } 243 | ProgressParamsValue::WorkDone(WorkDoneProgress::Report(report)) => { 244 | let percentage = report.percentage.map(|v| { 245 | if v < 100 { 246 | format!("{v}%") 247 | } else { 248 | String::default() 249 | } 250 | }); 251 | client_state.lsp_sender.send(( 252 | client_state.language_server.clone(), 253 | format!( 254 | "{} {}", 255 | percentage.unwrap_or_default(), 256 | report.message.clone().unwrap_or_default() 257 | ), 258 | )).ok(); 259 | } 260 | ProgressParamsValue::WorkDone(WorkDoneProgress::End(end)) => { 261 | *client_state.indexed.lock().unwrap() = true; 262 | client_state.lsp_sender.send(( 263 | client_state.language_server.clone(), 264 | end.message.unwrap_or_default() 265 | )).ok(); 266 | } 267 | } 268 | } 269 | ControlFlow::Continue(()) 270 | }) 271 | .notification::(|_, _| ControlFlow::Continue(())) 272 | .notification::(|_, _params| ControlFlow::Continue(())) 273 | .event(|_, _: Stop| ControlFlow::Break(Ok(()))); 274 | 275 | ServiceBuilder::new() 276 | .layer(TracingLayer::default()) 277 | .layer(CatchUnwindLayer::default()) 278 | .layer(ConcurrencyLayer::default()) 279 | .service(router) 280 | }); 281 | 282 | let child = Command::new(&config.language_server) 283 | .current_dir(root_path) 284 | .stdin(Stdio::piped()) 285 | .stdout(Stdio::piped()) 286 | .stderr(Stdio::inherit()) 287 | .spawn() 288 | .expect("Failed to start Language Server."); 289 | let stdout = tokio_util::compat::TokioAsyncReadCompatExt::compat(child.stdout.unwrap()); 290 | let stdin = 291 | tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(child.stdin.unwrap()); 292 | 293 | let _mainloop_fut = spawn_forever(async move { 294 | mainloop.run_buffered(stdout, stdin).await.ok(); 295 | }); 296 | 297 | (LSPClient { tx }, indexed, server) 298 | } 299 | } 300 | 301 | #[derive(Clone)] 302 | pub struct LspConfig { 303 | pub(crate) editor_type: EditorType, 304 | pub(crate) language_server: String, 305 | } 306 | 307 | impl LspConfig { 308 | pub fn new(editor_type: EditorType) -> Option { 309 | let language_server = editor_type.language_id().language_server()?.to_string(); 310 | 311 | Some(Self { 312 | editor_type, 313 | language_server, 314 | }) 315 | } 316 | } 317 | 318 | #[derive(Default, Clone, Debug, PartialEq, Copy)] 319 | pub enum LanguageId { 320 | Rust, 321 | Python, 322 | JavaScript, 323 | TypeScript, 324 | Markdown, 325 | #[default] 326 | Unknown, 327 | } 328 | 329 | impl Display for LanguageId { 330 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 331 | match self { 332 | Self::Rust => f.write_str("Rust"), 333 | Self::Python => f.write_str("Python"), 334 | Self::JavaScript => f.write_str("JavaScript"), 335 | Self::TypeScript => f.write_str("TypeScript"), 336 | Self::Markdown => f.write_str("Markdown"), 337 | Self::Unknown => f.write_str("Unknown"), 338 | } 339 | } 340 | } 341 | 342 | impl LanguageId { 343 | pub fn parse(id: &str) -> Self { 344 | match id { 345 | "rs" => LanguageId::Rust, 346 | "py" => LanguageId::Python, 347 | "js" => LanguageId::JavaScript, 348 | "ts" => LanguageId::TypeScript, 349 | "md" => LanguageId::Markdown, 350 | _ => LanguageId::Unknown, 351 | } 352 | } 353 | 354 | pub fn language_server(&self) -> Option<&str> { 355 | match self { 356 | LanguageId::Rust => Some("rust-analyzer"), 357 | _ => None, 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/lsp/mod.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | 3 | pub use client::*; 4 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | mod app; 7 | mod components; 8 | mod constants; 9 | mod fs; 10 | mod global_defaults; 11 | mod hooks; 12 | mod lsp; 13 | mod metrics; 14 | mod parser; 15 | mod settings; 16 | mod state; 17 | mod utils; 18 | mod views; 19 | 20 | use std::{path::PathBuf, sync::Arc}; 21 | 22 | use crate::app::App; 23 | use clap::Parser; 24 | use components::*; 25 | use freya::prelude::*; 26 | use hooks::*; 27 | use tracing::info; 28 | use tracing_subscriber::{EnvFilter, FmtSubscriber}; 29 | 30 | const CUSTOM_THEME: Theme = Theme { 31 | button: ButtonTheme { 32 | border_fill: Cow::Borrowed("rgb(45, 49, 50)"), 33 | background: Cow::Borrowed("rgb(28, 31, 32)"), 34 | hover_background: Cow::Borrowed("rgb(20, 23, 24)"), 35 | ..DARK_THEME.button 36 | }, 37 | input: InputTheme { 38 | background: Cow::Borrowed("rgb(28, 31, 32)"), 39 | ..DARK_THEME.input 40 | }, 41 | ..DARK_THEME 42 | }; 43 | 44 | #[derive(Parser, Debug)] 45 | #[command(version, about, long_about = None)] 46 | struct Args { 47 | /// Enable Support for language servers. 48 | #[arg(short, long)] 49 | lsp: bool, 50 | 51 | // Open certain folders or files. 52 | #[arg(num_args(0..))] 53 | paths: Vec, 54 | 55 | /// Enable the performance overlay. 56 | #[arg(short, long)] 57 | performance_overlay: bool, 58 | } 59 | 60 | fn main() { 61 | let subscriber = FmtSubscriber::builder() 62 | .with_env_filter( 63 | EnvFilter::builder() 64 | .with_default_directive("valin=debug".parse().unwrap()) 65 | .from_env() 66 | .unwrap() 67 | .add_directive("dioxus_radio=debug".parse().unwrap()), 68 | ) 69 | .finish(); 70 | 71 | tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); 72 | 73 | let args = Args::parse(); 74 | 75 | info!("Starting valin. \n{args:#?}"); 76 | 77 | let mut config = LaunchConfig::>::default(); 78 | 79 | if args.performance_overlay { 80 | config = config.with_plugin(PerformanceOverlayPlugin::default()) 81 | } 82 | 83 | launch_cfg( 84 | || { 85 | rsx!( 86 | ThemeProvider { 87 | theme: CUSTOM_THEME, 88 | App {} 89 | } 90 | ) 91 | }, 92 | config 93 | .with_size(1280.0, 720.0) 94 | .with_title("Valin") 95 | .with_state(Arc::new(args)), // .with_max_paragraph_cache_size(200), 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/metrics.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::cmp::Ordering; 3 | 4 | use freya::prelude::*; 5 | use skia_safe::scalar; 6 | use skia_safe::textlayout::FontCollection; 7 | use skia_safe::textlayout::ParagraphBuilder; 8 | use skia_safe::textlayout::ParagraphStyle; 9 | use skia_safe::textlayout::TextStyle; 10 | 11 | use crate::parser::*; 12 | 13 | pub struct EditorMetrics { 14 | pub(crate) syntax_blocks: SyntaxBlocks, 15 | pub(crate) longest_width: f32, 16 | } 17 | 18 | impl EditorMetrics { 19 | pub fn new() -> Self { 20 | Self { 21 | syntax_blocks: SyntaxBlocks::default(), 22 | longest_width: 0.0, 23 | } 24 | } 25 | 26 | pub fn measure_longest_line( 27 | &mut self, 28 | font_size: f32, 29 | rope: &Rope, 30 | font_collection: &FontCollection, 31 | ) { 32 | let mut paragraph_style = ParagraphStyle::default(); 33 | let mut text_style = TextStyle::default(); 34 | text_style.set_font_size(font_size); 35 | paragraph_style.set_text_style(&text_style); 36 | let mut paragraph_builder = ParagraphBuilder::new(¶graph_style, font_collection); 37 | 38 | let mut longest_line: Vec> = vec![]; 39 | 40 | for line in rope.lines() { 41 | let current_longest_width = longest_line.first().map(|l| l.len()).unwrap_or_default(); 42 | 43 | let line_len = line.len_chars(); 44 | 45 | match line_len.cmp(¤t_longest_width) { 46 | Ordering::Greater => { 47 | longest_line.clear(); 48 | longest_line.push(line.into()) 49 | } 50 | Ordering::Equal => longest_line.push(line.into()), 51 | _ => {} 52 | } 53 | } 54 | 55 | for line in longest_line { 56 | paragraph_builder.add_text(line); 57 | } 58 | 59 | let mut paragraph = paragraph_builder.build(); 60 | 61 | paragraph.layout(scalar::MAX); 62 | 63 | self.longest_width = paragraph.longest_line(); 64 | } 65 | 66 | pub fn run_parser(&mut self, rope: &Rope) { 67 | parse(rope, &mut self.syntax_blocks); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, ops::Range}; 2 | 3 | use fxhash::FxHashMap; 4 | use ropey::Rope; 5 | use smallvec::SmallVec; 6 | 7 | const LARGE_FILE: usize = 45_000_000; 8 | 9 | #[derive(Clone, Debug)] 10 | pub enum SyntaxType { 11 | String, 12 | Keyword, 13 | SpecialKeyword, 14 | Punctuation, 15 | Punctuation2, 16 | Unknown, 17 | Property, 18 | Module, 19 | Comment, 20 | SpaceMark, 21 | } 22 | 23 | impl SyntaxType { 24 | pub fn color(&self) -> &str { 25 | match self { 26 | SyntaxType::Keyword => "rgb(251, 60, 44)", 27 | SyntaxType::String => "rgb(151, 151, 26)", 28 | SyntaxType::Punctuation => "rgb(104, 157, 96)", 29 | SyntaxType::Punctuation2 => "rgb(252, 188, 61)", 30 | SyntaxType::Unknown => "rgb(223, 191, 142)", 31 | SyntaxType::Property => "rgb(152, 192, 124)", 32 | SyntaxType::Module => "rgb(250, 189, 40)", 33 | SyntaxType::SpecialKeyword => "rgb(211, 134, 155)", 34 | SyntaxType::Comment => "gray", 35 | SyntaxType::SpaceMark => "rgb(223, 191, 142, 0.2)", 36 | } 37 | } 38 | } 39 | 40 | #[derive(Clone, Copy, PartialEq, Debug)] 41 | enum SyntaxSemantic { 42 | Unknown, 43 | PropertyAccess, 44 | } 45 | 46 | impl From for SyntaxType { 47 | fn from(value: SyntaxSemantic) -> Self { 48 | match value { 49 | SyntaxSemantic::PropertyAccess => SyntaxType::Property, 50 | SyntaxSemantic::Unknown => SyntaxType::Unknown, 51 | } 52 | } 53 | } 54 | 55 | pub enum TextNode { 56 | Range(Range), 57 | LineOfChars { len: usize, char: char }, 58 | } 59 | 60 | pub type SyntaxLine = SmallVec<[(SyntaxType, TextNode); 4]>; 61 | 62 | #[derive(Default)] 63 | pub struct SyntaxBlocks { 64 | blocks: FxHashMap, 65 | } 66 | 67 | impl SyntaxBlocks { 68 | pub fn push_line(&mut self, line: SyntaxLine) { 69 | self.blocks.insert(self.len(), line); 70 | } 71 | 72 | pub fn get_line(&self, line: usize) -> &[(SyntaxType, TextNode)] { 73 | self.blocks.get(&line).unwrap() 74 | } 75 | 76 | pub fn len(&self) -> usize { 77 | self.blocks.len() 78 | } 79 | 80 | pub fn clear(&mut self) { 81 | self.blocks.clear(); 82 | } 83 | } 84 | 85 | const GENERIC_KEYWORDS: &[&str] = &[ 86 | "mod", "use", "impl", "if", "let", "fn", "struct", "enum", "const", "pub", "crate", "else", 87 | "mut", "for", "i8", "u8", "i16", "u16", "i32", "u32", "f32", "i64", "u64", "f64", "i128", 88 | "u128", "usize", "isize", "move", "async", "in", "of", "dyn", "type", "match", 89 | ]; 90 | 91 | const SPECIAL_KEYWORDS: &[&str] = &["self", "Self", "false", "true"]; 92 | 93 | const SPECIAL_CHARACTER: &[char] = &['.', '=', ';', ':', '\'', ',', '#', '&', '-', '+', '^', '\\']; 94 | 95 | const SPECIAL_CHARACTER_2: &[char] = &['{', '}', '(', ')', '>', '<', '[', ']']; 96 | 97 | #[derive(PartialEq, Clone, Debug)] 98 | enum CommentTracking { 99 | None, 100 | OneLine, 101 | MultiLine, 102 | } 103 | 104 | fn flush_generic_stack( 105 | rope: &Rope, 106 | generic_stack: &mut Option>, 107 | syntax_blocks: &mut SyntaxLine, 108 | last_semantic: &mut SyntaxSemantic, 109 | ch: char, 110 | ) { 111 | if let Some(word_pos) = generic_stack { 112 | let word: Cow = rope.slice(word_pos.clone()).into(); 113 | let trimmed = word.trim(); 114 | if trimmed.is_empty() { 115 | return; 116 | } 117 | 118 | let word_pos = generic_stack.take().unwrap(); 119 | let next_char = rope 120 | .get_slice(word_pos.end + 1..word_pos.end + 2) 121 | .and_then(|s| s.as_str()); 122 | 123 | if ch == ':' && Some(":") == next_char { 124 | syntax_blocks.push((SyntaxType::Module, TextNode::Range(word_pos))); 125 | } 126 | // Match special keywords 127 | else if GENERIC_KEYWORDS.contains(&trimmed) { 128 | syntax_blocks.push((SyntaxType::Keyword, TextNode::Range(word_pos))); 129 | } 130 | // Match other special keyword, CONSTANTS and numbers 131 | else if SPECIAL_KEYWORDS.contains(&trimmed) || word.to_uppercase() == word { 132 | syntax_blocks.push((SyntaxType::SpecialKeyword, TextNode::Range(word_pos))); 133 | } 134 | // Match anything else 135 | else { 136 | syntax_blocks.push(((*last_semantic).into(), TextNode::Range(word_pos))); 137 | } 138 | 139 | *last_semantic = SyntaxSemantic::Unknown; 140 | } 141 | } 142 | 143 | fn flush_spaces_stack( 144 | rope: &Rope, 145 | generic_stack: &mut Option>, 146 | syntax_blocks: &mut SyntaxLine, 147 | begining_of_line: bool, 148 | line_is_ending: bool, 149 | ) { 150 | if let Some(word_pos) = &generic_stack { 151 | let word: Cow = rope.slice(word_pos.clone()).into(); 152 | let trimmed = word.trim(); 153 | if trimmed.is_empty() { 154 | let range = generic_stack.take().unwrap(); 155 | if !line_is_ending && begining_of_line { 156 | syntax_blocks.push(( 157 | SyntaxType::SpaceMark, 158 | TextNode::LineOfChars { 159 | len: range.end - range.start, 160 | char: '·', 161 | }, 162 | )); 163 | } else { 164 | syntax_blocks.push((SyntaxType::Unknown, TextNode::Range(range))); 165 | } 166 | } 167 | } 168 | } 169 | 170 | pub fn parse(rope: &Rope, syntax_blocks: &mut SyntaxBlocks) { 171 | // Clear any blocks from before 172 | syntax_blocks.clear(); 173 | 174 | if rope.len_chars() >= LARGE_FILE { 175 | for (n, line) in rope.lines().enumerate() { 176 | let mut line_blocks = SmallVec::default(); 177 | let start = rope.line_to_char(n); 178 | let end = line.len_chars(); 179 | line_blocks.push((SyntaxType::Unknown, TextNode::Range(start..start + end))); 180 | syntax_blocks.push_line(line_blocks); 181 | } 182 | return; 183 | } 184 | 185 | // Track comments 186 | let mut tracking_comment = CommentTracking::None; 187 | let mut comment_stack: Option> = None; 188 | 189 | // Track strings 190 | let mut tracking_string = false; 191 | let mut string_stack: Option> = None; 192 | 193 | // Track anything else 194 | let mut generic_stack: Option> = None; 195 | let mut last_semantic = SyntaxSemantic::Unknown; 196 | 197 | // Elements of the current line 198 | let mut line = SyntaxLine::new(); 199 | let mut begining_of_line = true; 200 | 201 | for (i, ch) in rope.chars().enumerate() { 202 | let is_last_character = rope.len_chars() - 1 == i; 203 | 204 | // Ignore the return 205 | if ch == '\r' { 206 | continue; 207 | } 208 | 209 | // Flush all whitespaces from the backback if the character is not an space 210 | if !ch.is_whitespace() { 211 | flush_spaces_stack( 212 | rope, 213 | &mut generic_stack, 214 | &mut line, 215 | begining_of_line, 216 | is_last_character, 217 | ); 218 | 219 | begining_of_line = false; 220 | } 221 | 222 | // Stop tracking a string 223 | if tracking_string && ch == '"' { 224 | flush_generic_stack(rope, &mut generic_stack, &mut line, &mut last_semantic, ch); 225 | 226 | let mut st = string_stack.take().unwrap_or_default(); 227 | st.end += 1; 228 | 229 | // Strings 230 | line.push((SyntaxType::String, TextNode::Range(st))); 231 | tracking_string = false; 232 | } 233 | // Start tracking a string 234 | else if tracking_comment == CommentTracking::None && ch == '"' { 235 | string_stack = Some(i..i + 1); 236 | tracking_string = true; 237 | } 238 | // While tracking a comment 239 | else if tracking_comment != CommentTracking::None { 240 | if let Some(ct) = comment_stack.as_mut() { 241 | ct.end = i + 1; 242 | 243 | let current_comment: Cow = rope.slice(ct.clone()).into(); 244 | 245 | // Stop a multi line comment 246 | if ch == '/' && current_comment.ends_with("*/") { 247 | generic_stack.take(); 248 | line.push(( 249 | SyntaxType::Comment, 250 | TextNode::Range(comment_stack.take().unwrap()), 251 | )); 252 | tracking_comment = CommentTracking::None; 253 | } 254 | } else { 255 | comment_stack = Some(i..i + 1); 256 | } 257 | } 258 | // While tracking a string 259 | else if tracking_string { 260 | push_to_stack(&mut string_stack, i); 261 | } 262 | // If is a special character 263 | else if SPECIAL_CHARACTER.contains(&ch) { 264 | flush_generic_stack(rope, &mut generic_stack, &mut line, &mut last_semantic, ch); 265 | 266 | if ch == '.' { 267 | last_semantic = SyntaxSemantic::PropertyAccess; 268 | } 269 | 270 | // Punctuation 271 | line.push((SyntaxType::Punctuation, TextNode::Range(i..i + 1))); 272 | } 273 | // If is a special character 2 274 | else if SPECIAL_CHARACTER_2.contains(&ch) { 275 | flush_generic_stack(rope, &mut generic_stack, &mut line, &mut last_semantic, ch); 276 | 277 | if ch == '.' { 278 | last_semantic = SyntaxSemantic::PropertyAccess; 279 | } 280 | 281 | // Punctuation 282 | line.push((SyntaxType::Punctuation2, TextNode::Range(i..i + 1))); 283 | } 284 | // Unknown (for now at least) characters 285 | else { 286 | // Start tracking a comment (both one line and multine) 287 | if tracking_comment == CommentTracking::None && (ch == '*' || ch == '/') { 288 | if let Some(us) = generic_stack.as_mut() { 289 | let generic_stack_text: Cow = rope.slice(us.clone()).into(); 290 | if generic_stack_text == "/" { 291 | comment_stack = generic_stack.take(); 292 | 293 | push_to_stack(&mut comment_stack, i); 294 | 295 | if ch == '*' { 296 | tracking_comment = CommentTracking::MultiLine 297 | } else if ch == '/' { 298 | tracking_comment = CommentTracking::OneLine 299 | } 300 | } 301 | } 302 | } 303 | 304 | // Flush the generic stack before adding the space 305 | if ch.is_whitespace() { 306 | flush_generic_stack(rope, &mut generic_stack, &mut line, &mut last_semantic, ch); 307 | } 308 | 309 | push_to_stack(&mut generic_stack, i); 310 | } 311 | 312 | if ch == '\n' || is_last_character { 313 | // Flush OneLine and MultiLine comments 314 | if tracking_comment != CommentTracking::None { 315 | if let Some(ct) = comment_stack.take() { 316 | line.push((SyntaxType::Comment, TextNode::Range(ct))); 317 | } 318 | 319 | // Stop tracking one line comments on line ending 320 | if tracking_comment == CommentTracking::OneLine { 321 | tracking_comment = CommentTracking::None 322 | } 323 | } 324 | 325 | flush_generic_stack(rope, &mut generic_stack, &mut line, &mut last_semantic, ch); 326 | flush_spaces_stack(rope, &mut generic_stack, &mut line, begining_of_line, true); 327 | 328 | if let Some(st) = string_stack.take() { 329 | line.push((SyntaxType::String, TextNode::Range(st))); 330 | } 331 | 332 | syntax_blocks.push_line(line.drain(0..).collect()); 333 | 334 | // Leave an empty line at the end 335 | if ch == '\n' && is_last_character { 336 | syntax_blocks.push_line(SmallVec::default()); 337 | } 338 | 339 | begining_of_line = true; 340 | } 341 | } 342 | } 343 | 344 | // Push if exists otherwise create the stack 345 | #[inline(always)] 346 | fn push_to_stack(stack: &mut Option>, idx: usize) { 347 | if let Some(stack) = stack.as_mut() { 348 | stack.end = idx + 1; 349 | } else { 350 | stack.replace(idx..idx + 1); 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{read_to_string, write}, 3 | path::PathBuf, 4 | }; 5 | 6 | use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; 7 | use tokio::sync::mpsc::channel; 8 | use tracing::info; 9 | 10 | use crate::state::{AppSettings, Channel, RadioAppState}; 11 | 12 | pub fn settings_path() -> Option { 13 | let home_dir = home::home_dir()?; 14 | 15 | let settings_path = home_dir.join("valin.toml"); 16 | 17 | Some(settings_path) 18 | } 19 | 20 | pub fn load_settings() -> Option { 21 | let settings_path = settings_path()?; 22 | 23 | // Create if it doesn't exist 24 | if std::fs::metadata(&settings_path).is_err() { 25 | let default_settings_content = toml::to_string(&AppSettings::default()).unwrap(); 26 | write(&settings_path, default_settings_content).ok()?; 27 | info!("Settings file didn't exist, so one was created."); 28 | } 29 | 30 | let settings_content = read_to_string(&settings_path).ok()?; 31 | 32 | let settings: AppSettings = toml::from_str(&settings_content).ok()?; 33 | 34 | Some(settings) 35 | } 36 | 37 | pub async fn watch_settings(mut radio_app_state: RadioAppState) -> Option<()> { 38 | let (tx, mut rx) = channel::<()>(1); 39 | 40 | let settings_path = settings_path()?; 41 | 42 | let mut watcher = RecommendedWatcher::new( 43 | move |ev: notify::Result| { 44 | if let Ok(ev) = ev { 45 | if ev.kind.is_modify() { 46 | tx.blocking_send(()).unwrap(); 47 | } 48 | } 49 | }, 50 | Config::default(), 51 | ) 52 | .ok()?; 53 | 54 | watcher 55 | .watch(&settings_path, RecursiveMode::Recursive) 56 | .ok()?; 57 | 58 | while rx.recv().await.is_some() { 59 | let settings = load_settings(); 60 | if let Some(settings) = settings { 61 | let mut app_state = radio_app_state.write_channel(Channel::Settings); 62 | app_state.set_settings(settings); 63 | } else { 64 | info!("Failed to update in-memory settings with the newest changes.") 65 | } 66 | } 67 | 68 | Some(()) 69 | } 70 | -------------------------------------------------------------------------------- /src/state/app.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, vec}; 2 | 3 | use dioxus_clipboard::prelude::UseClipboard; 4 | use dioxus_radio::prelude::{Radio, RadioChannel}; 5 | use freya::{core::accessibility::AccessibilityFocusStrategy, hooks::UsePlatform}; 6 | use skia_safe::{textlayout::FontCollection, FontMgr}; 7 | use tracing::info; 8 | 9 | use crate::{ 10 | fs::FSTransport, 11 | lsp::{LSPClient, LspConfig}, 12 | views::file_explorer::file_explorer_state::FileExplorerState, 13 | LspStatusSender, 14 | }; 15 | 16 | use super::{AppSettings, EditorView, Panel, PanelTab, TabId}; 17 | 18 | pub type RadioAppState = Radio; 19 | 20 | pub trait AppStateUtils { 21 | fn get_active_tab(&self) -> Option; 22 | } 23 | 24 | impl AppStateUtils for RadioAppState { 25 | fn get_active_tab(&self) -> Option { 26 | let app_state = self.read(); 27 | app_state.panel(app_state.focused_panel).active_tab 28 | } 29 | } 30 | 31 | #[derive(PartialEq, Eq, Debug, Clone, Copy, Hash, PartialOrd, Ord)] 32 | pub enum Channel { 33 | /// Affects global components 34 | Global, 35 | /// Affects all tabs 36 | AllTabs, 37 | /// Affects individual tab 38 | Tab { 39 | tab_id: TabId, 40 | }, 41 | /// Only affects the active tab 42 | ActiveTab, 43 | /// Affects the settings 44 | Settings, 45 | // Only affects the file explorer 46 | FileExplorer, 47 | } 48 | 49 | impl RadioChannel for Channel { 50 | fn derive_channel(self, app_state: &AppState) -> Vec { 51 | match self { 52 | Self::AllTabs => { 53 | let mut channels = vec![self, Self::ActiveTab]; 54 | channels.extend( 55 | app_state 56 | .tabs 57 | .keys() 58 | .map(move |tab_id| Self::Tab { tab_id: *tab_id }) 59 | .collect::>(), 60 | ); 61 | 62 | channels 63 | } 64 | Self::Tab { tab_id } => { 65 | let mut channels = vec![self]; 66 | for (panel_index, panel) in app_state.panels.iter().enumerate() { 67 | if app_state.focused_panel == panel_index { 68 | if let Some(active_tab) = panel.active_tab { 69 | if active_tab == tab_id { 70 | channels.push(Self::ActiveTab); 71 | } 72 | } 73 | } 74 | } 75 | 76 | channels 77 | } 78 | Self::Settings => { 79 | let mut channels = vec![self]; 80 | channels.extend(Channel::Global.derive_channel(app_state)); 81 | channels 82 | } 83 | Self::Global => { 84 | let mut channels = vec![self]; 85 | channels.extend(Channel::AllTabs.derive_channel(app_state)); 86 | channels 87 | } 88 | _ => vec![self], 89 | } 90 | } 91 | } 92 | 93 | impl Channel { 94 | pub fn follow_tab(tab_id: TabId) -> Self { 95 | Self::Tab { tab_id } 96 | } 97 | } 98 | 99 | #[derive(Clone, Default, PartialEq, Copy)] 100 | pub enum EditorSidePanel { 101 | #[default] 102 | FileExplorer, 103 | } 104 | 105 | pub struct AppState { 106 | pub previous_focused_view: Option, 107 | pub focused_view: EditorView, 108 | 109 | pub focused_panel: usize, 110 | pub panels: Vec, 111 | pub tabs: HashMap>, 112 | pub settings: AppSettings, 113 | pub language_servers: HashMap, 114 | pub lsp_sender: LspStatusSender, 115 | pub side_panel: Option, 116 | pub default_transport: FSTransport, 117 | pub font_collection: FontCollection, 118 | pub clipboard: UseClipboard, 119 | 120 | pub file_explorer: FileExplorerState, 121 | } 122 | 123 | impl AppState { 124 | pub fn new( 125 | lsp_sender: LspStatusSender, 126 | default_transport: FSTransport, 127 | clipboard: UseClipboard, 128 | ) -> Self { 129 | let mut font_collection = FontCollection::new(); 130 | font_collection.set_default_font_manager(FontMgr::default(), "Jetbrains Mono"); 131 | 132 | Self { 133 | previous_focused_view: None, 134 | focused_view: EditorView::default(), 135 | focused_panel: 0, 136 | tabs: HashMap::new(), 137 | panels: vec![Panel::new()], 138 | settings: AppSettings::load(), 139 | language_servers: HashMap::default(), 140 | lsp_sender, 141 | side_panel: Some(EditorSidePanel::default()), 142 | default_transport, 143 | font_collection, 144 | clipboard, 145 | 146 | file_explorer: FileExplorerState::new(), 147 | } 148 | } 149 | 150 | pub fn toggle_side_panel(&mut self, side_panel: EditorSidePanel) { 151 | if let Some(current_side_panel) = self.side_panel { 152 | if current_side_panel == side_panel { 153 | self.side_panel = None; 154 | return; 155 | } 156 | } 157 | 158 | self.side_panel = Some(side_panel); 159 | } 160 | 161 | pub fn set_settings(&mut self, settins: AppSettings) { 162 | self.settings = settins; 163 | self.apply_settings(); 164 | } 165 | 166 | pub fn set_fontsize(&mut self, font_size: f32) { 167 | self.settings.editor.font_size = font_size; 168 | self.apply_settings() 169 | } 170 | 171 | /// There are a few things that need to revaluated when the settings are changed 172 | pub fn apply_settings(&mut self) { 173 | for tab in self.tabs.values_mut() { 174 | tab.on_settings_changed(&self.settings, &self.font_collection) 175 | } 176 | } 177 | 178 | pub fn focus_view(&mut self, view: EditorView) { 179 | if !self.focused_view.is_popup() { 180 | self.previous_focused_view = Some(self.focused_view); 181 | } 182 | 183 | self.focused_view = view; 184 | 185 | match view { 186 | EditorView::Panels => { 187 | self.focus_tab( 188 | self.focused_panel, 189 | self.panels[self.focused_panel].active_tab, 190 | ); 191 | } 192 | EditorView::FilesExplorer => { 193 | self.file_explorer.focus(); 194 | } 195 | _ => {} 196 | } 197 | } 198 | 199 | pub fn focused_view(&self) -> EditorView { 200 | self.focused_view 201 | } 202 | 203 | pub fn focus_previous_view(&mut self) { 204 | if let Some(previous_focused_view) = self.previous_focused_view { 205 | self.focused_view = previous_focused_view; 206 | self.previous_focused_view = None; 207 | } 208 | 209 | match self.focused_view { 210 | EditorView::Panels => { 211 | self.focus_tab( 212 | self.focused_panel, 213 | self.panels[self.focused_panel].active_tab, 214 | ); 215 | } 216 | EditorView::FilesExplorer => { 217 | self.file_explorer.focus(); 218 | } 219 | _ => {} 220 | } 221 | } 222 | 223 | pub fn font_size(&self) -> f32 { 224 | self.settings.editor.font_size 225 | } 226 | 227 | pub fn line_height(&self) -> f32 { 228 | self.settings.editor.line_height 229 | } 230 | 231 | pub fn focused_panel(&self) -> usize { 232 | self.focused_panel 233 | } 234 | 235 | #[allow(clippy::borrowed_box)] 236 | pub fn tab(&self, tab_id: &TabId) -> &Box { 237 | self.tabs.get(tab_id).unwrap() 238 | } 239 | 240 | #[allow(clippy::borrowed_box)] 241 | pub fn tab_mut(&mut self, tab_id: &TabId) -> &mut Box { 242 | self.tabs.get_mut(tab_id).unwrap() 243 | } 244 | 245 | fn get_tab_if_exists(&self, tab: &impl PanelTab) -> Option { 246 | self.tabs.iter().find_map(|(other_tab_id, other_tab)| { 247 | if other_tab.get_data().content_id == tab.get_data().content_id { 248 | Some(*other_tab_id) 249 | } else { 250 | None 251 | } 252 | }) 253 | } 254 | 255 | // Push a [PanelTab] to a given panel index, return true if it didnt exist yet. 256 | pub fn push_tab(&mut self, tab: impl PanelTab + 'static, panel_index: usize) -> bool { 257 | let opened_tab = self.get_tab_if_exists(&tab); 258 | 259 | if let Some(tab_id) = opened_tab { 260 | // Focus the already open tab with the same content id 261 | self.focused_panel = panel_index; 262 | self.focus_tab(panel_index, Some(tab_id)); 263 | } else { 264 | // Register the new tab 265 | self.panels[panel_index].tabs.push(tab.get_data().id); 266 | self.tabs.insert(tab.get_data().id, Box::new(tab)); 267 | 268 | // Focus the new tab 269 | self.focused_panel = panel_index; 270 | self.focus_tab(panel_index, self.panels[panel_index].tabs.last().cloned()); 271 | } 272 | 273 | self.focused_view = EditorView::Panels; 274 | 275 | info!( 276 | "Opened/Focused tab [panel={panel_index}] [tab={}]", 277 | self.panels[panel_index].tabs.len() 278 | ); 279 | 280 | opened_tab.is_none() 281 | } 282 | 283 | pub fn close_tab(&mut self, tab_id: TabId) { 284 | let (panel_index, panel) = self 285 | .panels 286 | .iter() 287 | .enumerate() 288 | .find(|(_, panel)| panel.tabs.contains(&tab_id)) 289 | .unwrap(); 290 | if let Some(active_tab) = panel.active_tab { 291 | if active_tab == tab_id { 292 | let tab_index = panel.tabs.iter().position(|tab| *tab == tab_id).unwrap(); 293 | self.focus_tab( 294 | panel_index, 295 | if let Some(next_tab) = panel.tabs.get(tab_index + 1) { 296 | Some(*next_tab) 297 | } else if tab_index > 0 { 298 | panel.tabs.get(tab_index - 1).copied() 299 | } else { 300 | None 301 | }, 302 | ); 303 | } 304 | } 305 | 306 | let mut panel_tab = self.tabs.remove(&tab_id).unwrap(); 307 | panel_tab.on_close(self); 308 | 309 | let panel = self 310 | .panels 311 | .iter_mut() 312 | .find(|panel| panel.tabs.contains(&tab_id)) 313 | .unwrap(); 314 | panel.tabs.retain(|tab| *tab != tab_id); 315 | 316 | info!("Closed tab [panel={panel_index}] [tab={tab_id:?}]",); 317 | } 318 | 319 | pub fn focus_tab(&mut self, panel_index: usize, tab_id: Option) { 320 | self.panels[panel_index].active_tab = tab_id; 321 | if let Some(tab_id) = tab_id { 322 | let platform = UsePlatform::current(); 323 | let tab = self.tab(&tab_id); 324 | platform.focus(AccessibilityFocusStrategy::Node(tab.get_data().focus_id)); 325 | } 326 | } 327 | 328 | pub fn close_active_tab(&mut self) { 329 | let panel = self.focused_panel; 330 | if let Some(active_tab) = self.panels[panel].active_tab { 331 | self.close_tab(active_tab); 332 | } 333 | } 334 | 335 | pub fn close_active_panel(&mut self) { 336 | self.close_panel(self.focused_panel); 337 | } 338 | 339 | pub fn focus_previous_panel(&mut self) { 340 | if self.focused_panel > 0 { 341 | self.focus_panel(self.focused_panel - 1); 342 | } 343 | } 344 | 345 | pub fn focus_next_panel(&mut self) { 346 | if self.focused_panel < self.panels.len() - 1 { 347 | self.focus_panel(self.focused_panel + 1); 348 | } 349 | } 350 | 351 | pub fn push_panel(&mut self, panel: Panel) { 352 | self.panels.push(panel); 353 | 354 | self.focused_view = EditorView::Panels; 355 | } 356 | 357 | pub fn panels(&self) -> &[Panel] { 358 | &self.panels 359 | } 360 | 361 | pub fn panel(&self, panel: usize) -> &Panel { 362 | &self.panels[panel] 363 | } 364 | 365 | pub fn panel_mut(&mut self, panel: usize) -> &mut Panel { 366 | &mut self.panels[panel] 367 | } 368 | 369 | pub fn focus_panel(&mut self, panel: usize) { 370 | self.focused_panel = panel; 371 | } 372 | 373 | pub fn close_panel(&mut self, panel: usize) { 374 | if self.panels.len() > 1 { 375 | self.panels.remove(panel); 376 | if self.focused_panel > 0 { 377 | self.focused_panel -= 1; 378 | } 379 | } 380 | } 381 | 382 | pub fn lsp(&self, lsp_config: &LspConfig) -> Option<&LSPClient> { 383 | self.language_servers.get(&lsp_config.language_server) 384 | } 385 | 386 | pub fn insert_lsp_client(&mut self, language_server: String, client: LSPClient) { 387 | info!("Registered language server '{language_server}'"); 388 | self.language_servers.insert(language_server, client); 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/state/commands.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | pub struct CommandRunContext { 4 | /// Only for Commander. 5 | pub focus_previous_view: bool, 6 | } 7 | 8 | impl Default for CommandRunContext { 9 | fn default() -> Self { 10 | Self { 11 | focus_previous_view: true, 12 | } 13 | } 14 | } 15 | 16 | pub trait EditorCommand { 17 | fn is_visible(&self) -> bool { 18 | true 19 | } 20 | 21 | fn matches(&self, input: &str) -> bool; 22 | 23 | fn id(&self) -> &str; 24 | 25 | fn text(&self) -> &str; 26 | 27 | fn run(&self, ctx: &mut CommandRunContext); 28 | } 29 | 30 | #[derive(Default)] 31 | pub struct EditorCommands { 32 | pub(crate) commands: HashMap>, 33 | } 34 | 35 | impl EditorCommands { 36 | pub fn register(&mut self, editor: impl EditorCommand + 'static) { 37 | self.commands 38 | .insert(editor.id().to_string(), Box::new(editor)); 39 | } 40 | 41 | pub fn trigger(&self, command_name: &str) { 42 | let command = self.commands.get(command_name); 43 | 44 | if let Some(command) = command { 45 | command.run(&mut CommandRunContext::default()); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/state/editor.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use dioxus_radio::hooks::{ChannelSelection, DataReducer}; 4 | use freya::{ 5 | events::{Code, Key, KeyboardData, Modifiers, MouseData}, 6 | prelude::{Readable, Signal, Writable}, 7 | }; 8 | use freya_hooks::EditableEvent; 9 | 10 | use crate::views::panels::tabs::editor::AppStateEditorUtils; 11 | 12 | use super::{AppState, Channel, EditorView, TabId}; 13 | 14 | pub struct EditorAction { 15 | pub tab_id: TabId, 16 | pub data: EditorActionData, 17 | } 18 | 19 | #[derive(Debug)] 20 | pub enum EditorActionData { 21 | KeyUp { 22 | data: Rc, 23 | }, 24 | KeyDown { 25 | data: Rc, 26 | scroll_offsets: Signal<(i32, i32)>, 27 | line_height: f32, 28 | lines_len: usize, 29 | }, 30 | Click, 31 | MouseDown { 32 | data: Rc, 33 | line_index: usize, 34 | }, 35 | MouseMove { 36 | data: Rc, 37 | line_index: usize, 38 | }, 39 | } 40 | 41 | impl DataReducer for AppState { 42 | type Action = EditorAction; 43 | type Channel = Channel; 44 | 45 | fn reduce( 46 | &mut self, 47 | EditorAction { tab_id, data }: Self::Action, 48 | ) -> ChannelSelection { 49 | let (panel_index, panel) = self 50 | .panels 51 | .iter() 52 | .enumerate() 53 | .find(|(_, panel)| panel.tabs.contains(&tab_id)) 54 | .unwrap(); 55 | let is_panels_view_focused = self.focused_view() == EditorView::Panels; 56 | let is_panel_focused = self.focused_panel() == panel_index; 57 | let is_editor_focused = is_panel_focused && panel.active_tab() == Some(tab_id); 58 | 59 | match data { 60 | EditorActionData::MouseMove { data, line_index } 61 | if is_editor_focused && is_panel_focused => 62 | { 63 | let editor_tab = self.editor_tab_mut(tab_id); 64 | editor_tab 65 | .editor 66 | .process_event(&EditableEvent::MouseMove(data, line_index)); 67 | 68 | ChannelSelection::Silence 69 | } 70 | EditorActionData::MouseDown { data, line_index } => { 71 | let mut channel = ChannelSelection::Select(Channel::follow_tab(tab_id)); 72 | 73 | let editor_tab = self.editor_tab_mut(tab_id); 74 | editor_tab 75 | .editor 76 | .process_event(&EditableEvent::MouseDown(data, line_index)); 77 | 78 | if !is_editor_focused { 79 | self.focus_panel(panel_index); 80 | self.panel_mut(panel_index).set_active_tab(tab_id); 81 | channel.select(Channel::AllTabs); 82 | } 83 | 84 | if !is_panels_view_focused { 85 | self.focus_view(EditorView::Panels); 86 | channel.select(Channel::Global) 87 | } 88 | 89 | channel 90 | } 91 | EditorActionData::Click => { 92 | let editor_tab = self.editor_tab_mut(tab_id); 93 | editor_tab.editor.process_event(&EditableEvent::Click); 94 | ChannelSelection::Silence 95 | } 96 | EditorActionData::KeyUp { data } if is_editor_focused && is_panel_focused => { 97 | let editor_tab = self.editor_tab_mut(tab_id); 98 | editor_tab.editor.process_event(&EditableEvent::KeyUp(data)); 99 | ChannelSelection::Select(Channel::follow_tab(tab_id)) 100 | } 101 | EditorActionData::KeyDown { 102 | data, 103 | mut scroll_offsets, 104 | line_height, 105 | lines_len, 106 | } if is_editor_focused && is_panel_focused => { 107 | const LINES_JUMP_ALT: usize = 5; 108 | const LINES_JUMP_CONTROL: usize = 3; 109 | 110 | let lines_jump = (line_height * LINES_JUMP_ALT as f32).ceil() as i32; 111 | let min_height = -(lines_len as f32 * line_height) as i32; 112 | let max_height = 0; // TODO, this should be the height of the viewport 113 | let current_scroll = scroll_offsets.read().1; 114 | 115 | let events = match &data.key { 116 | Key::ArrowUp if data.modifiers.contains(Modifiers::ALT) => { 117 | let jump = (current_scroll + lines_jump).clamp(min_height, max_height); 118 | scroll_offsets.write().1 = jump; 119 | (0..LINES_JUMP_ALT) 120 | .map(|_| EditableEvent::KeyDown(data.clone())) 121 | .collect::>() 122 | } 123 | Key::ArrowDown if data.modifiers.contains(Modifiers::ALT) => { 124 | let jump = (current_scroll - lines_jump).clamp(min_height, max_height); 125 | scroll_offsets.write().1 = jump; 126 | (0..LINES_JUMP_ALT) 127 | .map(|_| EditableEvent::KeyDown(data.clone())) 128 | .collect::>() 129 | } 130 | Key::ArrowDown | Key::ArrowUp 131 | if data.modifiers.contains(Modifiers::CONTROL) => 132 | { 133 | (0..LINES_JUMP_CONTROL) 134 | .map(|_| EditableEvent::KeyDown(data.clone())) 135 | .collect::>() 136 | } 137 | _ if data.code == Code::Escape 138 | || data.modifiers.contains(Modifiers::ALT) 139 | || (data.modifiers.contains(Modifiers::CONTROL) 140 | && data.code == Code::KeyS) => 141 | { 142 | Vec::new() 143 | } 144 | _ => { 145 | vec![EditableEvent::KeyDown(data.clone())] 146 | } 147 | }; 148 | 149 | let no_changes = events.is_empty(); 150 | 151 | let editor_tab = self.editor_tab_mut(tab_id); 152 | for event in events { 153 | editor_tab.editor.process_event(&event); 154 | } 155 | 156 | if no_changes { 157 | ChannelSelection::Silence 158 | } else { 159 | ChannelSelection::Select(Channel::follow_tab(tab_id)) 160 | } 161 | } 162 | _ => ChannelSelection::Silence, 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/state/keyboard_shortcuts.rs: -------------------------------------------------------------------------------- 1 | use freya::events::KeyboardData; 2 | 3 | use super::{EditorCommands, RadioAppState}; 4 | 5 | type KeyboardShortcutHandler = dyn Fn(&KeyboardData, &mut EditorCommands, RadioAppState) -> bool; 6 | 7 | #[derive(Default)] 8 | pub struct KeyboardShortcuts { 9 | handlers: Vec>, 10 | } 11 | 12 | impl KeyboardShortcuts { 13 | pub fn register( 14 | &mut self, 15 | handler: impl Fn(&KeyboardData, &mut EditorCommands, RadioAppState) -> bool + 'static, 16 | ) { 17 | self.handlers.push(Box::new(handler)) 18 | } 19 | 20 | pub fn run( 21 | &self, 22 | data: &KeyboardData, 23 | editor_commands: &mut EditorCommands, 24 | radio_app_state: RadioAppState, 25 | ) { 26 | for event_handler in &self.handlers { 27 | let res = (event_handler)(data, editor_commands, radio_app_state); 28 | 29 | if res { 30 | break; 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/state/mod.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod commands; 3 | mod editor; 4 | mod keyboard_shortcuts; 5 | mod panels_tabs; 6 | mod settings; 7 | mod views; 8 | 9 | pub use app::*; 10 | pub use commands::*; 11 | pub use editor::*; 12 | pub use keyboard_shortcuts::*; 13 | pub use panels_tabs::*; 14 | pub use settings::*; 15 | pub use views::*; 16 | -------------------------------------------------------------------------------- /src/state/panels_tabs.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | any::Any, 3 | fmt::Display, 4 | sync::atomic::{AtomicU64, Ordering}, 5 | }; 6 | 7 | use freya::prelude::*; 8 | use skia_safe::textlayout::FontCollection; 9 | 10 | use super::{AppSettings, AppState}; 11 | 12 | pub trait PanelTab { 13 | fn on_close(&mut self, _app_state: &mut AppState) {} 14 | 15 | fn on_settings_changed( 16 | &mut self, 17 | _app_settings: &AppSettings, 18 | _font_collection: &FontCollection, 19 | ) { 20 | } 21 | 22 | fn get_data(&self) -> PanelTabData; 23 | 24 | fn render(&self) -> fn(TabProps) -> Element; 25 | 26 | fn as_any(&self) -> &dyn Any; 27 | 28 | fn as_any_mut(&mut self) -> &mut dyn Any; 29 | } 30 | 31 | #[derive(Props, Clone, PartialEq)] 32 | pub struct TabProps { 33 | pub tab_id: TabId, 34 | } 35 | 36 | static TAB_IDS: AtomicU64 = AtomicU64::new(0); 37 | 38 | #[derive(PartialEq, Eq, Debug, Clone, Copy, Hash, PartialOrd, Ord)] 39 | pub struct TabId(u64); 40 | 41 | impl TabId { 42 | pub fn new() -> Self { 43 | let n = TAB_IDS.fetch_add(1, Ordering::Relaxed); 44 | Self(n) 45 | } 46 | } 47 | 48 | impl Display for TabId { 49 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 | f.write_str(&self.0.to_string()) 51 | } 52 | } 53 | 54 | #[derive(PartialEq, Eq)] 55 | pub struct PanelTabData { 56 | pub edited: bool, 57 | pub title: String, 58 | pub content_id: String, 59 | pub id: TabId, 60 | pub focus_id: AccessibilityId, 61 | } 62 | 63 | #[derive(Default)] 64 | pub struct Panel { 65 | pub active_tab: Option, 66 | pub tabs: Vec, 67 | } 68 | 69 | impl Panel { 70 | pub fn new() -> Self { 71 | Self::default() 72 | } 73 | 74 | pub fn active_tab(&self) -> Option { 75 | self.active_tab 76 | } 77 | 78 | pub fn set_active_tab(&mut self, active_tab: TabId) { 79 | self.active_tab = Some(active_tab); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/state/settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize, Serializer}; 2 | use tracing::info; 3 | 4 | use crate::settings::load_settings; 5 | 6 | fn human_number_serializer(value: &f32, serializer: S) -> Result 7 | where 8 | S: Serializer, 9 | { 10 | serializer.serialize_f64((*value as f64 * 100.0).trunc() / 100.0) 11 | } 12 | #[derive(Serialize, Deserialize, Debug)] 13 | pub struct EditorSettings { 14 | #[serde(serialize_with = "human_number_serializer")] 15 | pub(crate) font_size: f32, 16 | #[serde(serialize_with = "human_number_serializer")] 17 | pub(crate) line_height: f32, 18 | } 19 | 20 | impl Default for EditorSettings { 21 | fn default() -> Self { 22 | Self { 23 | font_size: 17.0, 24 | line_height: 1.6_f32, 25 | } 26 | } 27 | } 28 | 29 | #[derive(Serialize, Deserialize, Debug, Default)] 30 | pub struct AppSettings { 31 | pub(crate) editor: EditorSettings, 32 | } 33 | 34 | impl AppSettings { 35 | pub fn load() -> Self { 36 | load_settings().unwrap_or_else(|| { 37 | info!("Failed to load settings, using defaults."); 38 | Self::default() 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/state/views.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | #[derive(Clone, Default, PartialEq, Copy, Debug)] 4 | pub enum EditorView { 5 | #[default] 6 | Panels, 7 | FilesExplorer, 8 | Commander, 9 | Search, 10 | } 11 | 12 | impl EditorView { 13 | pub fn is_popup(&self) -> bool { 14 | matches!(self, Self::Search | Self::Commander) 15 | } 16 | } 17 | 18 | impl Display for EditorView { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | match self { 21 | Self::Panels => f.write_str("Panels"), 22 | Self::FilesExplorer => f.write_str("Files Explorer"), 23 | Self::Commander => f.write_str("Commander"), 24 | Self::Search => f.write_str("Search"), 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | use skia_safe::{ 3 | scalar, 4 | textlayout::{Paragraph, ParagraphBuilder, ParagraphStyle, TextStyle}, 5 | }; 6 | 7 | use crate::state::AppState; 8 | 9 | #[allow(non_snake_case)] 10 | pub fn Divider() -> Element { 11 | rsx!(rect { 12 | background: "rgb(56, 59, 66)", 13 | height: "100%", 14 | width: "1", 15 | }) 16 | } 17 | 18 | #[allow(non_snake_case)] 19 | pub fn VerticalDivider() -> Element { 20 | rsx!(rect { 21 | background: "rgb(56, 59, 66)", 22 | height: "1", 23 | width: "100%", 24 | }) 25 | } 26 | 27 | pub fn create_paragraph(text: &str, font_size: f32, app_state: &AppState) -> Paragraph { 28 | let mut style = ParagraphStyle::default(); 29 | let mut text_style = TextStyle::default(); 30 | text_style.set_font_size(font_size); 31 | style.set_text_style(&text_style); 32 | 33 | let mut paragraph_builder = ParagraphBuilder::new(&style, &app_state.font_collection); 34 | 35 | paragraph_builder.add_text(text); 36 | 37 | let mut paragraph = paragraph_builder.build(); 38 | 39 | paragraph.layout(scalar::MAX); 40 | 41 | paragraph 42 | } 43 | -------------------------------------------------------------------------------- /src/views/commander/commander_ui.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | state::{Channel, CommandRunContext, EditorCommands}, 3 | Overlay, TextArea, 4 | }; 5 | use dioxus_radio::prelude::use_radio; 6 | use freya::prelude::*; 7 | 8 | #[derive(Props, Clone, PartialEq)] 9 | pub struct CommanderProps { 10 | editor_commands: Signal, 11 | } 12 | 13 | #[allow(non_snake_case)] 14 | pub fn Commander(CommanderProps { editor_commands }: CommanderProps) -> Element { 15 | let mut radio_app_state = use_radio(Channel::Global); 16 | let mut value = use_signal(String::new); 17 | let mut selected = use_signal(|| 0); 18 | let mut focus = use_focus(); 19 | 20 | let commands = editor_commands.read(); 21 | let filtered_commands = commands 22 | .commands 23 | .iter() 24 | .filter_map(|(id, command)| { 25 | if value.read().is_empty() { 26 | command.is_visible() 27 | } else { 28 | command.is_visible() && command.matches(value.read().as_str()) 29 | } 30 | .then_some(id.clone()) 31 | }) 32 | .collect::>(); 33 | let filtered_commands_len = filtered_commands.len(); 34 | let options_height = ((filtered_commands_len.max(1)) * 30).max(175); 35 | 36 | let onchange = move |v| { 37 | if *value.read() != v { 38 | selected.set(0); 39 | value.set(v); 40 | } 41 | }; 42 | 43 | let command_id = filtered_commands.get(selected()).cloned(); 44 | 45 | let onsubmit = move |_: String| { 46 | let editor_commands = editor_commands.read(); 47 | let command = command_id 48 | .as_ref() 49 | .and_then(|command_i| editor_commands.commands.get(command_i)); 50 | if let Some(command) = command { 51 | let mut ctx = CommandRunContext::default(); 52 | 53 | // Run the command 54 | command.run(&mut ctx); 55 | 56 | if ctx.focus_previous_view { 57 | let mut app_state = radio_app_state.write(); 58 | app_state.focus_previous_view(); 59 | } 60 | } 61 | }; 62 | 63 | let onkeydown = move |e: KeyboardEvent| { 64 | e.stop_propagation(); 65 | focus.prevent_navigation(); 66 | match e.code { 67 | Code::ArrowDown => { 68 | if filtered_commands_len > 0 { 69 | if *selected.read() < filtered_commands_len - 1 { 70 | *selected.write() += 1; 71 | } else { 72 | selected.set(0); 73 | } 74 | } 75 | } 76 | Code::ArrowUp => { 77 | if selected() > 0 && filtered_commands_len > 0 { 78 | *selected.write() -= 1; 79 | } else { 80 | selected.set(filtered_commands_len - 1); 81 | } 82 | } 83 | _ => {} 84 | } 85 | }; 86 | 87 | rsx!( 88 | Overlay { 89 | rect { 90 | onkeydown, 91 | spacing: "5", 92 | TextArea { 93 | placeholder: "Run a command...", 94 | value: "{value}", 95 | onchange, 96 | onsubmit, 97 | } 98 | ScrollView { 99 | height: "{options_height}", 100 | if filtered_commands.is_empty() { 101 | {commander_option("not-found", "Command Not Found", true)} 102 | } 103 | for (n, command_id) in filtered_commands.into_iter().enumerate() { 104 | { 105 | let command = commands.commands.get(&command_id).unwrap(); 106 | commander_option(&command_id, command.text(), n == selected()) 107 | } 108 | } 109 | } 110 | } 111 | } 112 | ) 113 | } 114 | 115 | fn commander_option(command_id: &str, command_text: &str, is_selected: bool) -> Element { 116 | let background = if is_selected { 117 | "rgb(29, 32, 33)" 118 | } else { 119 | "none" 120 | }; 121 | 122 | rsx!( 123 | rect { 124 | background, 125 | key: "{command_id}", 126 | padding: "8 6", 127 | width: "100%", 128 | height: "30", 129 | corner_radius: "10", 130 | main_align: "center", 131 | label { 132 | "{command_text}" 133 | } 134 | } 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/views/commander/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod commander_ui; 2 | -------------------------------------------------------------------------------- /src/views/file_explorer/file_explorer_state.rs: -------------------------------------------------------------------------------- 1 | use freya::{ 2 | core::accessibility::AccessibilityFocusStrategy, 3 | hooks::{UseFocus, UsePlatform}, 4 | prelude::AccessibilityId, 5 | }; 6 | 7 | use super::file_explorer_ui::ExplorerItem; 8 | 9 | pub struct FileExplorerState { 10 | pub folders: Vec, 11 | pub focus_id: AccessibilityId, 12 | } 13 | 14 | impl FileExplorerState { 15 | pub fn new() -> Self { 16 | Self { 17 | folders: Vec::new(), 18 | focus_id: UseFocus::new_id(), 19 | } 20 | } 21 | 22 | pub fn focus(&self) { 23 | let platform = UsePlatform::current(); 24 | platform.focus(AccessibilityFocusStrategy::Node(self.focus_id)); 25 | } 26 | 27 | pub fn open_folder(&mut self, item: ExplorerItem) { 28 | self.folders.push(item) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/views/file_explorer/file_explorer_ui.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use dioxus_radio::hooks::use_radio; 4 | use freya::prelude::keyboard::Code; 5 | use freya::prelude::*; 6 | use futures::StreamExt; 7 | use tokio::io; 8 | 9 | use crate::{ 10 | fs::FSTransport, 11 | state::{AppState, Channel, EditorView, RadioAppState}, 12 | views::panels::tabs::editor::EditorTab, 13 | }; 14 | 15 | #[derive(Debug, Clone, PartialEq)] 16 | pub enum FolderState { 17 | Opened(Vec), 18 | Closed, 19 | } 20 | 21 | #[derive(Debug, Clone, PartialEq)] 22 | pub enum ExplorerItem { 23 | Folder { path: PathBuf, state: FolderState }, 24 | File { path: PathBuf }, 25 | } 26 | 27 | impl ExplorerItem { 28 | pub fn path(&self) -> &PathBuf { 29 | match self { 30 | Self::Folder { path, .. } => path, 31 | Self::File { path } => path, 32 | } 33 | } 34 | 35 | pub fn set_folder_state(&mut self, folder_path: &PathBuf, folder_state: &FolderState) { 36 | if let ExplorerItem::Folder { path, state } = self { 37 | if path == folder_path { 38 | *state = folder_state.clone(); // Ugly 39 | } else if folder_path.starts_with(path) { 40 | if let FolderState::Opened(items) = state { 41 | for item in items { 42 | item.set_folder_state(folder_path, folder_state); 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | pub fn flat(&self, depth: usize, root_path: &PathBuf) -> Vec { 50 | let mut flat_items = vec![self.clone().into_flat(depth, root_path.clone())]; 51 | if let ExplorerItem::Folder { 52 | state: FolderState::Opened(items), 53 | .. 54 | } = self 55 | { 56 | for item in items { 57 | let inner_items = item.flat(depth + 1, root_path); 58 | flat_items.extend(inner_items); 59 | } 60 | } 61 | flat_items 62 | } 63 | 64 | fn into_flat(self, depth: usize, root_path: PathBuf) -> FlatItem { 65 | match self { 66 | ExplorerItem::File { path } => FlatItem { 67 | path, 68 | is_file: true, 69 | is_opened: false, 70 | depth, 71 | root_path, 72 | }, 73 | ExplorerItem::Folder { path, state } => FlatItem { 74 | path, 75 | is_file: false, 76 | is_opened: state != FolderState::Closed, 77 | depth, 78 | root_path, 79 | }, 80 | } 81 | } 82 | } 83 | 84 | #[derive(Clone, Debug, PartialEq)] 85 | pub struct FlatItem { 86 | path: PathBuf, 87 | is_opened: bool, 88 | is_file: bool, 89 | depth: usize, 90 | root_path: PathBuf, 91 | } 92 | 93 | pub async fn read_folder_as_items( 94 | dir: &Path, 95 | transport: &FSTransport, 96 | ) -> io::Result> { 97 | let mut paths = transport.read_dir(dir).await?; 98 | let mut folder_items = Vec::default(); 99 | let mut files_items = Vec::default(); 100 | 101 | while let Ok(Some(entry)) = paths.next_entry().await { 102 | let file_type = entry.file_type().await?; 103 | let is_file = file_type.is_file(); 104 | let path = entry.path(); 105 | 106 | if is_file { 107 | files_items.push(ExplorerItem::File { path }) 108 | } else { 109 | folder_items.push(ExplorerItem::Folder { 110 | path, 111 | state: FolderState::Closed, 112 | }) 113 | } 114 | } 115 | 116 | folder_items.extend(files_items); 117 | 118 | Ok(folder_items) 119 | } 120 | 121 | #[derive(Debug, Clone, PartialEq)] 122 | enum TreeTask { 123 | OpenFolder { 124 | folder_path: PathBuf, 125 | root_path: PathBuf, 126 | }, 127 | CloseFolder { 128 | folder_path: PathBuf, 129 | root_path: PathBuf, 130 | }, 131 | OpenFile { 132 | file_path: PathBuf, 133 | root_path: PathBuf, 134 | }, 135 | } 136 | 137 | #[allow(non_snake_case)] 138 | pub fn FileExplorer() -> Element { 139 | let mut radio_app_state = use_radio::(Channel::FileExplorer); 140 | let app_state = radio_app_state.read(); 141 | let mut focus = use_focus_for_id(app_state.file_explorer.focus_id); 142 | let mut focused_item_index = use_signal(|| 0); 143 | 144 | let items = app_state 145 | .file_explorer 146 | .folders 147 | .iter() 148 | .flat_map(|tree| tree.flat(0, tree.path())) 149 | .collect::>(); 150 | let items_len = items.len(); 151 | let focused_item = items.get(focused_item_index()).cloned(); 152 | 153 | let channel = use_coroutine(move |mut rx| { 154 | async move { 155 | while let Some((task, item_index)) = rx.next().await { 156 | // Focus the FilesExplorer view if it wasn't focused already 157 | let focused_view = radio_app_state.read().focused_view(); 158 | if focused_view != EditorView::FilesExplorer { 159 | radio_app_state 160 | .write_channel(Channel::Global) 161 | .focus_view(EditorView::FilesExplorer); 162 | } 163 | 164 | match task { 165 | TreeTask::OpenFolder { 166 | folder_path, 167 | root_path, 168 | } => { 169 | let transport = radio_app_state.read().default_transport.clone(); 170 | if let Ok(items) = read_folder_as_items(&folder_path, &transport).await { 171 | let mut app_state = radio_app_state.write(); 172 | let folder = app_state 173 | .file_explorer 174 | .folders 175 | .iter_mut() 176 | .find(|folder| folder.path() == &root_path) 177 | .unwrap(); 178 | folder.set_folder_state(&folder_path, &FolderState::Opened(items)); 179 | } 180 | } 181 | TreeTask::CloseFolder { 182 | folder_path, 183 | root_path, 184 | } => { 185 | let mut app_state = radio_app_state.write(); 186 | let folder = app_state 187 | .file_explorer 188 | .folders 189 | .iter_mut() 190 | .find(|folder| folder.path() == &root_path) 191 | .unwrap(); 192 | folder.set_folder_state(&folder_path, &FolderState::Closed); 193 | } 194 | TreeTask::OpenFile { 195 | file_path, 196 | root_path, 197 | } => { 198 | let transport = radio_app_state.read().default_transport.clone(); 199 | let mut app_state = radio_app_state.write_channel(Channel::Global); 200 | EditorTab::open_with( 201 | radio_app_state, 202 | &mut app_state, 203 | file_path, 204 | root_path, 205 | transport.as_read(), 206 | ); 207 | } 208 | } 209 | focused_item_index.set(item_index); 210 | } 211 | } 212 | }); 213 | 214 | let open_dialog = move |_| { 215 | spawn(async move { 216 | let folder = rfd::AsyncFileDialog::new().pick_folder().await; 217 | 218 | if let Some(folder) = folder { 219 | let transport = radio_app_state.read().default_transport.clone(); 220 | 221 | let path = folder.path().to_owned(); 222 | let items = read_folder_as_items(&path, &transport) 223 | .await 224 | .unwrap_or_default(); 225 | 226 | let mut app_state = radio_app_state.write(); 227 | 228 | app_state.file_explorer.open_folder(ExplorerItem::Folder { 229 | path, 230 | state: FolderState::Opened(items), 231 | }); 232 | 233 | app_state.focus_view(EditorView::FilesExplorer); 234 | } 235 | }); 236 | }; 237 | 238 | let onkeydown = move |ev: KeyboardEvent| { 239 | let is_focused_files_explorer = 240 | radio_app_state.read().focused_view() == EditorView::FilesExplorer; 241 | if is_focused_files_explorer { 242 | match ev.code { 243 | Code::ArrowDown => { 244 | focused_item_index.with_mut(|i| { 245 | if *i < items_len - 1 { 246 | *i += 1 247 | } 248 | }); 249 | } 250 | Code::ArrowUp => { 251 | focused_item_index.with_mut(|i| { 252 | if *i > 0 { 253 | *i -= 1 254 | } 255 | }); 256 | } 257 | Code::Enter => { 258 | if let Some(focused_item) = &focused_item { 259 | if focused_item.is_file { 260 | channel.send(( 261 | TreeTask::OpenFile { 262 | file_path: focused_item.path.clone(), 263 | root_path: focused_item.root_path.clone(), 264 | }, 265 | focused_item_index(), 266 | )); 267 | } else if focused_item.is_opened { 268 | channel.send(( 269 | TreeTask::CloseFolder { 270 | folder_path: focused_item.path.clone(), 271 | root_path: focused_item.root_path.clone(), 272 | }, 273 | focused_item_index(), 274 | )); 275 | } else { 276 | channel.send(( 277 | TreeTask::OpenFolder { 278 | folder_path: focused_item.path.clone(), 279 | root_path: focused_item.root_path.clone(), 280 | }, 281 | focused_item_index(), 282 | )); 283 | } 284 | } 285 | } 286 | _ => {} 287 | } 288 | } 289 | }; 290 | 291 | let onclick = move |e: MouseEvent| { 292 | e.stop_propagation(); 293 | focus.request_focus(); 294 | }; 295 | 296 | if items.is_empty() { 297 | rsx!( 298 | rect { 299 | width: "100%", 300 | height: "100%", 301 | main_align: "center", 302 | cross_align: "center", 303 | Button { 304 | onclick: open_dialog, 305 | label { 306 | "Open folder" 307 | } 308 | } 309 | } 310 | ) 311 | } else { 312 | rsx!(rect { 313 | width: "100%", 314 | height: "100%", 315 | onkeydown, 316 | onclick, 317 | a11y_id: focus.attribute(), 318 | VirtualScrollView { 319 | length: items.len(), 320 | item_size: 27.0, 321 | builder_args: (items, channel, focused_item_index, radio_app_state), 322 | direction: "vertical", 323 | scroll_with_arrows: false, 324 | builder: file_explorer_item_builder 325 | } 326 | }) 327 | } 328 | } 329 | 330 | type TreeBuilderOptions = ( 331 | Vec, 332 | Coroutine<(TreeTask, usize)>, 333 | Signal, 334 | RadioAppState, 335 | ); 336 | 337 | fn file_explorer_item_builder(index: usize, values: &Option) -> Element { 338 | let (items, channel, focused_item, radio_app_state) = values.as_ref().unwrap(); 339 | let item: &FlatItem = &items[index]; 340 | 341 | let path = item.path.to_str().unwrap().to_owned(); 342 | let name = item 343 | .path 344 | .file_name() 345 | .unwrap() 346 | .to_owned() 347 | .to_str() 348 | .unwrap() 349 | .to_string(); 350 | let is_focused = *focused_item.read() == index; 351 | 352 | if item.is_file { 353 | to_owned![channel, item]; 354 | let onclick = move |_| { 355 | channel.send(( 356 | TreeTask::OpenFile { 357 | file_path: item.path.clone(), 358 | root_path: item.root_path.clone(), 359 | }, 360 | index, 361 | )); 362 | }; 363 | rsx!( 364 | FileExplorerItem { 365 | key: "{path}", 366 | depth: item.depth, 367 | radio_app_state: *radio_app_state, 368 | onclick, 369 | is_focused, 370 | label { 371 | max_lines: "1", 372 | text_overflow: "ellipsis", 373 | "📃 {name}" 374 | } 375 | } 376 | ) 377 | } else { 378 | to_owned![channel, item]; 379 | let onclick = move |_| { 380 | if item.is_opened { 381 | channel.send(( 382 | TreeTask::CloseFolder { 383 | folder_path: item.path.clone(), 384 | root_path: item.root_path.clone(), 385 | }, 386 | index, 387 | )); 388 | } else { 389 | channel.send(( 390 | TreeTask::OpenFolder { 391 | folder_path: item.path.clone(), 392 | root_path: item.root_path.clone(), 393 | }, 394 | index, 395 | )); 396 | } 397 | }; 398 | 399 | let icon = if item.is_opened { "📂" } else { "📁" }; 400 | 401 | rsx!( 402 | FileExplorerItem { 403 | key: "{path}", 404 | depth: item.depth, 405 | radio_app_state: *radio_app_state, 406 | onclick, 407 | is_focused, 408 | label { 409 | max_lines: "1", 410 | text_overflow: "ellipsis", 411 | "{icon} {name}" 412 | } 413 | } 414 | ) 415 | } 416 | } 417 | 418 | #[allow(non_snake_case)] 419 | #[component] 420 | fn FileExplorerItem( 421 | children: Element, 422 | onclick: EventHandler<()>, 423 | depth: usize, 424 | is_focused: bool, 425 | radio_app_state: RadioAppState, 426 | ) -> Element { 427 | let mut status = use_signal(|| ButtonStatus::Idle); 428 | 429 | let onmouseenter = move |_| status.set(ButtonStatus::Hovering); 430 | 431 | let onmouseleave = move |_| status.set(ButtonStatus::Idle); 432 | 433 | let onclick = move |_: MouseEvent| { 434 | onclick.call(()); 435 | }; 436 | 437 | let background = match *status.read() { 438 | ButtonStatus::Idle | ButtonStatus::Hovering if is_focused => "rgb(29, 32, 33)", 439 | ButtonStatus::Hovering => "rgb(29, 32, 33, 0.7)", 440 | ButtonStatus::Idle => "transparent", 441 | }; 442 | 443 | let color = if is_focused { 444 | "rgb(245, 245, 245)" 445 | } else { 446 | "rgb(210, 210, 210)" 447 | }; 448 | 449 | rsx!(rect { 450 | onmouseenter, 451 | onmouseleave, 452 | onclick, 453 | background, 454 | width: "100%", 455 | padding: "0 0 0 {(depth * 10) + 10}", 456 | main_align: "center", 457 | height: "27", 458 | color, 459 | font_size: "14", 460 | {children} 461 | }) 462 | } 463 | -------------------------------------------------------------------------------- /src/views/file_explorer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod file_explorer_state; 2 | pub mod file_explorer_ui; 3 | -------------------------------------------------------------------------------- /src/views/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod commander; 2 | pub mod file_explorer; 3 | pub mod panels; 4 | pub mod search; 5 | -------------------------------------------------------------------------------- /src/views/panels/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod tabs; 2 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/commands.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::spawn; 2 | use tokio::fs::OpenOptions; 3 | 4 | use crate::{ 5 | constants::{BASE_FONT_SIZE, MAX_FONT_SIZE}, 6 | state::{AppStateUtils, Channel, CommandRunContext, EditorCommand, RadioAppState}, 7 | }; 8 | 9 | use crate::views::panels::tabs::editor::utils::AppStateEditorUtils; 10 | 11 | #[derive(Clone)] 12 | pub struct IncreaseFontSizeCommand(pub RadioAppState); 13 | 14 | impl IncreaseFontSizeCommand { 15 | pub fn id() -> &'static str { 16 | "increase-editor-font-size" 17 | } 18 | } 19 | 20 | impl EditorCommand for IncreaseFontSizeCommand { 21 | fn matches(&self, input: &str) -> bool { 22 | self.text().to_lowercase().contains(&input.to_lowercase()) 23 | } 24 | 25 | fn id(&self) -> &str { 26 | Self::id() 27 | } 28 | 29 | fn text(&self) -> &str { 30 | "Increase Font Size" 31 | } 32 | 33 | fn run(&self, _ctx: &mut CommandRunContext) { 34 | let mut radio_app_state = self.0; 35 | let mut app_state = radio_app_state.write_channel(Channel::AllTabs); 36 | let font_size = app_state.font_size(); 37 | app_state.set_fontsize((font_size + 2.0).clamp(BASE_FONT_SIZE, MAX_FONT_SIZE)); 38 | } 39 | } 40 | 41 | #[derive(Clone)] 42 | pub struct DecreaseFontSizeCommand(pub RadioAppState); 43 | 44 | impl DecreaseFontSizeCommand { 45 | pub fn id() -> &'static str { 46 | "decrease-editor-font-size" 47 | } 48 | } 49 | 50 | impl EditorCommand for DecreaseFontSizeCommand { 51 | fn matches(&self, input: &str) -> bool { 52 | self.text().to_lowercase().contains(&input.to_lowercase()) 53 | } 54 | 55 | fn id(&self) -> &str { 56 | Self::id() 57 | } 58 | 59 | fn text(&self) -> &str { 60 | "Decrease Font Size" 61 | } 62 | 63 | fn run(&self, _ctx: &mut CommandRunContext) { 64 | let mut radio_app_state = self.0; 65 | let mut app_state = radio_app_state.write_channel(Channel::AllTabs); 66 | let font_size = app_state.font_size(); 67 | app_state.set_fontsize((font_size - 2.0).clamp(BASE_FONT_SIZE, MAX_FONT_SIZE)); 68 | } 69 | } 70 | 71 | #[derive(Clone)] 72 | pub struct SaveFileCommand(pub RadioAppState); 73 | 74 | impl SaveFileCommand { 75 | pub fn id() -> &'static str { 76 | "save-file" 77 | } 78 | } 79 | 80 | impl EditorCommand for SaveFileCommand { 81 | fn matches(&self, input: &str) -> bool { 82 | self.text().to_lowercase().contains(&input.to_lowercase()) 83 | } 84 | 85 | fn id(&self) -> &str { 86 | Self::id() 87 | } 88 | 89 | fn text(&self) -> &str { 90 | "Save File" 91 | } 92 | 93 | fn run(&self, _ctx: &mut CommandRunContext) { 94 | let mut radio_app_state = self.0; 95 | let active_tab = radio_app_state.get_active_tab(); 96 | 97 | if let Some(active_tab) = active_tab { 98 | let editor_data = radio_app_state.read().editor_tab_data(active_tab); 99 | 100 | if let Some((Some(file_path), rope, transport)) = editor_data { 101 | spawn(async move { 102 | let writer = transport 103 | .open(&file_path, OpenOptions::new().write(true).truncate(true)) 104 | .await 105 | .unwrap(); 106 | let std_writer = writer.into_std().await; 107 | rope.borrow_mut().write_to(std_writer).unwrap(); 108 | let mut app_state = 109 | radio_app_state.write_channel(Channel::follow_tab(active_tab)); 110 | let editor_tab = app_state.editor_tab_mut(active_tab); 111 | editor_tab.editor.mark_as_saved() 112 | }); 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/editor_data.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::cell::RefCell; 3 | use std::rc::Rc; 4 | use std::{cmp::Ordering, fmt::Display, ops::Range, path::PathBuf}; 5 | 6 | use dioxus_clipboard::prelude::UseClipboard; 7 | use freya::core::event_loop_messages::{EventLoopMessage, TextGroupMeasurement}; 8 | use freya::events::Code; 9 | use freya::hooks::{EditorHistory, HistoryChange, Line, LinesIterator, TextCursor, TextEditor}; 10 | use freya::prelude::Rope; 11 | use freya_hooks::{EditableEvent, TextDragging, TextEvent, UsePlatform}; 12 | use lsp_types::Url; 13 | use skia_safe::textlayout::FontCollection; 14 | use uuid::Uuid; 15 | 16 | use crate::{fs::FSTransport, lsp::LanguageId, metrics::EditorMetrics}; 17 | 18 | pub type SharedRope = Rc>; 19 | 20 | #[derive(Clone, PartialEq)] 21 | pub enum EditorType { 22 | #[allow(dead_code)] 23 | Memory { 24 | title: String, 25 | }, 26 | FS { 27 | path: PathBuf, 28 | root_path: PathBuf, 29 | }, 30 | } 31 | 32 | impl EditorType { 33 | pub fn content_id(&self) -> Option { 34 | match self { 35 | Self::Memory { .. } => None, 36 | Self::FS { path, .. } => Some(path.file_name().unwrap().to_str().unwrap().to_owned()), 37 | } 38 | } 39 | 40 | pub fn title(&self) -> String { 41 | match self { 42 | Self::Memory { title } => title.clone(), 43 | Self::FS { path, .. } => path.file_name().unwrap().to_str().unwrap().to_owned(), 44 | } 45 | } 46 | 47 | pub fn paths(&self) -> Option<(&PathBuf, &PathBuf)> { 48 | match self { 49 | #[allow(unused_variables)] 50 | Self::Memory { title } => None, 51 | Self::FS { path, root_path } => Some((path, root_path)), 52 | } 53 | } 54 | 55 | pub fn language_id(&self) -> LanguageId { 56 | if let Some(ext) = self.paths().and_then(|(path, _)| path.extension()) { 57 | LanguageId::parse(ext.to_str().unwrap()) 58 | } else { 59 | LanguageId::default() 60 | } 61 | } 62 | } 63 | 64 | #[derive(PartialEq, Clone)] 65 | pub struct Diagnostics { 66 | pub range: lsp_types::Range, 67 | pub line: u32, 68 | pub content: String, 69 | } 70 | 71 | pub struct EditorData { 72 | pub(crate) editor_type: EditorType, 73 | pub(crate) cursor: TextCursor, 74 | pub(crate) history: EditorHistory, 75 | pub(crate) rope: SharedRope, 76 | pub(crate) selected: Option<(usize, usize)>, 77 | pub(crate) clipboard: UseClipboard, 78 | pub(crate) last_saved_history_change: usize, 79 | pub(crate) transport: FSTransport, 80 | pub(crate) metrics: EditorMetrics, 81 | pub(crate) dragging: TextDragging, 82 | pub(crate) diagnostics: Option, 83 | pub(crate) text_id: Uuid, 84 | } 85 | 86 | impl EditorData { 87 | pub fn new( 88 | editor_type: EditorType, 89 | rope: SharedRope, 90 | pos: usize, 91 | clipboard: UseClipboard, 92 | transport: FSTransport, 93 | ) -> Self { 94 | Self { 95 | editor_type, 96 | rope, 97 | cursor: TextCursor::new(pos), 98 | selected: None, 99 | history: EditorHistory::new(), 100 | last_saved_history_change: 0, 101 | clipboard, 102 | transport, 103 | metrics: EditorMetrics::new(), 104 | dragging: TextDragging::None, 105 | text_id: Uuid::new_v4(), 106 | diagnostics: None, 107 | } 108 | } 109 | 110 | pub fn uri(&self) -> Option { 111 | self.editor_type 112 | .paths() 113 | .and_then(|(path, _)| Url::from_file_path(path).ok()) 114 | } 115 | 116 | pub fn content(&self) -> String { 117 | self.rope.borrow().to_string() 118 | } 119 | 120 | pub fn is_edited(&self) -> bool { 121 | self.history.current_change() != self.last_saved_history_change 122 | } 123 | 124 | pub fn mark_as_saved(&mut self) { 125 | self.last_saved_history_change = self.history.current_change(); 126 | } 127 | 128 | pub fn path(&self) -> Option<&PathBuf> { 129 | self.editor_type.paths().map(|(path, _)| path) 130 | } 131 | 132 | pub fn cursor(&self) -> TextCursor { 133 | self.cursor.clone() 134 | } 135 | 136 | pub fn rope(&self) -> &SharedRope { 137 | &self.rope 138 | } 139 | 140 | pub fn run_parser(&mut self) { 141 | self.metrics.run_parser(&self.rope.borrow()); 142 | } 143 | 144 | pub fn measure_longest_line(&mut self, font_size: f32, font_collection: &FontCollection) { 145 | self.metrics 146 | .measure_longest_line(font_size, &self.rope.borrow(), font_collection); 147 | } 148 | 149 | pub fn editor_type(&self) -> &EditorType { 150 | &self.editor_type 151 | } 152 | 153 | pub fn process_event(&mut self, edit_event: &EditableEvent) -> bool { 154 | let mut processed = false; 155 | let res = match edit_event { 156 | EditableEvent::MouseDown(e, id) => { 157 | let coords = e.get_element_coordinates(); 158 | 159 | self.dragging.set_cursor_coords(coords); 160 | self.clear_selection(); 161 | processed = true; 162 | 163 | Some((*id, Some(coords), None)) 164 | } 165 | EditableEvent::MouseMove(e, id) => { 166 | if let Some(src) = self.dragging.get_cursor_coords() { 167 | let new_dist = e.get_element_coordinates(); 168 | 169 | Some((*id, None, Some((src, new_dist)))) 170 | } else { 171 | None 172 | } 173 | } 174 | EditableEvent::Click => { 175 | let selection = &mut self.dragging; 176 | match selection { 177 | TextDragging::FromCursorToPoint { shift, clicked, .. } if *shift => { 178 | *clicked = false; 179 | } 180 | _ => { 181 | *selection = TextDragging::None; 182 | } 183 | } 184 | None 185 | } 186 | EditableEvent::KeyDown(e) => { 187 | if e.code == Code::ShiftLeft { 188 | let cursor = self.cursor_pos(); 189 | let dragging = &mut self.dragging; 190 | match dragging { 191 | TextDragging::FromCursorToPoint { 192 | shift: shift_pressed, 193 | .. 194 | } => { 195 | *shift_pressed = true; 196 | } 197 | TextDragging::None => { 198 | *dragging = TextDragging::FromCursorToPoint { 199 | shift: true, 200 | clicked: false, 201 | cursor, 202 | dist: None, 203 | } 204 | } 205 | _ => {} 206 | } 207 | } 208 | 209 | let event = self.process_key(&e.key, &e.code, &e.modifiers, true, true, true); 210 | if event.contains(TextEvent::TEXT_CHANGED) { 211 | self.run_parser(); 212 | self.dragging = TextDragging::None; 213 | } 214 | 215 | if !event.is_empty() { 216 | processed = true; 217 | } 218 | 219 | None 220 | } 221 | EditableEvent::KeyUp(e) => { 222 | if e.code == Code::ShiftLeft { 223 | if let TextDragging::FromCursorToPoint { shift, .. } = &mut self.dragging { 224 | *shift = false; 225 | } 226 | } else { 227 | self.dragging = TextDragging::None; 228 | } 229 | 230 | None 231 | } 232 | }; 233 | 234 | if let Some((cursor_id, cursor_position, cursor_selection)) = res { 235 | if self.dragging.has_cursor_coords() { 236 | UsePlatform::current() 237 | .send(EventLoopMessage::RemeasureTextGroup(TextGroupMeasurement { 238 | text_id: self.text_id, 239 | cursor_id, 240 | cursor_position, 241 | cursor_selection, 242 | })) 243 | .unwrap(); 244 | } 245 | } 246 | 247 | processed 248 | } 249 | } 250 | 251 | impl Display for EditorData { 252 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 253 | f.write_str(&self.rope.borrow().to_string()) 254 | } 255 | } 256 | 257 | impl TextEditor for EditorData { 258 | type LinesIterator<'a> 259 | = LinesIterator<'a> 260 | where 261 | Self: 'a; 262 | 263 | fn lines(&self) -> Self::LinesIterator<'_> { 264 | unimplemented!("Unused.") 265 | } 266 | 267 | fn insert_char(&mut self, ch: char, idx: usize) -> usize { 268 | let idx_utf8 = self.utf16_cu_to_char(idx); 269 | 270 | let len_before_insert = self.len_utf16_cu(); 271 | self.rope.borrow_mut().insert_char(idx_utf8, ch); 272 | let len_after_insert = self.len_utf16_cu(); 273 | 274 | let inserted_text_len = len_after_insert - len_before_insert; 275 | 276 | self.history.push_change(HistoryChange::InsertChar { 277 | idx, 278 | ch, 279 | len: inserted_text_len, 280 | }); 281 | 282 | inserted_text_len 283 | } 284 | 285 | fn insert(&mut self, text: &str, idx: usize) -> usize { 286 | let idx_utf8 = self.utf16_cu_to_char(idx); 287 | 288 | let len_before_insert = self.len_utf16_cu(); 289 | self.rope.borrow_mut().insert(idx_utf8, text); 290 | let len_after_insert = self.len_utf16_cu(); 291 | 292 | let inserted_text_len = len_after_insert - len_before_insert; 293 | 294 | self.history.push_change(HistoryChange::InsertText { 295 | idx, 296 | text: text.to_owned(), 297 | len: inserted_text_len, 298 | }); 299 | 300 | inserted_text_len 301 | } 302 | 303 | fn remove(&mut self, range_utf16: Range) -> usize { 304 | let range = 305 | self.utf16_cu_to_char(range_utf16.start)..self.utf16_cu_to_char(range_utf16.end); 306 | let text = self.rope.borrow().slice(range.clone()).to_string(); 307 | 308 | let len_before_remove = self.len_utf16_cu(); 309 | self.rope.borrow_mut().remove(range); 310 | let len_after_remove = self.len_utf16_cu(); 311 | 312 | let removed_text_len = len_before_remove - len_after_remove; 313 | 314 | self.history.push_change(HistoryChange::Remove { 315 | idx: range_utf16.end - removed_text_len, 316 | text, 317 | len: removed_text_len, 318 | }); 319 | 320 | removed_text_len 321 | } 322 | 323 | fn char_to_line(&self, char_idx: usize) -> usize { 324 | self.rope.borrow().char_to_line(char_idx) 325 | } 326 | 327 | fn line_to_char(&self, line_idx: usize) -> usize { 328 | self.rope.borrow().line_to_char(line_idx) 329 | } 330 | 331 | fn utf16_cu_to_char(&self, utf16_cu_idx: usize) -> usize { 332 | self.rope.borrow().utf16_cu_to_char(utf16_cu_idx) 333 | } 334 | 335 | fn char_to_utf16_cu(&self, idx: usize) -> usize { 336 | self.rope.borrow().char_to_utf16_cu(idx) 337 | } 338 | 339 | fn line(&self, line_idx: usize) -> Option> { 340 | let rope = self.rope.borrow(); 341 | let line = rope.get_line(line_idx); 342 | 343 | line.map(|line| Line { 344 | text: Cow::Owned(line.to_string()), 345 | utf16_len: line.len_utf16_cu(), 346 | }) 347 | } 348 | 349 | fn len_lines(&self) -> usize { 350 | self.rope.borrow().len_lines() 351 | } 352 | 353 | fn len_chars(&self) -> usize { 354 | self.rope.borrow().len_chars() 355 | } 356 | 357 | fn len_utf16_cu(&self) -> usize { 358 | self.rope.borrow().len_utf16_cu() 359 | } 360 | 361 | fn cursor(&self) -> &TextCursor { 362 | &self.cursor 363 | } 364 | 365 | fn cursor_mut(&mut self) -> &mut TextCursor { 366 | &mut self.cursor 367 | } 368 | 369 | fn expand_selection_to_cursor(&mut self) { 370 | let pos = self.cursor_pos(); 371 | if let Some(selected) = self.selected.as_mut() { 372 | selected.1 = pos; 373 | } else { 374 | self.selected = Some((self.cursor_pos(), self.cursor_pos())) 375 | } 376 | } 377 | 378 | fn has_any_selection(&self) -> bool { 379 | self.selected.is_some() 380 | } 381 | 382 | fn get_selection(&self) -> Option<(usize, usize)> { 383 | self.selected 384 | } 385 | 386 | fn get_visible_selection(&self, editor_id: usize) -> Option<(usize, usize)> { 387 | let (selected_from, selected_to) = self.selected?; 388 | let selected_from_row = self.char_to_line(self.utf16_cu_to_char(selected_from)); 389 | let selected_to_row = self.char_to_line(self.utf16_cu_to_char(selected_to)); 390 | 391 | let editor_row_idx = self.char_to_utf16_cu(self.line_to_char(editor_id)); 392 | let selected_from_row_idx = self.char_to_utf16_cu(self.line_to_char(selected_from_row)); 393 | let selected_to_row_idx = self.char_to_utf16_cu(self.line_to_char(selected_to_row)); 394 | 395 | let selected_from_col_idx = selected_from - selected_from_row_idx; 396 | let selected_to_col_idx = selected_to - selected_to_row_idx; 397 | 398 | // Between starting line and endling line 399 | if (editor_id > selected_from_row && editor_id < selected_to_row) 400 | || (editor_id < selected_from_row && editor_id > selected_to_row) 401 | { 402 | let len = self.line(editor_id).unwrap().utf16_len(); 403 | return Some((0, len)); 404 | } 405 | 406 | let highlights = match selected_from_row.cmp(&selected_to_row) { 407 | // Selection direction is from bottom -> top 408 | Ordering::Greater => { 409 | if selected_from_row == editor_id { 410 | // Starting line 411 | Some((0, selected_from_col_idx)) 412 | } else if selected_to_row == editor_id { 413 | // Ending line 414 | let len = self.line(selected_to_row).unwrap().utf16_len(); 415 | Some((selected_to_col_idx, len)) 416 | } else { 417 | None 418 | } 419 | } 420 | // Selection direction is from top -> bottom 421 | Ordering::Less => { 422 | if selected_from_row == editor_id { 423 | // Starting line 424 | let len = self.line(selected_from_row).unwrap().utf16_len(); 425 | Some((selected_from_col_idx, len)) 426 | } else if selected_to_row == editor_id { 427 | // Ending line 428 | Some((0, selected_to_col_idx)) 429 | } else { 430 | None 431 | } 432 | } 433 | Ordering::Equal if selected_from_row == editor_id => { 434 | // Starting and endline line are the same 435 | Some((selected_from - editor_row_idx, selected_to - editor_row_idx)) 436 | } 437 | _ => None, 438 | }; 439 | 440 | highlights 441 | } 442 | 443 | fn set(&mut self, text: &str) { 444 | self.rope.borrow_mut().remove(0..); 445 | self.rope.borrow_mut().insert(0, text); 446 | } 447 | 448 | fn clear_selection(&mut self) { 449 | self.selected = None; 450 | } 451 | 452 | fn measure_new_selection(&self, from: usize, to: usize, editor_id: usize) -> (usize, usize) { 453 | let row_idx = self.line_to_char(editor_id); 454 | let row_idx = self.char_to_utf16_cu(row_idx); 455 | if let Some((start, _)) = self.selected { 456 | (start, row_idx + to) 457 | } else { 458 | (row_idx + from, row_idx + to) 459 | } 460 | } 461 | 462 | fn measure_new_cursor(&self, to: usize, editor_id: usize) -> TextCursor { 463 | let row_char = self.line_to_char(editor_id); 464 | let pos = self.char_to_utf16_cu(row_char) + to; 465 | TextCursor::new(pos) 466 | } 467 | 468 | fn get_clipboard(&mut self) -> &mut UseClipboard { 469 | &mut self.clipboard 470 | } 471 | 472 | fn set_selection(&mut self, selected: (usize, usize)) { 473 | self.selected = Some(selected); 474 | } 475 | 476 | fn get_selected_text(&self) -> Option { 477 | let (start, end) = self.get_selection_range()?; 478 | 479 | Some(self.rope.borrow().get_slice(start..end)?.to_string()) 480 | } 481 | 482 | fn get_selection_range(&self) -> Option<(usize, usize)> { 483 | let (start, end) = self.selected?; 484 | 485 | // Use left-to-right selection 486 | let (start, end) = if start < end { 487 | (start, end) 488 | } else { 489 | (end, start) 490 | }; 491 | 492 | Some((start, end)) 493 | } 494 | 495 | fn redo(&mut self) -> Option { 496 | if self.history.can_redo() { 497 | self.history.redo(&mut self.rope.borrow_mut()) 498 | } else { 499 | None 500 | } 501 | } 502 | 503 | fn undo(&mut self) -> Option { 504 | if self.history.can_undo() { 505 | self.history.undo(&mut self.rope.borrow_mut()) 506 | } else { 507 | None 508 | } 509 | } 510 | 511 | fn editor_history(&mut self) -> &mut EditorHistory { 512 | &mut self.history 513 | } 514 | 515 | fn get_identation(&self) -> u8 { 516 | 4 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/editor_line.rs: -------------------------------------------------------------------------------- 1 | use dioxus_radio::hooks::RadioReducer; 2 | use dioxus_radio::prelude::use_radio; 3 | use freya::prelude::*; 4 | use skia_safe::textlayout::{Paragraph, RectHeightStyle, RectWidthStyle}; 5 | 6 | use crate::hooks::{use_computed, UseDebounce}; 7 | use crate::lsp::LspActionData; 8 | use crate::parser::TextNode; 9 | use crate::state::{EditorAction, EditorActionData, TabId}; 10 | use crate::views::panels::tabs::editor::hover_box::HoverBox; 11 | use crate::views::panels::tabs::editor::AppStateEditorUtils; 12 | use crate::{hooks::UseEdit, utils::create_paragraph}; 13 | use crate::{lsp::LspAction, state::Channel}; 14 | 15 | use super::SharedRope; 16 | 17 | #[derive(Props, Clone)] 18 | pub struct BuilderArgs { 19 | pub(crate) tab_id: TabId, 20 | pub(crate) font_size: f32, 21 | pub(crate) line_height: f32, 22 | } 23 | 24 | impl PartialEq for BuilderArgs { 25 | fn eq(&self, other: &Self) -> bool { 26 | self.tab_id == other.tab_id 27 | && self.font_size == other.font_size 28 | && self.line_height == other.line_height 29 | } 30 | } 31 | 32 | #[derive(Props, Clone, PartialEq)] 33 | pub struct EditorLineProps { 34 | builder_args: BuilderArgs, 35 | line_index: usize, 36 | editable: UseEdit, 37 | debouncer: UseDebounce<(CursorPoint, u32, Paragraph)>, 38 | rope: SharedRope, 39 | } 40 | 41 | #[allow(non_snake_case)] 42 | pub fn EditorLine( 43 | EditorLineProps { 44 | builder_args: 45 | BuilderArgs { 46 | tab_id, 47 | font_size, 48 | line_height, 49 | }, 50 | line_index, 51 | editable, 52 | mut debouncer, 53 | rope, 54 | }: EditorLineProps, 55 | ) -> Element { 56 | let mut radio_app_state = use_radio(Channel::follow_tab(tab_id)); 57 | 58 | let app_state = radio_app_state.read(); 59 | let editor_tab = app_state.editor_tab(tab_id); 60 | let editor = &editor_tab.editor; 61 | let longest_width = editor.metrics.longest_width; 62 | let line = editor.metrics.syntax_blocks.get_line(line_index); 63 | let highlights = editable.highlights_attr(line_index, editor_tab); 64 | let gutter_width = font_size * 5.0; 65 | let cursor_reference = editable.cursor_attr(); 66 | let is_line_selected = editor.cursor_row() == line_index; 67 | 68 | let hover_diagnostics = use_computed(&editor.diagnostics, { 69 | to_owned![rope]; 70 | move |diagnostics| { 71 | if let Some(diagnostics) = diagnostics.as_ref() { 72 | if diagnostics.line == line_index as u32 { 73 | let rope = rope.borrow(); 74 | let line_str = rope.line(line_index).to_string(); 75 | let app_state = radio_app_state.read(); 76 | let paragraph = create_paragraph(&line_str, font_size, &app_state); 77 | let mut text_boxs = paragraph.get_rects_for_range( 78 | diagnostics.range.start.character as usize 79 | ..diagnostics.range.end.character as usize, 80 | RectHeightStyle::default(), 81 | RectWidthStyle::default(), 82 | ); 83 | if !text_boxs.is_empty() { 84 | return Some((text_boxs.remove(0), diagnostics.content.clone())); 85 | } 86 | } 87 | } 88 | None 89 | } 90 | }); 91 | 92 | let onmousedown = move |e: MouseEvent| { 93 | radio_app_state.apply(EditorAction { 94 | tab_id, 95 | data: EditorActionData::MouseDown { 96 | data: e.data, 97 | line_index, 98 | }, 99 | }); 100 | }; 101 | 102 | let onmouseleave = move |_| { 103 | debouncer.cancel(); 104 | let app_state = radio_app_state.read(); 105 | if let Some(lsp) = app_state.editor_tab_lsp(tab_id) { 106 | lsp.send(LspAction { 107 | tab_id, 108 | action: LspActionData::Clear, 109 | }); 110 | } 111 | }; 112 | 113 | let onmousemove = { 114 | to_owned![rope]; 115 | move |e: MouseEvent| { 116 | let coords = e.get_element_coordinates(); 117 | 118 | radio_app_state.apply(EditorAction { 119 | tab_id, 120 | data: EditorActionData::MouseMove { 121 | data: e.data, 122 | line_index, 123 | }, 124 | }); 125 | 126 | let app_state = radio_app_state.read(); 127 | let Some(lsp) = app_state.editor_tab_lsp(tab_id) else { 128 | return; 129 | }; 130 | 131 | let rope = rope.borrow(); 132 | let line_str = rope.line(line_index).to_string(); 133 | 134 | let paragraph = create_paragraph(&line_str, font_size, &app_state); 135 | 136 | if (coords.x as f32) < paragraph.max_intrinsic_width() { 137 | debouncer.action((coords, line_index as u32, paragraph)); 138 | } else { 139 | lsp.send(LspAction { 140 | tab_id, 141 | action: LspActionData::Clear, 142 | }); 143 | } 144 | } 145 | }; 146 | 147 | // Only show the cursor in the active line 148 | let cursor_index = if is_line_selected { 149 | editor.cursor_col().to_string() 150 | } else { 151 | "none".to_string() 152 | }; 153 | 154 | // Only highlight the gutter on the active line 155 | let gutter_color = if is_line_selected { 156 | "rgb(235, 235, 235)" 157 | } else { 158 | "rgb(135, 135, 135)" 159 | }; 160 | 161 | // Only highlight the active line when there is no text selected 162 | let line_background = if is_line_selected && !editable.has_any_highlight(editor_tab) { 163 | "rgb(70, 70, 70)" 164 | } else { 165 | "none" 166 | }; 167 | 168 | rsx!( 169 | rect { 170 | height: "{line_height}", 171 | direction: "horizontal", 172 | background: line_background, 173 | cross_align: "center", 174 | rect { 175 | width: "{gutter_width}", 176 | direction: "horizontal", 177 | main_align: "end", 178 | label { 179 | margin: "0 20 0 0", 180 | font_size: "{font_size}", 181 | color: gutter_color, 182 | "{line_index + 1} " 183 | } 184 | } 185 | if let Some((text_box, content)) = hover_diagnostics.borrow().value.as_ref() { 186 | rect { 187 | position: "absolute", 188 | position_top: "{line_height}", 189 | position_left: "{gutter_width + text_box.rect.left}", 190 | HoverBox { 191 | content: "{content}" 192 | } 193 | } 194 | } 195 | paragraph { 196 | onmousedown, 197 | onmousemove, 198 | onmouseleave, 199 | min_width: "fill", 200 | width: "{longest_width}", 201 | height: "fill", 202 | main_align: "center", 203 | cursor_index, 204 | cursor_color: "white", 205 | max_lines: "1", 206 | cursor_reference, 207 | cursor_mode: "editable", 208 | cursor_id: "{line_index}", 209 | highlights, 210 | highlight_color: "rgb(65, 65, 65)", 211 | highlight_mode: "expanded", 212 | font_size: "{font_size}", 213 | font_family: "Jetbrains Mono", 214 | {line.iter().enumerate().map(|(i, (syntax_type, text))| { 215 | let rope = rope.borrow(); 216 | let text: Cow = match text { 217 | TextNode::Range(word_pos) => { 218 | rope.slice(word_pos.clone()).into() 219 | }, 220 | TextNode::LineOfChars { len, char } => { 221 | Cow::Owned(char.to_string().repeat(*len)) 222 | } 223 | }; 224 | 225 | rsx!( 226 | text { 227 | key: "{i}", 228 | color: syntax_type.color(), 229 | {text} 230 | } 231 | ) 232 | })} 233 | } 234 | } 235 | ) 236 | } 237 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/editor_tab.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::Arc}; 2 | 3 | use crate::{ 4 | fs::FSReadTransportInterface, 5 | lsp::{LSPClient, LspAction, LspActionData, LspConfig}, 6 | state::{ 7 | AppSettings, AppState, Channel, EditorCommands, KeyboardShortcuts, PanelTab, PanelTabData, 8 | RadioAppState, TabId, TabProps, 9 | }, 10 | views::panels::tabs::editor::TabEditorUtils, 11 | Args, 12 | }; 13 | 14 | use freya::prelude::keyboard::Modifiers; 15 | use freya::prelude::*; 16 | 17 | use skia_safe::textlayout::FontCollection; 18 | use tracing::info; 19 | 20 | use super::{ 21 | commands::{DecreaseFontSizeCommand, IncreaseFontSizeCommand, SaveFileCommand}, 22 | editor_data::{EditorData, EditorType}, 23 | editor_ui::EditorUi, 24 | SharedRope, 25 | }; 26 | 27 | /// A tab with an embedded Editor. 28 | pub struct EditorTab { 29 | pub editor: EditorData, 30 | pub id: TabId, 31 | pub focus_id: AccessibilityId, 32 | } 33 | 34 | impl PanelTab for EditorTab { 35 | fn on_close(&mut self, app_state: &mut AppState) { 36 | // Notify the language server that a document was closed 37 | let language_id = self.editor.editor_type.language_id(); 38 | let language_server_id = language_id.language_server(); 39 | 40 | // Only if it ever hard LSP support 41 | if let Some(language_server_id) = language_server_id { 42 | let lsp = app_state.language_servers.get_mut(language_server_id); 43 | 44 | // And there was an actual language server running 45 | if let Some(lsp) = lsp { 46 | let file_uri = self.editor.uri(); 47 | if let Some(file_uri) = file_uri { 48 | lsp.send(LspAction { 49 | tab_id: self.id, 50 | action: LspActionData::CloseFile { file_uri }, 51 | }); 52 | } 53 | } 54 | } 55 | } 56 | 57 | fn on_settings_changed( 58 | &mut self, 59 | app_settings: &AppSettings, 60 | font_collection: &FontCollection, 61 | ) { 62 | self.editor 63 | .measure_longest_line(app_settings.editor.font_size, font_collection); 64 | } 65 | 66 | fn get_data(&self) -> PanelTabData { 67 | let title = self.editor.editor_type.title(); 68 | PanelTabData { 69 | id: self.id, 70 | title, 71 | edited: self.editor.is_edited(), 72 | focus_id: self.focus_id, 73 | content_id: self 74 | .editor 75 | .editor_type 76 | .content_id() 77 | .unwrap_or_else(|| self.id.to_string()), 78 | } 79 | } 80 | fn render(&self) -> fn(TabProps) -> Element { 81 | EditorUi 82 | } 83 | 84 | fn as_any(&self) -> &dyn std::any::Any { 85 | self 86 | } 87 | 88 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { 89 | self 90 | } 91 | } 92 | 93 | impl EditorTab { 94 | pub fn new(id: TabId, editor: EditorData) -> Self { 95 | Self { 96 | editor, 97 | id, 98 | focus_id: UseFocus::new_id(), 99 | } 100 | } 101 | 102 | /// Open an EditorTab in the focused panel. 103 | pub fn open_with( 104 | mut radio: RadioAppState, 105 | app_state: &mut AppState, 106 | path: PathBuf, 107 | root_path: PathBuf, 108 | read_transport: Box, 109 | ) { 110 | let rope = SharedRope::default(); 111 | let tab_id = TabId::new(); 112 | 113 | let data = EditorData::new( 114 | EditorType::FS { 115 | path: path.clone(), 116 | root_path: root_path.clone(), 117 | }, 118 | rope.clone(), 119 | 0, 120 | app_state.clipboard, 121 | app_state.default_transport.clone(), 122 | ); 123 | 124 | let tab = Self::new(tab_id, data); 125 | 126 | // Dont create the same tab twice 127 | if !app_state.push_tab(tab, app_state.focused_panel) { 128 | return; 129 | } 130 | 131 | // Load file content asynchronously 132 | spawn_forever({ 133 | to_owned![path]; 134 | async move { 135 | let content = read_transport.read_to_string(&path).await; 136 | if let Ok(content) = content { 137 | rope.borrow_mut().insert(0, &content); 138 | 139 | let mut app_state = radio.write_channel(Channel::follow_tab(tab_id)); 140 | let font_size = app_state.font_size(); 141 | let font_collection = app_state.font_collection.clone(); 142 | 143 | let tab = app_state.tab_mut(&tab_id); 144 | let editor_tab = tab.as_text_editor_mut().unwrap(); 145 | editor_tab.editor.run_parser(); 146 | editor_tab 147 | .editor 148 | .measure_longest_line(font_size, &font_collection); 149 | 150 | info!("Loaded file content for {path:?}"); 151 | } 152 | } 153 | }); 154 | 155 | let args = consume_context::>(); 156 | 157 | let lsp_config = args 158 | .lsp 159 | .then(|| { 160 | LspConfig::new(EditorType::FS { 161 | path, 162 | root_path: root_path.clone(), 163 | }) 164 | }) 165 | .flatten(); 166 | 167 | if let Some(lsp_config) = lsp_config { 168 | let (lsp, needs_initialization) = LSPClient::open_with(radio, app_state, &lsp_config); 169 | 170 | // Registry the LSP client 171 | if needs_initialization { 172 | app_state.insert_lsp_client(lsp_config.language_server, lsp.clone()); 173 | lsp.send(LspAction { 174 | tab_id, 175 | action: LspActionData::Initialize(root_path), 176 | }); 177 | } 178 | 179 | // Open File in LSP Client 180 | lsp.send(LspAction { 181 | tab_id, 182 | action: LspActionData::OpenFile, 183 | }); 184 | } 185 | } 186 | 187 | /// Initialize the EditorTab module. 188 | pub fn init( 189 | keyboard_shorcuts: &mut KeyboardShortcuts, 190 | commands: &mut EditorCommands, 191 | radio_app_state: RadioAppState, 192 | ) { 193 | // Register Commands 194 | commands.register(IncreaseFontSizeCommand(radio_app_state)); 195 | commands.register(DecreaseFontSizeCommand(radio_app_state)); 196 | commands.register(SaveFileCommand(radio_app_state)); 197 | 198 | // Register Shortcuts 199 | keyboard_shorcuts.register( 200 | |data: &KeyboardData, 201 | commands: &mut EditorCommands, 202 | _radio_app_state: RadioAppState| { 203 | let is_pressing_alt = data.modifiers == Modifiers::ALT; 204 | let is_pressing_ctrl = data.modifiers == Modifiers::CONTROL; 205 | match data.code { 206 | // Pressing `Alt ,` 207 | Code::Period if is_pressing_alt => { 208 | commands.trigger(IncreaseFontSizeCommand::id()); 209 | } 210 | // Pressing `Alt .` 211 | Code::Comma if is_pressing_alt => { 212 | commands.trigger(DecreaseFontSizeCommand::id()); 213 | } 214 | // Pressing `Ctrl S` 215 | Code::KeyS if is_pressing_ctrl => { 216 | commands.trigger(SaveFileCommand::id()); 217 | } 218 | _ => return false, 219 | } 220 | 221 | true 222 | }, 223 | ) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/editor_ui.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsStr, path::PathBuf, time::Duration}; 2 | 3 | use crate::hooks::*; 4 | use crate::lsp::{LspAction, LspActionData}; 5 | use crate::state::{EditorAction, EditorActionData, TabProps}; 6 | use crate::views::panels::tabs::editor::AppStateEditorUtils; 7 | use crate::views::panels::tabs::editor::BuilderArgs; 8 | use crate::views::panels::tabs::editor::EditorLine; 9 | use crate::{components::*, state::Channel}; 10 | 11 | use dioxus_radio::hooks::RadioReducer; 12 | use dioxus_radio::prelude::use_radio; 13 | use freya::events::KeyboardEvent; 14 | use freya::prelude::*; 15 | use lsp_types::Position; 16 | 17 | use skia_safe::textlayout::Paragraph; 18 | 19 | #[allow(non_snake_case)] 20 | pub fn EditorUi(TabProps { tab_id }: TabProps) -> Element { 21 | // Subscribe to the changes of this Tab. 22 | let mut radio_app_state = use_radio(Channel::follow_tab(tab_id)); 23 | 24 | let app_state = radio_app_state.read(); 25 | let editor_tab = app_state.editor_tab(tab_id); 26 | let editor = &editor_tab.editor; 27 | let paths = editor.editor_type().paths(); 28 | let rope = editor.rope().clone(); 29 | 30 | let mut focus = use_focus_for_id(editor_tab.focus_id); 31 | 32 | // Initialize the editable text 33 | let editable = use_edit(radio_app_state, tab_id, editor_tab.editor.text_id); 34 | 35 | // The scroll positions of the editor 36 | let mut scroll_offsets = use_signal(|| (0, 0)); 37 | 38 | let mut pressing_shift = use_signal(|| false); 39 | let mut pressing_alt = use_signal(|| false); 40 | 41 | // Send hover notifications to the LSP only every 300ms and when hovering 42 | let debouncer = use_debounce( 43 | Duration::from_millis(300), 44 | move |(coords, line_index, paragraph): (CursorPoint, u32, Paragraph)| { 45 | let app_state = radio_app_state.read(); 46 | if let Some(lsp) = app_state.editor_tab_lsp(tab_id) { 47 | let glyph = 48 | paragraph.get_glyph_position_at_coordinate((coords.x as i32, coords.y as i32)); 49 | lsp.send(LspAction { 50 | tab_id, 51 | action: LspActionData::Hover { 52 | position: Position::new(line_index, glyph.position as u32), 53 | }, 54 | }); 55 | } 56 | }, 57 | ); 58 | 59 | let line_height = app_state.line_height(); 60 | let font_size = app_state.font_size(); 61 | 62 | let line_height = (font_size * line_height).floor(); 63 | let lines_len = editor.metrics.syntax_blocks.len(); 64 | 65 | let onscroll = move |(axis, scroll): (Axis, i32)| match axis { 66 | Axis::X => { 67 | if scroll_offsets.read().0 != scroll { 68 | scroll_offsets.write().0 = scroll 69 | } 70 | } 71 | Axis::Y => { 72 | if scroll_offsets.read().1 != scroll { 73 | scroll_offsets.write().1 = scroll 74 | } 75 | } 76 | }; 77 | 78 | let onclick = move |e: MouseEvent| { 79 | e.stop_propagation(); 80 | focus.request_focus(); 81 | radio_app_state.apply(EditorAction { 82 | tab_id, 83 | data: EditorActionData::Click, 84 | }); 85 | }; 86 | 87 | let onkeyup = move |e: KeyboardEvent| { 88 | match &e.key { 89 | Key::Shift => { 90 | pressing_shift.set(false); 91 | } 92 | Key::Alt => { 93 | pressing_alt.set(false); 94 | } 95 | _ => {} 96 | }; 97 | 98 | radio_app_state.apply(EditorAction { 99 | tab_id, 100 | data: EditorActionData::KeyUp { data: e.data }, 101 | }); 102 | }; 103 | 104 | let onkeydown = move |e: KeyboardEvent| { 105 | focus.prevent_navigation(); 106 | e.stop_propagation(); 107 | 108 | match &e.key { 109 | Key::Shift => { 110 | pressing_shift.set(true); 111 | } 112 | Key::Alt => { 113 | pressing_alt.set(true); 114 | } 115 | _ => {} 116 | }; 117 | 118 | radio_app_state.apply(EditorAction { 119 | tab_id, 120 | data: EditorActionData::KeyDown { 121 | data: e.data, 122 | scroll_offsets, 123 | line_height, 124 | lines_len, 125 | }, 126 | }); 127 | }; 128 | 129 | rsx!( 130 | rect { 131 | width: "100%", 132 | height: "100%", 133 | background: "rgb(29, 32, 33)", 134 | if let Some((path, root_path)) = paths { 135 | FilePath { 136 | path: path.clone(), 137 | root_path: root_path.clone(), 138 | } 139 | } 140 | rect { 141 | a11y_id: focus.attribute(), 142 | onkeydown, 143 | onkeyup, 144 | onclick, 145 | EditorScrollView { 146 | offset_x: scroll_offsets.read().0, 147 | offset_y: scroll_offsets.read().1, 148 | onscroll, 149 | length: lines_len, 150 | item_size: line_height, 151 | builder_args: BuilderArgs { 152 | tab_id, 153 | font_size, 154 | line_height, 155 | }, 156 | pressing_alt, 157 | pressing_shift, 158 | builder: move |i: usize, builder_args: &BuilderArgs| rsx!( 159 | EditorLine { 160 | key: "{i}", 161 | line_index: i, 162 | builder_args: builder_args.clone(), 163 | editable, 164 | debouncer, 165 | rope: rope.clone() 166 | } 167 | ) 168 | } 169 | } 170 | } 171 | ) 172 | } 173 | 174 | #[allow(non_snake_case)] 175 | #[component] 176 | fn FilePath(path: PathBuf, root_path: PathBuf) -> Element { 177 | let relative_path = if path == root_path { 178 | path 179 | } else { 180 | path.strip_prefix(&root_path).unwrap().to_path_buf() 181 | }; 182 | 183 | let mut components = relative_path.components().enumerate().peekable(); 184 | 185 | let mut children = Vec::new(); 186 | 187 | while let Some((i, component)) = components.next() { 188 | let is_last = components.peek().is_none(); 189 | let text: &OsStr = component.as_ref(); 190 | 191 | children.push(rsx!( 192 | rect { 193 | key: "{i}", 194 | direction: "horizontal", 195 | label { 196 | "{text.to_str().unwrap()}" 197 | } 198 | if !is_last { 199 | label { 200 | margin: "0 6", 201 | ">" 202 | } 203 | } 204 | } 205 | )) 206 | } 207 | 208 | rsx!( 209 | rect { 210 | width: "100%", 211 | direction: "horizontal", 212 | color: "rgb(215, 215, 215)", 213 | padding: "0 10", 214 | height: "28", 215 | cross_align: "center", 216 | {children.into_iter()} 217 | } 218 | ) 219 | } 220 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/hover_box.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | 3 | #[allow(non_snake_case)] 4 | #[component] 5 | pub fn HoverBox(content: String) -> Element { 6 | let height = match content.trim().lines().count() { 7 | x if x < 2 => 65, 8 | x if x < 5 => 100, 9 | x if x < 7 => 135, 10 | _ => 170, 11 | }; 12 | 13 | rsx!( rect { 14 | width: "300", 15 | height: "{height}", 16 | background: "rgb(60, 60, 60)", 17 | corner_radius: "6", 18 | layer: "-50", 19 | padding: "8", 20 | shadow: "0 2 10 0 rgb(0, 0, 0, 40)", 21 | border: "1 solid rgb(45, 45, 45)", 22 | ScrollView { 23 | label { 24 | width: "100%", 25 | color: "rgb(245, 245, 245)", 26 | {content} 27 | } 28 | } 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/mod.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | mod editor_data; 3 | mod editor_line; 4 | mod editor_tab; 5 | mod editor_ui; 6 | mod hover_box; 7 | mod utils; 8 | 9 | pub use editor_data::*; 10 | pub use editor_line::*; 11 | pub use editor_tab::*; 12 | pub use utils::*; 13 | -------------------------------------------------------------------------------- /src/views/panels/tabs/editor/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ 4 | fs::FSTransport, 5 | lsp::{LSPClient, LspConfig}, 6 | state::{AppState, PanelTab, TabId}, 7 | }; 8 | 9 | use super::{EditorTab, SharedRope}; 10 | 11 | pub trait AppStateEditorUtils { 12 | fn editor_tab(&self, tab_id: TabId) -> &EditorTab; 13 | 14 | fn editor_tab_mut(&mut self, tab_id: TabId) -> &mut EditorTab; 15 | 16 | fn editor_tab_data(&self, tab_id: TabId) -> Option<(Option, SharedRope, FSTransport)>; 17 | 18 | fn editor_tab_lsp(&self, tab_id: TabId) -> Option; 19 | } 20 | 21 | impl AppStateEditorUtils for AppState { 22 | fn editor_tab(&self, tab_id: TabId) -> &EditorTab { 23 | self.tabs.get(&tab_id).unwrap().as_text_editor().unwrap() 24 | } 25 | 26 | fn editor_tab_mut(&mut self, tab_id: TabId) -> &mut EditorTab { 27 | self.tabs 28 | .get_mut(&tab_id) 29 | .unwrap() 30 | .as_text_editor_mut() 31 | .unwrap() 32 | } 33 | 34 | fn editor_tab_data(&self, tab_id: TabId) -> Option<(Option, SharedRope, FSTransport)> { 35 | let tab = self.tabs.get(&tab_id)?.as_text_editor()?; 36 | Some(( 37 | tab.editor.path().cloned(), 38 | tab.editor.rope.clone(), 39 | tab.editor.transport.clone(), 40 | )) 41 | } 42 | 43 | fn editor_tab_lsp(&self, tab_id: TabId) -> Option { 44 | let editor_tab = self.editor_tab(tab_id); 45 | let lsp_config = LspConfig::new(editor_tab.editor.editor_type.clone())?; 46 | self.lsp(&lsp_config).cloned() 47 | } 48 | } 49 | 50 | pub trait TabEditorUtils { 51 | fn as_text_editor(&self) -> Option<&EditorTab>; 52 | 53 | fn as_text_editor_mut(&mut self) -> Option<&mut EditorTab>; 54 | } 55 | 56 | impl TabEditorUtils for Box { 57 | fn as_text_editor(&self) -> Option<&EditorTab> { 58 | self.as_any().downcast_ref() 59 | } 60 | 61 | fn as_text_editor_mut(&mut self) -> Option<&mut EditorTab> { 62 | self.as_any_mut().downcast_mut() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/views/panels/tabs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod editor; 2 | pub mod settings; 3 | pub mod welcome; 4 | -------------------------------------------------------------------------------- /src/views/panels/tabs/settings.rs: -------------------------------------------------------------------------------- 1 | #[allow(non_snake_case)] 2 | pub mod Settings { 3 | use std::path::Path; 4 | 5 | use async_trait::async_trait; 6 | 7 | use crate::{ 8 | fs::FSReadTransportInterface, 9 | settings::settings_path, 10 | state::{AppState, RadioAppState}, 11 | views::panels::tabs::editor::EditorTab, 12 | }; 13 | 14 | pub fn open_with(radio_app_state: RadioAppState, app_state: &mut AppState) { 15 | let settings_path = settings_path().unwrap(); 16 | 17 | EditorTab::open_with( 18 | radio_app_state, 19 | app_state, 20 | settings_path.clone(), 21 | settings_path, 22 | Box::new(MemoryTransport( 23 | toml::to_string(&app_state.settings).unwrap(), 24 | )), 25 | ); 26 | } 27 | 28 | struct MemoryTransport(String); 29 | 30 | #[async_trait] 31 | impl FSReadTransportInterface for MemoryTransport { 32 | async fn read_to_string(&self, _path: &Path) -> tokio::io::Result { 33 | Ok(self.0.clone()) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/views/panels/tabs/welcome.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | 3 | use crate::state::{AppState, PanelTab, PanelTabData, TabId, TabProps}; 4 | 5 | pub struct WelcomeTab { 6 | id: TabId, 7 | focus_id: AccessibilityId, 8 | } 9 | 10 | impl PanelTab for WelcomeTab { 11 | fn get_data(&self) -> PanelTabData { 12 | PanelTabData { 13 | id: self.id, 14 | title: "welcome".to_string(), 15 | edited: false, 16 | focus_id: self.focus_id, 17 | content_id: "welcome".to_string(), 18 | } 19 | } 20 | fn render(&self) -> fn(TabProps) -> Element { 21 | render 22 | } 23 | 24 | fn as_any(&self) -> &dyn std::any::Any { 25 | self 26 | } 27 | 28 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { 29 | self 30 | } 31 | } 32 | 33 | impl WelcomeTab { 34 | pub fn new() -> Self { 35 | Self { 36 | id: TabId::new(), 37 | focus_id: UseFocus::new_id(), 38 | } 39 | } 40 | 41 | pub fn open_with(app_state: &mut AppState) { 42 | app_state.push_tab(Self::new(), app_state.focused_panel); 43 | } 44 | } 45 | 46 | pub fn render(_: TabProps) -> Element { 47 | rsx!( 48 | rect { 49 | height: "100%", 50 | width: "100%", 51 | background: "rgb(29, 32, 33)", 52 | padding: "20", 53 | Link { 54 | to: "https://github.com/marc2332/freya", 55 | tooltip: LinkTooltip::None, 56 | label { 57 | "freya source code" 58 | } 59 | } 60 | Link { 61 | to: "https://github.com/marc2332/valin", 62 | tooltip: LinkTooltip::None, 63 | label { 64 | "Valin source code" 65 | } 66 | } 67 | } 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/views/search/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod search_ui; 2 | -------------------------------------------------------------------------------- /src/views/search/search_ui.rs: -------------------------------------------------------------------------------- 1 | use freya::prelude::*; 2 | use grep::{ 3 | regex::RegexMatcher, 4 | searcher::{sinks::UTF8, BinaryDetection, SearcherBuilder}, 5 | }; 6 | 7 | use crate::{Overlay, TextArea}; 8 | 9 | #[component] 10 | pub fn Search() -> Element { 11 | let mut value = use_signal(String::new); 12 | let mut focus = use_focus(); 13 | 14 | let onchange = move |v| { 15 | if *value.read() != v { 16 | value.set(v); 17 | } 18 | }; 19 | 20 | let results = use_memo(move || { 21 | let mut results = Vec::new(); 22 | 23 | if value.read().is_empty() { 24 | return results; 25 | } 26 | 27 | let matcher = RegexMatcher::new_line_matcher(&value()).unwrap(); 28 | 29 | let mut searcher = SearcherBuilder::new() 30 | .binary_detection(BinaryDetection::quit(b'\x00')) 31 | .line_number(true) 32 | .build(); 33 | 34 | let path = "..."; 35 | 36 | searcher 37 | .search_path( 38 | &matcher, 39 | path, 40 | UTF8(|a, b| { 41 | print!("{}:{}", a, b); 42 | results.push((a, b.to_string())); 43 | Ok(true) 44 | }), 45 | ) 46 | .unwrap(); 47 | 48 | results 49 | }); 50 | 51 | let onsubmit = move |_: String| { 52 | println!("{value}"); 53 | }; 54 | 55 | let onkeydown = move |e: KeyboardEvent| { 56 | e.stop_propagation(); 57 | focus.prevent_navigation(); 58 | }; 59 | 60 | rsx!( 61 | Overlay { 62 | rect { 63 | onkeydown, 64 | ScrollView { 65 | height: "400", 66 | width: "100%", 67 | for (line, path) in &*results.read() { 68 | rect { 69 | key: "{line}{path}", 70 | label { 71 | "{line}:{path}" 72 | } 73 | } 74 | } 75 | } 76 | TextArea { 77 | placeholder: "Search...", 78 | value: "{value}", 79 | onchange, 80 | onsubmit, 81 | } 82 | } 83 | } 84 | ) 85 | } 86 | --------------------------------------------------------------------------------