initial commit

This commit is contained in:
2026-04-20 15:13:26 +01:00
commit b521c4e4a0
22 changed files with 956 additions and 0 deletions

1
src/api.rs Normal file
View File

@@ -0,0 +1 @@
pub(crate) mod repo;

17
src/api/repo.rs Normal file
View File

@@ -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<Self::Data, Self::Error> {
todo!()
}
}

58
src/app.rs Normal file
View File

@@ -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<gpui::Pixels>,
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>) -> Self {
cx.observe_window_appearance(window, |_, window, cx| {
cx.update_global::<app::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<Self>,
) -> 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<E>) -> &'a theme::Theme {
&cx.global::<Global>().current_theme
}
pub fn query_store<'a, E>(cx: &'a gpui::Context<E>) -> &'a query::Store {
&cx.global::<Global>().query_store
}

13
src/asset.rs Normal file
View File

@@ -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<Option<std::borrow::Cow<'static, [u8]>>> {
load_asset(path)
}
fn list(&self, path: &str) -> gpui::Result<Vec<gpui::SharedString>> {
list_assets(path)
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-git2-icon lucide-folder-git-2"><path d="M18 19a5 5 0 0 1-5-5v8"/><path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><circle cx="20" cy="19" r="2"/></svg>

After

Width:  |  Height:  |  Size: 457 B

80
src/colors.rs Normal file
View File

@@ -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"),
}
}

2
src/component.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod font_icon;
pub mod text;

View File

@@ -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<T>(icon: FontIcon, cx: &gpui::Context<T>) -> gpui::Svg {
let theme = cx.global::<app::Global>().current_theme;
svg().path(icon_path(icon)).text_color(theme.colors.text)
}

13
src/component/text.rs Normal file
View File

@@ -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<T>) -> gpui::Div {
let theme = cx.global::<app::Global>().current_theme;
div().text_color(theme.colors.text).child(s)
}

48
src/dashboard.rs Normal file
View File

@@ -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<Self>,
) -> 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)),
)
}
}

45
src/main.rs Normal file
View File

@@ -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();
}

123
src/query.rs Normal file
View File

@@ -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<Self::Data, Self::Error>;
}
pub enum QueryStatus<'a, Data, Error> {
Loading,
Loaded(&'a Data),
Err(&'a Error),
}
pub fn use_query<F, T>(query_fn: F, cx: &mut gpui::Context<T>)
where
F: QueryFn,
T: 'static,
{
let ent = cx.update_global::<app::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<dyn Any>),
Err(Box<dyn Any>),
}
pub(crate) type Entity = gpui::Entity<QueryState>;
pub struct Store {
query_data: std::collections::HashMap<String, Entity>,
}
impl Store {
pub fn new() -> Self {
Self {
query_data: std::collections::HashMap::new(),
}
}
fn ensure_query<Q, T>(&mut self, query: &Q, cx: &mut gpui::Context<T>) -> 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<Q, T>(
&mut self,
query: Q,
entity: Entity,
cx: &mut gpui::Context<T>,
) -> gpui::Task<anyhow::Result<()>>
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<E>) -> 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::<Q::Data>().unwrap()),
QueryData::Err(error) => QueryStatus::Err(error.downcast_ref::<Q::Error>().unwrap())
}
}
}

117
src/theme.rs Normal file
View File

@@ -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<gpui::WindowAppearance> 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()
}
}

59
src/titlebar.rs Normal file
View File

@@ -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<Self>,
) -> impl gpui::IntoElement {
let g = cx.global::<app::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>) -> Self {
use_query(api::repo::List, cx);
Self {}
}
}
fn repo_selector<T>(cx: &gpui::Context<T>) -> 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())
}