From b327648d3186bd30849d80a9f49b465f128982f6 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Thu, 23 Apr 2026 11:18:43 +0100 Subject: [PATCH] wip: connect to github --- Cargo.toml | 1 + src/app.rs | 7 +- src/component.rs | 3 - src/component/button.rs | 36 +++-- src/component/mod.rs | 3 + src/component/text.rs | 182 ++++++++++++++++++++++-- src/main.rs | 3 +- src/screen/setup_wizard/github_step.rs | 98 +++++++++++-- src/screen/setup_wizard/screen.rs | 55 ++++--- src/screen/setup_wizard/welcome_step.rs | 97 +++++++------ src/theme.rs | 6 + src/titlebar.rs | 5 +- 12 files changed, 399 insertions(+), 97 deletions(-) delete mode 100644 src/component.rs create mode 100644 src/component/mod.rs diff --git a/Cargo.toml b/Cargo.toml index edb68f3..663b323 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" anyhow = "1" gpui = { version = "*" } paste = "1.0" +rand = "0.10.1" reqwest = "0.13.2" serde = "1.0.228" serde_json = "1.0.149" diff --git a/src/app.rs b/src/app.rs index 4534029..5228aa6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ use crate::{api, app}; pub struct Global { pub safe_area: gpui::Bounds, pub current_theme: theme::Theme, + pub rng: rand::prelude::ThreadRng, } pub struct Chrome {} @@ -50,10 +51,14 @@ impl gpui::Render for Chrome { impl gpui::Global for Global {} -pub fn current_theme<'a, E>(cx: &'a gpui::Context) -> &'a theme::Theme { +pub fn current_theme(cx: &gpui::App) -> &theme::Theme { &cx.global::().current_theme } +pub fn rng(cx: &mut gpui::App) -> &mut rand::prelude::ThreadRng { + &mut cx.global_mut::().rng +} + pub fn query_store<'a, E>(cx: &'a gpui::Context) -> &'a query::Store { cx.global::>() } diff --git a/src/component.rs b/src/component.rs deleted file mode 100644 index 0d77b04..0000000 --- a/src/component.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod button; -pub mod font_icon; -pub mod text; diff --git a/src/component/button.rs b/src/component/button.rs index 8d0814b..40c3b78 100644 --- a/src/component/button.rs +++ b/src/component/button.rs @@ -3,23 +3,20 @@ use gpui::{ StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, }; -use crate::{app, component::text::Text}; +use crate::{app, component::text::TextContent, theme}; #[derive(gpui::IntoElement)] pub struct Button { id: gpui::ElementId, - text_color: gpui::Rgba, label: Option, leading: Option, trailing: Option, on_click: Option>, } -pub fn button(id: impl Into, cx: &gpui::Context) -> Button { - let theme = app::current_theme(cx); +pub fn button(id: impl Into) -> Button { Button { id: id.into(), - text_color: theme.colors.accent_text, label: None, leading: None, trailing: None, @@ -28,40 +25,53 @@ pub fn button(id: impl Into, cx: &gpui::Context) -> Butto } impl Button { - pub fn label(mut self, s: impl Text) -> Self { + pub fn label(mut self, s: impl TextContent) -> Self { self.label = Some(s.into_any_element()); self } pub fn leading(mut self, s: gpui::Svg) -> Self { - self.leading = Some(s.size_3().text_color(self.text_color)); + self.leading = Some(s.size_3()); self } pub fn trailing(mut self, s: gpui::Svg) -> Self { - self.trailing = Some(s.text_color(self.text_color)); + self.trailing = Some(s.size_3()); self } - pub fn on_click(mut self, fn: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App)) -> Self { + pub fn on_click( + mut self, + f: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(f)); + self } } impl gpui::RenderOnce for Button { fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { + let theme = app::current_theme(cx); + let mut children: Vec = Vec::with_capacity(3); if let Some(leading) = self.leading { - children.push(leading.into_any_element()); + children.push( + leading + .text_color(theme.colors.accent_text) + .into_any_element(), + ); } if let Some(label) = self.label { children.push(label); } if let Some(trailing) = self.trailing { - children.push(trailing.into_any_element()); + children.push( + trailing + .text_color(theme.colors.accent_text) + .into_any_element(), + ); } - let theme = cx.global::().current_theme; - div() .id(self.id) .flex() diff --git a/src/component/mod.rs b/src/component/mod.rs new file mode 100644 index 0000000..330a3fa --- /dev/null +++ b/src/component/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod button; +pub(crate) mod font_icon; +pub(crate) mod text; diff --git a/src/component/text.rs b/src/component/text.rs index 0adb78e..df3c709 100644 --- a/src/component/text.rs +++ b/src/component/text.rs @@ -1,13 +1,177 @@ 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) +#[derive(gpui::IntoElement)] +pub(crate) struct Text { + content: gpui::AnyElement, + font_weight: gpui::FontWeight, + opacity: f32, + text_align: gpui::TextAlign, + text_size: Option, + line_height: gpui::DefiniteLength, + styled: Option gpui::Div>>, +} + +pub(crate) trait TextContent: gpui::IntoElement {} + +impl TextContent for &'static str {} +impl TextContent for String {} +impl TextContent for gpui::SharedString {} + +pub(crate) fn text(content: impl TextContent) -> Text { + Text { + content: content.into_any_element(), + font_weight: gpui::FontWeight::NORMAL, + opacity: 1., + text_align: gpui::TextAlign::Left, + text_size: None, + line_height: gpui::relative(1.5), + styled: None, + } +} + +impl Text { + pub(crate) fn light(mut self) -> Self { + self.font_weight = gpui::FontWeight::LIGHT; + self + } + + pub(crate) fn medium(mut self) -> Self { + self.font_weight = gpui::FontWeight::MEDIUM; + self + } + + pub(crate) fn bold(mut self) -> Self { + self.font_weight = gpui::FontWeight::BOLD; + self + } + + pub(crate) fn opacity(mut self, opacity: f32) -> Self { + self.opacity = opacity; + self + } + + pub(crate) fn text_size(mut self, size: impl Into) -> Self { + self.text_size = Some(size.into()); + self + } + + pub(crate) fn text_xs(self) -> Self { + self.text_size(gpui::rems(0.75)) + } + + pub(crate) fn text_sm(self) -> Self { + self.text_size(gpui::rems(0.875)) + } + + pub(crate) fn text_base(self) -> Self { + self.text_size(gpui::rems(1.0)) + } + + pub(crate) fn text_lg(self) -> Self { + self.text_size(gpui::rems(1.125)) + } + + pub(crate) fn text_xl(self) -> Self { + self.text_size(gpui::rems(1.25)) + } + + pub(crate) fn text_2xl(self) -> Self { + self.text_size(gpui::rems(1.5)) + } + + pub(crate) fn text_3xl(self) -> Self { + self.text_size(gpui::rems(1.875)) + } + + pub(crate) fn line_height(mut self, line_height: impl Into) -> Self { + self.line_height = line_height.into(); + self + } + + pub(crate) fn leading_none(self) -> Self { + self.line_height(gpui::relative(1.0)) + } + + pub(crate) fn leading_tight(self) -> Self { + self.line_height(gpui::relative(1.25)) + } + + pub(crate) fn leading_snug(self) -> Self { + self.line_height(gpui::relative(1.375)) + } + + pub(crate) fn leading_normal(self) -> Self { + self.line_height(gpui::relative(1.5)) + } + + pub(crate) fn leading_relaxed(self) -> Self { + self.line_height(gpui::relative(1.625)) + } + + pub(crate) fn leading_loose(self) -> Self { + self.line_height(gpui::relative(2.0)) + } + + pub(crate) fn leading_3(self) -> Self { + self.line_height(gpui::rems(0.75)) + } + + pub(crate) fn leading_4(self) -> Self { + self.line_height(gpui::rems(1.0)) + } + + pub(crate) fn leading_5(self) -> Self { + self.line_height(gpui::rems(1.25)) + } + + pub(crate) fn leading_6(self) -> Self { + self.line_height(gpui::rems(1.5)) + } + + pub(crate) fn leading_7(self) -> Self { + self.line_height(gpui::rems(1.75)) + } + + pub(crate) fn leading_8(self) -> Self { + self.line_height(gpui::rems(2.0)) + } + + pub(crate) fn leading_9(self) -> Self { + self.line_height(gpui::rems(2.25)) + } + + pub(crate) fn leading_10(self) -> Self { + self.line_height(gpui::rems(2.5)) + } + + pub(crate) fn styled(mut self, styled: impl Fn(gpui::Div) -> gpui::Div + 'static) -> Self { + self.styled = Some(Box::new(styled)); + self + } + + pub(crate) fn centered(mut self) -> Self { + self.text_align = gpui::TextAlign::Center; + self + } +} + +impl gpui::RenderOnce for Text { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { + let theme = app::current_theme(cx); + let mut div = div() + .text_color(theme.colors.text) + .font_weight(self.font_weight) + .opacity(self.opacity) + .text_align(self.text_align) + .line_height(self.line_height) + .child(self.content); + if let Some(text_size) = self.text_size { + div = div.text_size(text_size); + } + if let Some(styled) = self.styled { + div = styled(div); + } + div + } } diff --git a/src/main.rs b/src/main.rs index 9cb0c23..3484e23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,7 @@ fn setup_application(cx: &mut gpui::App) { let global = app::Global { safe_area: bounds(point(px(0.), px(0.)), size(px(72.), px(12.))), current_theme: cx.window_appearance().into(), + rng: rand::rng(), }; let top_left = global.safe_area.origin; @@ -61,7 +62,7 @@ fn setup_application(cx: &mut gpui::App) { is_resizable: false, ..Default::default() }, - |_window, cx| cx.new(|cx| setup_wizard::new(cx)), + |window, cx| cx.new(|cx| setup_wizard::new(window, cx)), ) .unwrap(); } diff --git a/src/screen/setup_wizard/github_step.rs b/src/screen/setup_wizard/github_step.rs index e952c01..8f3cbda 100644 --- a/src/screen/setup_wizard/github_step.rs +++ b/src/screen/setup_wizard/github_step.rs @@ -1,39 +1,117 @@ -use gpui::{FontWeight, ParentElement, Styled, div}; +use std::time::{Duration, Instant}; + +use gpui::{AppContext, FontWeight, ParentElement, Styled, div, prelude::FluentBuilder}; +use rand::RngExt; use crate::{ - api, + api, app, component::text::text, - query::{self, use_lazy_query}, + query::{self, QueryStatus, read_query, use_lazy_query}, }; pub(crate) struct GithubStepView { + last_tick: Instant, + placeholder_code: String, create_device_code_query: query::Entity, } pub(crate) fn new(cx: &mut gpui::Context) -> GithubStepView { GithubStepView { + last_tick: Instant::now(), + placeholder_code: "ABCDEFGH".to_owned(), create_device_code_query: use_lazy_query(api::auth::CreateDeviceCode, cx), } } +impl GithubStepView { + const CHAR_POOL: &'static str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + fn generate_random_code(&mut self, cx: &mut gpui::Context) -> String { + let rng = app::rng(cx); + (0..8) + .map(|_| { + let idx = rng.random_range(0..Self::CHAR_POOL.len()); + Self::CHAR_POOL.chars().nth(idx).unwrap() + }) + .collect() + } +} + impl gpui::Render for GithubStepView { fn render( &mut self, - _window: &mut gpui::Window, + window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - div().flex().flex_col().size_full().child(header(cx)) + let theme = app::current_theme(cx); + + let border_color = theme.colors.surface_elevated.clone(); + let bg_color = theme.colors.surface.clone(); + + let create_device_code_query = read_query(&self.create_device_code_query, cx); + let is_loading_code = matches!(create_device_code_query, QueryStatus::Loading); + + let now = Instant::now(); + let should_tick = now.duration_since(self.last_tick) >= Duration::from_millis(50); + + if is_loading_code { + cx.on_next_frame(window, move |this, _, cx| { + if should_tick { + this.placeholder_code = this.generate_random_code(cx); + this.last_tick = Instant::now(); + } + cx.notify(); + }); + } + + let letter_boxes = self + .placeholder_code + .split("") + .filter(|c| !c.is_empty()) + .map(|c| { + text(String::from(c)) + .bold() + .text_2xl() + .styled(move |it| { + it.p_3() + .font_family("CommitMono") + .border_1() + .border_color(border_color) + .rounded_lg() + .bg(bg_color) + }) + .when(is_loading_code, |it| it.opacity(0.5)) + }) + .collect::>(); + + div() + .flex() + .flex_col() + .size_full() + .px_4() + .py_12() + .child(header()) + .child( + div() + .flex() + .flex_row() + .flex_1() + .items_center() + .justify_center() + .gap_1p5() + .children(letter_boxes), + ) } } -fn header(cx: &gpui::Context) -> impl gpui::IntoElement { +fn header() -> impl gpui::IntoElement { div() .flex() .flex_col() .items_center() - .child(text("Connect to GitHub", cx).font_weight(FontWeight(700.))) + .gap_1p5() + .child(text("Connect to GitHub").text_xl().bold()) .child(text( - "You will be redirected to GitHub to authorize access. Copy the device code below into GitHub.", - cx - ).opacity(0.8)) + "You will be redirected to GitHub to authorize access.\nCopy the device code below into GitHub.", + ).leading_tight().centered().opacity(0.8)) } diff --git a/src/screen/setup_wizard/screen.rs b/src/screen/setup_wizard/screen.rs index 2f53b44..55c630a 100644 --- a/src/screen/setup_wizard/screen.rs +++ b/src/screen/setup_wizard/screen.rs @@ -1,14 +1,17 @@ -use gpui::{AppContext, FontWeight, IntoElement, ParentElement, Styled, div}; +use gpui::{ + AppContext, BorrowAppContext, InteractiveElement, IntoElement, ParentElement, + StatefulInteractiveElement, Styled, div, +}; use crate::{ - api, app, + app, component::text::text, - query::{self, use_lazy_query}, - screen::setup_wizard::{github_step, welcome_step}, + screen::setup_wizard::{github_step, welcome_step::welcome_step}, }; pub(crate) struct Screen { current_step: Step, + github_step_view: gpui::Entity, } enum Step { @@ -16,9 +19,27 @@ enum Step { ConnectToGithub, } -pub(crate) fn new(cx: &mut gpui::Context) -> Screen { +pub(crate) fn new(window: &mut gpui::Window, cx: &mut gpui::Context) -> Screen { + cx.observe_window_appearance(window, |_, window, cx| { + cx.update_global::(|global, cx| { + global.current_theme = window.appearance().into(); + cx.notify(); + }); + }) + .detach(); + Screen { current_step: Step::Welcome, + github_step_view: cx.new(|cx| github_step::new(cx)), + } +} + +impl Screen { + fn advance_to_next_step(&mut self) { + match self.current_step { + Step::Welcome => self.current_step = Step::ConnectToGithub, + Step::ConnectToGithub => {} + } } } @@ -29,13 +50,17 @@ impl gpui::Render for Screen { cx: &mut gpui::Context, ) -> impl gpui::IntoElement { let step_view = match self.current_step { - Step::Welcome => welcome_step::new(cx).into_any_element(), - Step::ConnectToGithub => cx.new(|cx| github_step::new(cx)).into_any_element(), + Step::Welcome => welcome_step() + .on_next(cx.listener(|this, _, _window, _cx| this.advance_to_next_step())) + .into_any_element(), + Step::ConnectToGithub => self.github_step_view.clone().into_any_element(), }; let theme = app::current_theme(cx); div() + .id("awd") + .on_click(cx.listener(|a, b, c, d| {})) .flex() .flex_row() .items_center() @@ -51,11 +76,9 @@ impl gpui::Render for Screen { .bg(theme.colors.surface) .relative() .child( - text("Novem", cx) - .font_weight(FontWeight(700.)) - .absolute() - .top_20() - .left_8(), + text("Novem") + .bold() + .styled(|it| it.absolute().top_20().left_8()), ) .child(step_list(cx)), ) @@ -83,9 +106,9 @@ fn step_list(cx: &gpui::Context) -> impl gpui::IntoElement { .gap_3() .text_sm() .children(vec![ - text("Welcome!", cx), - text("Connect to GitHub", cx), - text("Customize Novem", cx), - text("Complete!", cx), + text("Welcome!"), + text("Connect to GitHub"), + text("Customize Novem"), + text("Complete!"), ]) } diff --git a/src/screen/setup_wizard/welcome_step.rs b/src/screen/setup_wizard/welcome_step.rs index 9d1313a..52eb657 100644 --- a/src/screen/setup_wizard/welcome_step.rs +++ b/src/screen/setup_wizard/welcome_step.rs @@ -1,47 +1,62 @@ -use gpui::{FontWeight, ParentElement, Styled, div}; +use gpui::{ParentElement, Styled, div, prelude::FluentBuilder}; -use crate::{ - app, - component::{button::button, text::text}, -}; +use crate::component::{button::button, text::text}; -struct WelcomeStep { - on_next: Option, +#[derive(gpui::IntoElement)] +pub(crate) struct WelcomeStep { + on_next: Option>, } -pub(crate) fn new(cx: &gpui::Context) -> impl gpui::IntoElement { - let theme = app::current_theme(cx); - div() - .flex() - .flex_col() - .size_full() - .items_start() - .justify_center() - .child( - div() - .flex() - .flex_col() - .flex_1() - .justify_center() - .w_full() - .p_8() - .child( - text( - "Welcome to Novem!\nThis wizard will guide you through setting up Novem.\n", - cx, +pub(crate) fn welcome_step() -> WelcomeStep { + WelcomeStep { on_next: None } +} + +impl WelcomeStep { + pub fn on_next(mut self, f: impl Fn(&(), &mut gpui::Window, &mut gpui::App) + 'static) -> Self { + self.on_next = Some(Box::new(f)); + self + } +} + +impl gpui::RenderOnce for WelcomeStep { + fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement { + let next_button = button("next") + .label("Next") + .when(self.on_next.is_some(), |b| { + b.on_click(move |_, window, cx| self.on_next.as_ref().unwrap()(&(), window, cx)) + }); + + div() + .flex() + .flex_col() + .size_full() + .items_start() + .justify_center() + .child( + div() + .flex() + .flex_col() + .flex_1() + .justify_center() + .w_full() + .p_8() + .child( + text( + "Welcome to Novem!\nThis wizard will guide you through setting up Novem.\n", + ) + .opacity(0.8), ) - .opacity(0.8), - ) - .child(text("Press 'Next' to begin setup.", cx).font_weight(FontWeight(500.))), - ) - .child( - div() - .flex() - .flex_row() - .justify_end() - .w_full() - .p_4() - .pt_0() - .child(button("next", cx).label("Next")), - ) + .child(text("Press 'Next' to begin setup.").medium()), + ) + .child( + div() + .flex() + .flex_row() + .justify_end() + .w_full() + .p_4() + .pt_0() + .child(next_button), + ) + } } diff --git a/src/theme.rs b/src/theme.rs index 5197173..c27716e 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -47,6 +47,10 @@ pub enum Variant { VioletDark, } +pub(crate) fn current(cx: &gpui::App) -> &Theme { + cx.global::() +} + impl Variant { #[allow(dead_code)] pub const ALL: [Self; 2] = [Self::VioletLight, Self::VioletDark]; @@ -115,3 +119,5 @@ impl From for Theme { variant.theme() } } + +impl gpui::Global for Theme {} diff --git a/src/titlebar.rs b/src/titlebar.rs index a68ab55..ccd1f0e 100644 --- a/src/titlebar.rs +++ b/src/titlebar.rs @@ -1,4 +1,3 @@ -use gpui::prelude::FluentBuilder; use gpui::{ParentElement, Styled, div}; use crate::component::button::button; @@ -36,7 +35,7 @@ impl gpui::Render for TitleBar { let user_avatar = match user { QueryStatus::Err(api::Error::Unauthenticated) => div().absolute().right_2p5().child( - button("login-btn", cx) + button("login-btn") .leading(font_icon(FontIcon::Github, cx)) .label("Login"), ), @@ -78,6 +77,6 @@ fn repo_selector(cx: &gpui::Context) -> gpui::Div { .gap_1() .text_xs() .child(font_icon(FontIcon::FolderGit, cx).size_3()) - .child(text("test/repo", cx)) + .child(text("test/repo")) .child(font_icon(FontIcon::ChevronDown, cx).size_3()) }