From b521c4e4a0d3fb0fc2663e89a239fb0c3822a1d2 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Mon, 20 Apr 2026 15:13:26 +0100 Subject: [PATCH] initial commit --- .gitignore | 178 +++++++++++++++++++++++++++ .idea/.gitignore | 10 ++ .idea/modules.xml | 8 ++ .idea/runa.iml | 11 ++ .idea/vcs.xml | 6 + Cargo.toml | 9 ++ build.rs | 122 ++++++++++++++++++ src/api.rs | 1 + src/api/repo.rs | 17 +++ src/app.rs | 58 +++++++++ src/asset.rs | 13 ++ src/asset/font_icon/chevron_down.svg | 1 + src/asset/font_icon/folder_git.svg | 1 + src/colors.rs | 80 ++++++++++++ src/component.rs | 2 + src/component/font_icon.rs | 34 +++++ src/component/text.rs | 13 ++ src/dashboard.rs | 48 ++++++++ src/main.rs | 45 +++++++ src/query.rs | 123 ++++++++++++++++++ src/theme.rs | 117 ++++++++++++++++++ src/titlebar.rs | 59 +++++++++ 22 files changed, 956 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/runa.iml create mode 100644 .idea/vcs.xml create mode 100644 Cargo.toml create mode 100644 build.rs create mode 100644 src/api.rs create mode 100644 src/api/repo.rs create mode 100644 src/app.rs create mode 100644 src/asset.rs create mode 100644 src/asset/font_icon/chevron_down.svg create mode 100644 src/asset/font_icon/folder_git.svg create mode 100644 src/colors.rs create mode 100644 src/component.rs create mode 100644 src/component/font_icon.rs create mode 100644 src/component/text.rs create mode 100644 src/dashboard.rs create mode 100644 src/main.rs create mode 100644 src/query.rs create mode 100644 src/theme.rs create mode 100644 src/titlebar.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f5eb60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,rust,swift +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,rust,swift + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,rust,swift \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..782f687 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runa.iml b/.idea/runa.iml new file mode 100644 index 0000000..cf84ae4 --- /dev/null +++ b/.idea/runa.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..61028c7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "novem" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +gpui = { version = "*" } +paste = "1.0" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..2c4eef5 --- /dev/null +++ b/build.rs @@ -0,0 +1,122 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +struct AssetFile { + virtual_path: String, + disk_path: PathBuf, +} + +fn main() { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR")); + let asset_root = manifest_dir.join("src/asset"); + let out_file = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")).join("asset.rs"); + + println!("cargo::rerun-if-changed={}", asset_root.display()); + + let mut directory_entries = BTreeMap::>::new(); + directory_entries + .entry(String::new()) + .or_default() + .insert(String::from("asset")); + + let mut asset_files = Vec::new(); + collect_assets( + &asset_root, + "asset", + &mut directory_entries, + &mut asset_files, + ); + + let generated = render_assets(&asset_files, &directory_entries); + + fs::write(out_file, generated).expect("failed to write generated assets module"); +} + +fn collect_assets( + disk_dir: &Path, + virtual_dir: &str, + directory_entries: &mut BTreeMap>, + asset_files: &mut Vec, +) { + let mut entries = fs::read_dir(disk_dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", disk_dir.display())) + .map(|entry| entry.expect("failed to read directory entry")) + .collect::>(); + + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let file_name = entry + .file_name() + .into_string() + .unwrap_or_else(|_| panic!("non-utf8 asset name in {}", disk_dir.display())); + let child_virtual_path = format!("{virtual_dir}/{file_name}"); + let path = entry.path(); + + directory_entries + .entry(String::from(virtual_dir)) + .or_default() + .insert(file_name); + + if path.is_dir() { + collect_assets(&path, &child_virtual_path, directory_entries, asset_files); + } else if path.is_file() { + asset_files.push(AssetFile { + virtual_path: child_virtual_path, + disk_path: path, + }); + } + } +} + +fn render_assets( + asset_files: &[AssetFile], + directory_entries: &BTreeMap>, +) -> String { + let mut output = String::new(); + + output.push_str( + "pub fn load_asset(path: &str) -> gpui::Result>> {\n", + ); + output.push_str(" match path {\n"); + for file in asset_files { + output.push_str(" "); + output.push_str(&string_literal(&file.virtual_path)); + output.push_str(" => Ok(Some(std::borrow::Cow::Borrowed(include_bytes!("); + output.push_str(&string_literal(&file.disk_path.to_string_lossy())); + output.push_str(")))),\n"); + } + output.push_str(" _ => Err(anyhow::anyhow!(\"asset not found: {path}\")),\n"); + output.push_str(" }\n"); + output.push_str("}\n\n"); + + output.push_str("pub fn list_assets(path: &str) -> gpui::Result> {\n"); + output.push_str(" let normalized = path.trim_end_matches('/');\n"); + output.push_str(" let normalized = if normalized == \".\" { \"\" } else { normalized };\n"); + output.push_str(" let entries: &[&str] = match normalized {\n"); + for (directory, entries) in directory_entries { + output.push_str(" "); + output.push_str(&string_literal(directory)); + output.push_str(" => &[\n"); + for entry in entries { + output.push_str(" "); + output.push_str(&string_literal(entry)); + output.push_str(",\n"); + } + output.push_str(" ],\n"); + } + output.push_str(" _ => &[],\n"); + output.push_str(" };\n"); + output.push_str(" Ok(entries.iter().copied().map(gpui::SharedString::from).collect())\n"); + output.push_str("}\n"); + + output +} + +fn string_literal(value: &str) -> String { + format!("{value:?}") +} diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..cb55580 --- /dev/null +++ b/src/api.rs @@ -0,0 +1 @@ +pub(crate) mod repo; \ No newline at end of file diff --git a/src/api/repo.rs b/src/api/repo.rs new file mode 100644 index 0000000..88cd45d --- /dev/null +++ b/src/api/repo.rs @@ -0,0 +1,17 @@ +use crate::query; + +#[derive(Clone)] +pub struct List; + +impl query::QueryFn for List { + type Data = (); + type Error = (); + + fn key(&self) -> &'static str { + "repo.list" + } + + async fn run(&self) -> Result { + todo!() + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d4af42b --- /dev/null +++ b/src/app.rs @@ -0,0 +1,58 @@ +use gpui::{div, prelude::*}; + +use crate::app; +use crate::query; +use crate::dashboard; +use crate::theme; +use crate::titlebar; + +pub struct Global { + pub safe_area: gpui::Bounds, + pub current_theme: theme::Theme, + pub query_store: query::Store +} + +pub struct Chrome {} + +impl Chrome { + pub fn new(window: &mut gpui::Window, cx: &mut gpui::Context) -> Self { + cx.observe_window_appearance(window, |_, window, cx| { + cx.update_global::(|global, _cx| { + global.current_theme = window.appearance().into(); + }); + }) + .detach(); + + Self {} + } +} + +impl gpui::Render for Chrome { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let title_bar = cx.new(|_| titlebar::TitleBar {}); + + let dashboard = cx.new(|_| dashboard::Screen { + text: "World".into(), + }); + + div() + .size_full() + .flex_col() + .child(title_bar) + .child(dashboard) + } +} + +impl gpui::Global for Global {} + +pub fn current_theme<'a, E>(cx: &'a gpui::Context) -> &'a theme::Theme { + &cx.global::().current_theme +} + +pub fn query_store<'a, E>(cx: &'a gpui::Context) -> &'a query::Store { + &cx.global::().query_store +} diff --git a/src/asset.rs b/src/asset.rs new file mode 100644 index 0000000..c56f457 --- /dev/null +++ b/src/asset.rs @@ -0,0 +1,13 @@ +include!(concat!(env!("OUT_DIR"), "/asset.rs")); + +pub struct Asset; + +impl gpui::AssetSource for Asset { + fn load(&self, path: &str) -> gpui::Result>> { + load_asset(path) + } + + fn list(&self, path: &str) -> gpui::Result> { + list_assets(path) + } +} diff --git a/src/asset/font_icon/chevron_down.svg b/src/asset/font_icon/chevron_down.svg new file mode 100644 index 0000000..b0a04dd --- /dev/null +++ b/src/asset/font_icon/chevron_down.svg @@ -0,0 +1 @@ + diff --git a/src/asset/font_icon/folder_git.svg b/src/asset/font_icon/folder_git.svg new file mode 100644 index 0000000..ab8ea77 --- /dev/null +++ b/src/asset/font_icon/folder_git.svg @@ -0,0 +1 @@ + diff --git a/src/colors.rs b/src/colors.rs new file mode 100644 index 0000000..ac6a753 --- /dev/null +++ b/src/colors.rs @@ -0,0 +1,80 @@ +use gpui::Rgba; + +pub const fn hex(hex: u32) -> Rgba { + let [_, r, g, b] = hex.to_be_bytes(); + + Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + } +} + +pub const fn neutral(shade: u16) -> Rgba { + match shade { + 50 => hex(0xfafafa), + 100 => hex(0xf5f5f5), + 200 => hex(0xe5e5e5), + 300 => hex(0xd4d4d4), + 400 => hex(0xa3a3a3), + 500 => hex(0x737373), + 600 => hex(0x525252), + 700 => hex(0x404040), + 800 => hex(0x262626), + 900 => hex(0x171717), + 950 => hex(0x0a0a0a), + _ => panic!("unsupported Tailwind neutral shade"), + } +} + +pub const fn violet(shade: u16) -> Rgba { + match shade { + 50 => hex(0xf5f3ff), + 100 => hex(0xede9fe), + 200 => hex(0xddd6fe), + 300 => hex(0xc4b5fd), + 400 => hex(0xa78bfa), + 500 => hex(0x8b5cf6), + 600 => hex(0x7c3aed), + 700 => hex(0x6d28d9), + 800 => hex(0x5b21b6), + 900 => hex(0x4c1d95), + 950 => hex(0x2e1065), + _ => panic!("unsupported Tailwind violet shade"), + } +} + +pub const fn amber(shade: u16) -> Rgba { + match shade { + 50 => hex(0xfffbeb), + 100 => hex(0xfef3c7), + 200 => hex(0xfde68a), + 300 => hex(0xfcd34d), + 400 => hex(0xfbbf24), + 500 => hex(0xf59e0b), + 600 => hex(0xd97706), + 700 => hex(0xb45309), + 800 => hex(0x92400e), + 900 => hex(0x78350f), + 950 => hex(0x451a03), + _ => panic!("unsupported Tailwind amber shade"), + } +} + +pub const fn red(shade: u16) -> Rgba { + match shade { + 50 => hex(0xfef2f2), + 100 => hex(0xfee2e2), + 200 => hex(0xfecaca), + 300 => hex(0xfca5a5), + 400 => hex(0xf87171), + 500 => hex(0xef4444), + 600 => hex(0xdc2626), + 700 => hex(0xb91c1c), + 800 => hex(0x991b1b), + 900 => hex(0x7f1d1d), + 950 => hex(0x450a0a), + _ => panic!("unsupported Tailwind red shade"), + } +} diff --git a/src/component.rs b/src/component.rs new file mode 100644 index 0000000..8e7fad6 --- /dev/null +++ b/src/component.rs @@ -0,0 +1,2 @@ +pub mod font_icon; +pub mod text; diff --git a/src/component/font_icon.rs b/src/component/font_icon.rs new file mode 100644 index 0000000..2e0ea1d --- /dev/null +++ b/src/component/font_icon.rs @@ -0,0 +1,34 @@ +use gpui::{Styled, svg}; +use paste::paste; + +use crate::app; + +macro_rules! define_font_icons { + ($($name:ident),+ $(,)?) => { + paste! { + #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] + pub enum FontIcon { + $($name),+ + } + + pub const fn icon_path(icon: FontIcon) -> &'static str { + match icon { + $( + FontIcon::$name => concat!( + "asset/font_icon/", + stringify!([<$name:snake>]), + ".svg" + ), + )+ + } + } + } + }; +} + +define_font_icons!(ChevronDown, FolderGit); + +pub fn font_icon(icon: FontIcon, cx: &gpui::Context) -> gpui::Svg { + let theme = cx.global::().current_theme; + svg().path(icon_path(icon)).text_color(theme.colors.text) +} diff --git a/src/component/text.rs b/src/component/text.rs new file mode 100644 index 0000000..0adb78e --- /dev/null +++ b/src/component/text.rs @@ -0,0 +1,13 @@ +use crate::app; +use gpui::{ParentElement, Styled, div}; + +pub trait Text: gpui::IntoElement {} + +impl Text for &'static str {} +impl Text for String {} +impl Text for gpui::SharedString {} + +pub fn text<'a, Content: Text, T>(s: Content, cx: &gpui::Context) -> gpui::Div { + let theme = cx.global::().current_theme; + div().text_color(theme.colors.text).child(s) +} diff --git a/src/dashboard.rs b/src/dashboard.rs new file mode 100644 index 0000000..a824b8b --- /dev/null +++ b/src/dashboard.rs @@ -0,0 +1,48 @@ +use gpui::{div, prelude::*}; + +use crate::theme::Variant; + +pub struct Screen { + pub text: gpui::SharedString, +} + +impl Render for Screen { + fn render( + &mut self, + _window: &mut gpui::Window, + _cx: &mut gpui::Context, + ) -> impl IntoElement { + let builtin = Variant::VioletDark; + let theme = builtin.theme(); + + div() + .flex() + .flex_col() + .size_full() + .gap_3() + .bg(theme.colors.background) + .justify_center() + .items_center() + .shadow_lg() + .text_xl() + .text_color(theme.colors.text) + .child(format!("Hello, {}!", &self.text)) + .child( + div() + .text_sm() + .text_color(theme.colors.text_muted) + .child(format!("Built-in theme: {}", builtin.label())), + ) + .child( + div() + .flex() + .gap_2() + .child(div().size_8().bg(theme.colors.surface)) + .child(div().size_8().bg(theme.colors.surface_elevated)) + .child(div().size_8().bg(theme.colors.accent)) + .child(div().size_8().bg(theme.colors.success)) + .child(div().size_8().bg(theme.colors.warning)) + .child(div().size_8().bg(theme.colors.danger)), + ) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a393b73 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,45 @@ +use gpui::{bounds, point, prelude::*, px, size}; + +mod app; +mod asset; +mod colors; +mod component; +mod dashboard; +mod query; +mod theme; +mod titlebar; +mod api; + +fn main() { + gpui::Application::new() + .with_assets(asset::Asset) + .run(setup_application); +} + +fn setup_application(cx: &mut gpui::App) { + let window_bounds = gpui::Bounds::centered(None, size(px(800.), px(600.0)), cx); + + let global = app::Global { + safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))), + current_theme: cx.window_appearance().into(), + query_store: query::Store::new(), + }; + + let top_left = global.safe_area.origin; + + cx.set_global(global); + + cx.open_window( + gpui::WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(window_bounds)), + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + traffic_light_position: Some(top_left + point(px(8.), px(8.))), + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| app::Chrome::new(window, cx)), + ) + .unwrap(); +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..acd49f1 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,123 @@ +use crate::app; +use gpui::{AppContext, BorrowAppContext}; +use std::any::Any; + +pub trait QueryFn: Clone + 'static { + type Data: 'static; + type Error: 'static; + + fn key(&self) -> &'static str; + + async fn run(&self) -> Result; +} + +pub enum QueryStatus<'a, Data, Error> { + Loading, + Loaded(&'a Data), + Err(&'a Error), +} + +pub fn use_query(query_fn: F, cx: &mut gpui::Context) +where + F: QueryFn, + T: 'static, +{ + let ent = cx.update_global::(|global, cx| { + let entity = global.query_store.ensure_query(&query_fn, cx); + + if entity.read_with(cx, |state, _| matches!(state.data, QueryData::Pending)) { + global.query_store.execute_query(query_fn, entity.clone(), cx).detach(); + } + + entity + }); + + cx.observe(&ent, |_, _, cx| { + cx.notify(); + }) + .detach(); +} + +// ================= Store ================== + +pub(crate) struct QueryState { + data: QueryData, +} + +pub(crate) enum QueryData { + Pending, + Loading, + Some(Box), + Err(Box), +} + +pub(crate) type Entity = gpui::Entity; + +pub struct Store { + query_data: std::collections::HashMap, +} + +impl Store { + pub fn new() -> Self { + Self { + query_data: std::collections::HashMap::new(), + } + } + + fn ensure_query(&mut self, query: &Q, cx: &mut gpui::Context) -> Entity + where + Q: QueryFn, + T: 'static, + { + self.query_data + .entry(query.key().into()) + .or_insert_with(|| { + cx.new(|_| QueryState { + data: QueryData::Pending, + }) + }) + .clone() + } + + fn execute_query( + &mut self, + query: Q, + entity: Entity, + cx: &mut gpui::Context, + ) -> gpui::Task> + where + Q: QueryFn, + T: 'static, + { + entity.update(cx, |state, cx| { + state.data = QueryData::Loading; + cx.notify(); + }); + + cx.spawn(async move |_, cx| { + let result = query.run().await; + + entity.update(cx, |state, cx| { + state.data = match result { + Ok(data) => QueryData::Some(Box::new(data)), + Err(err) => QueryData::Err(Box::new(err)), + }; + cx.notify(); + })?; + + anyhow::Ok(()) + }) + } + + pub(crate) fn read_query<'a, Q, E>(&self, query_fn: Q, cx: &'a gpui::Context) -> QueryStatus<'a, Q::Data, Q::Error> where Q: QueryFn { + let Some(ent) = self.query_data.get(query_fn.key()) else { + return QueryStatus::Loading; + }; + let QueryState { data } = ent.read(cx); + match data { + QueryData::Loading | QueryData::Pending => QueryStatus::Loading, + QueryData::Some(data) => QueryStatus::Loaded(data.downcast_ref::().unwrap()), + QueryData::Err(error) => QueryStatus::Err(error.downcast_ref::().unwrap()) + } + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..8204752 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,117 @@ +use gpui::Rgba; + +use crate::colors::{amber, hex, neutral, red, violet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ThemeMode { + Light, + #[default] + Dark, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Theme { + pub id: &'static str, + pub name: &'static str, + pub mode: ThemeMode, + pub colors: ThemeColors, +} + +impl Default for Theme { + fn default() -> Self { + Variant::default().theme() + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ThemeColors { + pub background: Rgba, + pub surface: Rgba, + pub surface_elevated: Rgba, + pub border: Rgba, + pub text: Rgba, + pub text_muted: Rgba, + pub accent: Rgba, + pub accent_hover: Rgba, + pub accent_text: Rgba, + pub success: Rgba, + pub warning: Rgba, + pub danger: Rgba, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[allow(dead_code)] +pub enum Variant { + VioletLight, + #[default] + VioletDark, +} + +impl Variant { + #[allow(dead_code)] + pub const ALL: [Self; 2] = [Self::VioletLight, Self::VioletDark]; + + pub const fn label(self) -> &'static str { + match self { + Self::VioletLight => "Violet Light", + Self::VioletDark => "Violet Dark", + } + } + + pub const fn theme(self) -> Theme { + match self { + Self::VioletLight => Theme { + id: "violet-light", + name: "Violet Light", + mode: ThemeMode::Light, + colors: ThemeColors { + background: neutral(50), + surface: neutral(100), + surface_elevated: neutral(200), + border: neutral(200), + text: neutral(900), + text_muted: neutral(600), + accent: violet(600), + accent_hover: violet(500), + accent_text: neutral(100), + success: hex(0x16a34a), + warning: amber(600), + danger: red(600), + }, + }, + Self::VioletDark => Theme { + id: "violet-dark", + name: "Violet Dark", + mode: ThemeMode::Dark, + colors: ThemeColors { + background: neutral(950), + surface: neutral(900), + surface_elevated: neutral(800), + border: neutral(800), + text: neutral(50), + text_muted: neutral(400), + accent: violet(400), + accent_hover: violet(300), + accent_text: neutral(100), + success: hex(0x22c55e), + warning: amber(500), + danger: red(500), + }, + }, + } + } +} + +impl From for Theme { + fn from(value: gpui::WindowAppearance) -> Self { + let variant = match value { + gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight => { + Variant::VioletLight + } + gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark => { + Variant::VioletDark + } + }; + variant.theme() + } +} diff --git a/src/titlebar.rs b/src/titlebar.rs new file mode 100644 index 0000000..7b004a8 --- /dev/null +++ b/src/titlebar.rs @@ -0,0 +1,59 @@ +use gpui::{ParentElement, Styled, div, AppContext}; + +use crate::{api, app, component::{ + font_icon::{FontIcon, font_icon}, + text::text, +}, query}; +use crate::app::query_store; +use crate::query::use_query; + +pub struct TitleBar {} + +pub struct RepoSelector {} + +impl gpui::Render for TitleBar { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let g = cx.global::(); + + div() + .w_full() + .h_8() + .flex() + .px(g.safe_area.size.width) + .py_2() + .flex_row() + .justify_center() + .items_center() + .border_b_1() + .border_color(g.current_theme.colors.border) + .bg(g.current_theme.colors.surface) + .text_color(g.current_theme.colors.text) + .child(repo_selector(cx)) + } +} + +impl RepoSelector { + pub fn new(cx: &mut gpui::Context) -> Self { + use_query(api::repo::List, cx); + Self {} + } +} + +fn repo_selector(cx: &gpui::Context) -> gpui::Div { + let store = app::query_store(cx); + let repo = store.read_query(api::repo::List, cx); + + div() + .flex() + .flex_row() + .items_center() + .gap_1() + .text_xs() + .child(font_icon(FontIcon::FolderGit, cx).size_3()) + .child(text("test/repo", cx)) + .child(font_icon(FontIcon::ChevronDown, cx).size_3()) +}