diff --git a/crates/lume/src/actions.rs b/crates/lume/src/actions.rs index ef5fca8e..95e79978 100644 --- a/crates/lume/src/actions.rs +++ b/crates/lume/src/actions.rs @@ -2,7 +2,7 @@ use std::sync::Mutex; use gpui::{actions, App}; -actions!(coop, [Quit]); +actions!(lume, [Quit, About, Open]); pub fn load_embedded_fonts(cx: &App) { let asset_source = cx.asset_source(); diff --git a/crates/lume/src/main.rs b/crates/lume/src/main.rs index 19799a81..93e3d622 100644 --- a/crates/lume/src/main.rs +++ b/crates/lume/src/main.rs @@ -3,16 +3,20 @@ use std::sync::Arc; use assets::Assets; use common::{APP_ID, CLIENT_NAME}; use gpui::{ - div, point, px, size, AppContext, Application, Bounds, Context, IntoElement, KeyBinding, Menu, - MenuItem, ParentElement, Render, SharedString, Styled, TitlebarOptions, Window, - WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, + point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString, + TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, + WindowOptions, }; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{Root, StyledExt}; +use gpui_component::Root; use crate::actions::{load_embedded_fonts, quit, Quit}; +use crate::workspace::Workspace; mod actions; +mod menus; +mod themes; +mod title_bar; +mod workspace; fn main() { // Initialize logging @@ -74,29 +78,12 @@ fn main() { // Initialize components gpui_component::init(cx); - let view = cx.new(|_| HelloWorld); - cx.new(|cx| Root::new(view, window, cx)) + // Initialize themes + themes::init(cx); + + let workspace = cx.new(|cx| Workspace::new(window, cx)); + cx.new(|cx| Root::new(workspace, window, cx)) }) .expect("Failed to open window. Please restart the application."); }) } - -pub struct HelloWorld; - -impl Render for HelloWorld { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - .v_flex() - .gap_2() - .size_full() - .items_center() - .justify_center() - .child("Hello, World!") - .child( - Button::new("ok") - .primary() - .label("Let's Go!") - .on_click(|_, _, _| println!("Clicked!")), - ) - } -} diff --git a/crates/lume/src/menus.rs b/crates/lume/src/menus.rs new file mode 100644 index 00000000..5287575c --- /dev/null +++ b/crates/lume/src/menus.rs @@ -0,0 +1,101 @@ +use gpui::{App, Entity, Menu, MenuItem, SharedString}; +use gpui_component::menu::AppMenuBar; +use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry}; + +use crate::actions::{About, Open, Quit}; +use crate::themes::{SwitchTheme, SwitchThemeMode}; + +pub fn init(title: impl Into, cx: &mut App) -> Entity { + let app_menu_bar = AppMenuBar::new(cx); + let title: SharedString = title.into(); + + update_app_menu(title.clone(), app_menu_bar.clone(), cx); + + // Observe theme changes to update the menu to refresh the checked state + cx.observe_global::({ + let title = title.clone(); + let app_menu_bar = app_menu_bar.clone(); + move |cx| { + update_app_menu(title.clone(), app_menu_bar.clone(), cx); + } + }) + .detach(); + + app_menu_bar +} + +fn update_app_menu(title: impl Into, app_menu_bar: Entity, cx: &mut App) { + let mode = cx.theme().mode; + cx.set_menus(vec![ + Menu { + name: title.into(), + items: vec![ + MenuItem::action("About", About), + MenuItem::Separator, + MenuItem::action("Open...", Open), + MenuItem::Separator, + MenuItem::Submenu(Menu { + name: "Appearance".into(), + items: vec![ + MenuItem::action("Light", SwitchThemeMode(ThemeMode::Light)) + .checked(!mode.is_dark()), + MenuItem::action("Dark", SwitchThemeMode(ThemeMode::Dark)) + .checked(mode.is_dark()), + ], + }), + theme_menu(cx), + MenuItem::Separator, + MenuItem::action("Quit", Quit), + ], + }, + Menu { + name: "Edit".into(), + items: vec![ + MenuItem::action("Undo", gpui_component::input::Undo), + MenuItem::action("Redo", gpui_component::input::Redo), + MenuItem::separator(), + MenuItem::action("Cut", gpui_component::input::Cut), + MenuItem::action("Copy", gpui_component::input::Copy), + MenuItem::action("Paste", gpui_component::input::Paste), + MenuItem::separator(), + MenuItem::action("Delete", gpui_component::input::Delete), + MenuItem::action( + "Delete Previous Word", + gpui_component::input::DeleteToPreviousWordStart, + ), + MenuItem::action( + "Delete Next Word", + gpui_component::input::DeleteToNextWordEnd, + ), + MenuItem::separator(), + MenuItem::action("Find", gpui_component::input::Search), + MenuItem::separator(), + MenuItem::action("Select All", gpui_component::input::SelectAll), + ], + }, + Menu { + name: "Help".into(), + items: vec![MenuItem::action("Open Website", Open)], + }, + ]); + + app_menu_bar.update(cx, |menu_bar, cx| { + menu_bar.reload(cx); + }) +} + +fn theme_menu(cx: &App) -> MenuItem { + let themes = ThemeRegistry::global(cx).sorted_themes(); + let current_name = cx.theme().theme_name(); + MenuItem::Submenu(Menu { + name: "Theme".into(), + items: themes + .iter() + .map(|theme| { + let checked = current_name == &theme.name; + MenuItem::action(theme.name.clone(), SwitchTheme(theme.name.clone())) + .checked(checked) + }) + .collect(), + }) +} diff --git a/crates/lume/src/themes.rs b/crates/lume/src/themes.rs new file mode 100644 index 00000000..905c777b --- /dev/null +++ b/crates/lume/src/themes.rs @@ -0,0 +1,82 @@ +use std::path::PathBuf; + +use common::home_dir; +use gpui::{Action, App, SharedString}; +use gpui_component::scroll::ScrollbarShow; +use gpui_component::{ActiveTheme, Theme, ThemeMode, ThemeRegistry}; +use serde::{Deserialize, Serialize}; + +#[derive(Action, Clone, PartialEq)] +#[action(namespace = themes, no_json)] +pub(crate) struct SwitchTheme(pub(crate) SharedString); + +#[derive(Action, Clone, PartialEq)] +#[action(namespace = themes, no_json)] +pub(crate) struct SwitchThemeMode(pub(crate) ThemeMode); + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct State { + theme: SharedString, + scrollbar_show: Option, +} + +impl Default for State { + fn default() -> Self { + Self { + theme: "Default Light".into(), + scrollbar_show: None, + } + } +} + +pub fn init(cx: &mut App) { + // Load last theme state + let path = home_dir().join("state.json"); + let json = std::fs::read_to_string(path).unwrap_or_default(); + let state = serde_json::from_str::(&json).unwrap_or_default(); + + if let Err(err) = ThemeRegistry::watch_dir(PathBuf::from("./themes"), cx, move |cx| { + if let Some(theme) = ThemeRegistry::global(cx) + .themes() + .get(&state.theme) + .cloned() + { + Theme::global_mut(cx).apply_config(&theme); + } + }) { + log::error!("Failed to watch themes directory: {}", err); + } + + if let Some(scrollbar_show) = state.scrollbar_show { + Theme::global_mut(cx).scrollbar_show = scrollbar_show; + } + cx.refresh_windows(); + + cx.observe_global::(|cx| { + let state = State { + theme: cx.theme().theme_name().clone(), + scrollbar_show: Some(cx.theme().scrollbar_show), + }; + + if let Ok(json) = serde_json::to_string_pretty(&state) { + let path = home_dir().join("state.json"); + // Ignore write errors - if STATE_FILE doesn't exist or can't be written, do nothing + let _ = std::fs::write(path, json); + } + }) + .detach(); + + cx.on_action(|switch: &SwitchTheme, cx| { + let theme_name = switch.0.clone(); + if let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&theme_name).cloned() { + Theme::global_mut(cx).apply_config(&theme_config); + } + cx.refresh_windows(); + }); + + cx.on_action(|switch: &SwitchThemeMode, cx| { + let mode = switch.0; + Theme::change(mode, None, cx); + cx.refresh_windows(); + }); +} diff --git a/crates/lume/src/title_bar.rs b/crates/lume/src/title_bar.rs new file mode 100644 index 00000000..56044607 --- /dev/null +++ b/crates/lume/src/title_bar.rs @@ -0,0 +1,64 @@ +use std::rc::Rc; + +use gpui::{ + div, AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, MouseButton, + ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, +}; +use gpui_component::menu::AppMenuBar; +use gpui_component::TitleBar; + +use crate::menus; + +pub struct AppTitleBar { + /// The app menu bar + app_menu_bar: Entity, + + /// Child elements + child: Rc AnyElement>, + + /// Event subscriptions + _subscriptions: Vec, +} + +impl AppTitleBar { + pub fn new( + title: impl Into, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let app_menu_bar = menus::init(title, cx); + + Self { + app_menu_bar, + child: Rc::new(|_, _| div().into_any_element()), + _subscriptions: vec![], + } + } + + pub fn child(mut self, f: F) -> Self + where + E: IntoElement, + F: Fn(&mut Window, &mut App) -> E + 'static, + { + self.child = Rc::new(move |window, cx| f(window, cx).into_any_element()); + self + } +} + +impl Render for AppTitleBar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + TitleBar::new() + // left side + .child(div().flex().items_center().child(self.app_menu_bar.clone())) + .child( + div() + .flex() + .items_center() + .justify_end() + .px_2() + .gap_2() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child((self.child.clone())(window, cx)), + ) + } +} diff --git a/crates/lume/src/workspace.rs b/crates/lume/src/workspace.rs new file mode 100644 index 00000000..31aae5bf --- /dev/null +++ b/crates/lume/src/workspace.rs @@ -0,0 +1,70 @@ +use common::CLIENT_NAME; +use gpui::{ + div, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, + Styled, Subscription, Window, +}; +use gpui_component::dock::DockArea; +use gpui_component::{v_flex, Root, Theme}; +use smallvec::{smallvec, SmallVec}; + +use crate::title_bar::AppTitleBar; + +#[derive(Debug)] +pub struct Workspace { + /// The dock area for the workspace. + dock: Entity, + + /// App's title bar. + title_bar: Entity, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl Workspace { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let dock = cx.new(|cx| DockArea::new("dock", None, window, cx)); + let title_bar = cx.new(|cx| AppTitleBar::new(CLIENT_NAME, window, cx)); + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Automatically sync theme with system appearance + window.observe_window_appearance(|window, cx| { + Theme::sync_system_appearance(Some(window), cx); + }), + ); + + Self { + dock, + title_bar, + _subscriptions: subscriptions, + } + } +} + +impl Render for Workspace { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let dialog_layer = Root::render_dialog_layer(window, cx); + let sheet_layer = Root::render_sheet_layer(window, cx); + let notification_layer = Root::render_notification_layer(window, cx); + + div() + .id("root") + .relative() + .size_full() + .child( + v_flex() + .size_full() + // Title bar + .child(self.title_bar.clone()) + // Dock + .child(self.dock.clone()), + ) + // Notifications + .children(notification_layer) + // Sheets + .children(sheet_layer) + // Modals + .children(dialog_layer) + } +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 91c3eb96..a03b0183 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -88,6 +88,11 @@ impl NostrRegistry { } } + /// Get the nostr client instance + pub fn client(&self) -> Client { + self.client.clone() + } + /// Establish connection to the bootstrap relays async fn connect(client: &Client) { // Get all bootstrapping relays @@ -103,9 +108,4 @@ impl NostrRegistry { // Connect to all added relays client.connect().await; } - - /// Get the nostr client instance - pub fn client(&self) -> Client { - self.client.clone() - } }