feat: add isolated GPUI component foundation
This commit is contained in:
1 parent
05bc8313cf
commit
9863cff05b
12 files changed
+8289
No files matched your search
@@ -7,6 +7,7 @@ members = [
|
||||
"nebula-completions",
|
||||
"nebula_hook",
|
||||
]
|
||||
exclude = ["nebula_gpui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Nebula GPUI Foundation
|
||||
|
||||
`nebula_gpui` is an isolated GPUI application workspace. It is a validation and
|
||||
incremental migration surface; it does not replace the existing `nebula_app`
|
||||
runtime yet.
|
||||
|
||||
## Direct Component Use
|
||||
|
||||
Views should import approved upstream components from `ui::prelude`:
|
||||
|
||||
```rust
|
||||
use crate::ui::prelude::*;
|
||||
|
||||
let action = Button::new("save").primary().label("保存");
|
||||
let input = Input::new(&input_state);
|
||||
```
|
||||
|
||||
`prelude` deliberately re-exports upstream types without changing their
|
||||
behavior. Do not create one-to-one wrappers such as `NebulaButton` or
|
||||
`NebulaInput`. A local component is justified only when it owns a Nebula
|
||||
business contract, such as a terminal pane, an atomic-save editor workflow, or
|
||||
a session-aware dock panel.
|
||||
|
||||
## Ownership Boundaries
|
||||
|
||||
- `ui::bootstrap` owns the single GPUI/component-library initialization point.
|
||||
- `ui::theme` owns Nebula visual tokens, fonts, density, and global defaults.
|
||||
- `ui::prelude` is the only approved direct-component import surface for views.
|
||||
- View code may use GPUI entities and window APIs.
|
||||
- Terminal, SSH, SFTP, configuration, history, and other core crates must not
|
||||
expose `gpui::Entity`, `gpui::Window`, or GPUI actions in their public APIs.
|
||||
|
||||
This preserves direct UI composition while keeping the product core portable
|
||||
to another UI library. UI adapters translate core state and commands at the
|
||||
application boundary.
|
||||
|
||||
## Dependency Policy
|
||||
|
||||
The validated dependency set is pinned exactly:
|
||||
|
||||
```toml
|
||||
gpui = "=0.2.2"
|
||||
gpui-component = "=0.5.1"
|
||||
gpui-component-assets = "=0.5.1"
|
||||
```
|
||||
|
||||
Do not use wildcard versions, Git `main`, or the tty7 forks. GPUI and
|
||||
gpui-component must resolve to one GPUI version; verify each dependency change
|
||||
with:
|
||||
|
||||
```powershell
|
||||
cargo check --locked --offline
|
||||
cargo tree -i gpui
|
||||
```
|
||||
|
||||
GPUI upgrades happen in a dedicated change with component acceptance testing;
|
||||
they are not bundled with feature work. Do not fork an upstream crate unless a
|
||||
documented upstream issue blocks a release, the temporary patch has a defined
|
||||
removal condition, and the maintenance owner is explicit.
|
||||
|
||||
## Workspace Boundary
|
||||
|
||||
`nebula_gpui` is excluded from the root Cargo workspace because its current
|
||||
font stack uses a different native `fontconfig` `links` dependency than the
|
||||
existing OpenGL app. Keeping it as its own workspace avoids an unsatisfiable
|
||||
Cargo native-link conflict and leaves the legacy application's lockfile and
|
||||
build path unchanged.
|
||||
|
||||
This boundary does not prevent future reuse of pure Rust core crates, for
|
||||
example `nebula_terminal`; it prevents GUI native dependencies from being
|
||||
resolved together before their ownership and rendering boundaries are ready.
|
||||
Generated
+7823
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "nebula-gpui"
|
||||
version = "0.1.0"
|
||||
license = "GPL-3.0-or-later"
|
||||
description = "Nebula's GPUI application foundation and component acceptance surface"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
[[bin]]
|
||||
name = "nebula-gpui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gpui = "=0.2.2"
|
||||
gpui-component = "=0.5.1"
|
||||
gpui-component-assets = "=0.5.1"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "deny"
|
||||
todo = "deny"
|
||||
@@ -0,0 +1,20 @@
|
||||
use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size};
|
||||
use gpui_component::Root;
|
||||
|
||||
use crate::views::component_gallery::ComponentGallery;
|
||||
|
||||
pub fn open_main_window(cx: &mut App) {
|
||||
let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
window_min_size: Some(size(px(760.0), px(540.0))),
|
||||
app_id: Some("nebula-gpui".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
cx.open_window(options, |window, cx| {
|
||||
let gallery = cx.new(|cx| ComponentGallery::new(window, cx));
|
||||
cx.new(|cx| Root::new(gallery, window, cx))
|
||||
})
|
||||
.expect("failed to open Nebula GPUI window");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod app;
|
||||
mod ui;
|
||||
mod views;
|
||||
|
||||
use app::open_main_window;
|
||||
use gpui_component_assets::Assets;
|
||||
|
||||
fn main() {
|
||||
gpui::Application::new().with_assets(Assets).run(|cx| {
|
||||
ui::bootstrap::init(cx);
|
||||
cx.activate(true);
|
||||
open_main_window(cx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use gpui::App;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
// 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。
|
||||
gpui_component::init(cx);
|
||||
super::theme::apply_nebula_theme(cx);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod bootstrap;
|
||||
pub mod prelude;
|
||||
pub mod theme;
|
||||
@@ -0,0 +1,25 @@
|
||||
//! 业务视图只从这里导入获准使用的第三方组件。
|
||||
//!
|
||||
//! 简单组件直接复用;只有需要承载 Nebula 业务合同的组件才在本 crate 中封装。
|
||||
|
||||
// 这些导出是受控的业务入口,未被当前验收页使用的组件仍应保留,避免业务代码
|
||||
// 重新从上游散落导入而绕过升级边界。
|
||||
#[allow(unused_imports)]
|
||||
pub use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName,
|
||||
button::{Button, ButtonVariants as _},
|
||||
checkbox::Checkbox,
|
||||
dialog::{Dialog, DialogButtonProps},
|
||||
dock::{DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState},
|
||||
h_flex,
|
||||
input::{Input, InputState},
|
||||
menu::{ContextMenuExt as _, PopupMenu},
|
||||
scroll::ScrollableElement as _,
|
||||
select::{Select, SelectState},
|
||||
sidebar::{Sidebar, SidebarGroup, SidebarMenu, SidebarMenuItem},
|
||||
tab::{Tab, TabBar},
|
||||
v_flex,
|
||||
};
|
||||
|
||||
pub use gpui_component::IndexPath;
|
||||
pub use gpui_component::WindowExt as _;
|
||||
@@ -0,0 +1,46 @@
|
||||
use gpui::{App, hsla, px};
|
||||
use gpui_component::{Theme, ThemeMode};
|
||||
|
||||
pub fn apply_nebula_theme(cx: &mut App) {
|
||||
Theme::change(ThemeMode::Dark, None, cx);
|
||||
|
||||
// 先改全局 token,而不是逐组件覆写样式,确保后续直接引入的组件自然进入
|
||||
// Nebula 视觉系统,也让上游升级时需要维护的差异集中在一个位置。
|
||||
let theme = Theme::global_mut(cx);
|
||||
let background = hsla(220.0 / 360.0, 0.16, 0.075, 1.0);
|
||||
let surface = hsla(220.0 / 360.0, 0.13, 0.105, 1.0);
|
||||
let border = hsla(216.0 / 360.0, 0.12, 0.21, 1.0);
|
||||
let text = hsla(210.0 / 360.0, 0.12, 0.91, 1.0);
|
||||
let muted_text = hsla(214.0 / 360.0, 0.09, 0.62, 1.0);
|
||||
let accent = hsla(155.0 / 360.0, 0.62, 0.47, 1.0);
|
||||
let accent_hover = hsla(155.0 / 360.0, 0.58, 0.52, 1.0);
|
||||
let accent_active = hsla(155.0 / 360.0, 0.66, 0.40, 1.0);
|
||||
|
||||
theme.background = background;
|
||||
theme.foreground = text;
|
||||
theme.group_box = surface;
|
||||
theme.group_box_foreground = text;
|
||||
theme.popover = surface;
|
||||
theme.popover_foreground = text;
|
||||
theme.border = border;
|
||||
theme.input = border;
|
||||
theme.primary = accent;
|
||||
theme.primary_hover = accent_hover;
|
||||
theme.primary_active = accent_active;
|
||||
theme.primary_foreground = background;
|
||||
theme.secondary = surface;
|
||||
theme.secondary_hover = border;
|
||||
theme.secondary_active = background;
|
||||
theme.secondary_foreground = text;
|
||||
theme.muted = surface;
|
||||
theme.muted_foreground = muted_text;
|
||||
theme.accent = accent;
|
||||
theme.accent_foreground = background;
|
||||
theme.caret = accent;
|
||||
theme.ring = accent;
|
||||
theme.selection = hsla(155.0 / 360.0, 0.52, 0.34, 0.72);
|
||||
theme.font_size = px(14.0);
|
||||
theme.mono_font_size = px(13.0);
|
||||
theme.radius = px(5.0);
|
||||
theme.radius_lg = px(7.0);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _,
|
||||
Window, div, px,
|
||||
};
|
||||
|
||||
use crate::ui::prelude::*;
|
||||
|
||||
gpui::actions!(component_gallery, [Increment]);
|
||||
|
||||
pub struct ComponentGallery {
|
||||
input: Entity<InputState>,
|
||||
editor: Entity<InputState>,
|
||||
select: Entity<SelectState<Vec<&'static str>>>,
|
||||
dock_area: Entity<DockArea>,
|
||||
enabled: bool,
|
||||
clicks: usize,
|
||||
active_tab: usize,
|
||||
}
|
||||
|
||||
/// Dock 需要一个真正的 Panel 合同;这个面板只负责演示接入边界,不携带业务状态。
|
||||
pub struct GalleryPanel {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl GalleryPanel {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self { focus_handle: cx.focus_handle() }
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for GalleryPanel {}
|
||||
|
||||
impl Focusable for GalleryPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for GalleryPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"NebulaGalleryPanel"
|
||||
}
|
||||
|
||||
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
"停靠面板"
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for GalleryPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex().size_full().gap_2().p_3().child("这是一个直接接入 DockArea 的 GPUI Panel。").child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("后续终端 Pane、文件树和设置页可以复用同一停靠合同。"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentGallery {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("输入中文以验证 IME")
|
||||
.default_value("Nebula GPUI 组件基础已接通")
|
||||
});
|
||||
|
||||
let editor = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.code_editor("rust")
|
||||
.line_number(true)
|
||||
.indent_guides(true)
|
||||
.soft_wrap(false)
|
||||
.rows(8)
|
||||
.default_value("fn main() {\n println!(\"Nebula\");\n}")
|
||||
.placeholder("代码编辑器支持 UTF-8、选区和撤销")
|
||||
});
|
||||
|
||||
let select = cx.new(|cx| {
|
||||
SelectState::new(
|
||||
vec!["终端", "代码编辑器", "设置"],
|
||||
Some(IndexPath::default().row(0)),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let dock_area = cx.new(|cx| DockArea::new("nebula-gallery", Some(1), window, cx));
|
||||
let weak_dock_area = dock_area.downgrade();
|
||||
let panel = cx.new(GalleryPanel::new);
|
||||
dock_area.update(cx, |dock, cx| {
|
||||
dock.set_center(DockItem::tab(panel, &weak_dock_area, window, cx), window, cx);
|
||||
});
|
||||
|
||||
Self { input, editor, select, dock_area, enabled: true, clicks: 0, active_tab: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ComponentGallery {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let count = self.clicks;
|
||||
let status: SharedString = format!("已触发 {count} 次组件事件").into();
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.bg(cx.theme().background)
|
||||
.text_color(cx.theme().foreground)
|
||||
.on_action(cx.listener(|this, _: &Increment, _, cx| {
|
||||
this.clicks += 1;
|
||||
cx.notify();
|
||||
}))
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_8()
|
||||
.gap_6()
|
||||
.overflow_y_scrollbar()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xl()
|
||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||
.child("Nebula"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("GPUI component acceptance surface"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.max_w(px(720.0))
|
||||
.gap_3()
|
||||
.p_5()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().group_box)
|
||||
.child(div().text_sm().child("文本输入与中文 IME"))
|
||||
.child(Input::new(&self.input))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("primary-action")
|
||||
.primary()
|
||||
.label("执行")
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.clicks += 1;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("secondary-action").label("重置计数").on_click(
|
||||
cx.listener(|this, _, _, cx| {
|
||||
this.clicks = 0;
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new("feature-enabled")
|
||||
.label("启用功能")
|
||||
.checked(self.enabled)
|
||||
.on_click(cx.listener(|this, checked, _, cx| {
|
||||
this.enabled = *checked;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(status),
|
||||
)
|
||||
.child(div().text_sm().child("Select / Tabs / Sidebar / Dialog"))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(Select::new(&self.select).placeholder("选择工作区"))
|
||||
.child(
|
||||
Button::new("open-dialog")
|
||||
.outline()
|
||||
.label("打开 Dialog")
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.open_dialog(cx, |dialog, _, _| {
|
||||
dialog.title("Nebula 组件 Dialog").alert().child(
|
||||
"Dialog、焦点和 Esc 关闭行为由组件库提供。",
|
||||
)
|
||||
});
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
TabBar::new("gallery-tabs")
|
||||
.w_full()
|
||||
.selected_index(self.active_tab)
|
||||
.on_click(cx.listener(|this, index: &usize, _, cx| {
|
||||
this.active_tab = *index;
|
||||
cx.notify();
|
||||
}))
|
||||
.child(Tab::new().label("终端"))
|
||||
.child(Tab::new().label("编辑器"))
|
||||
.child(Tab::new().label("设置")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h(px(190.0))
|
||||
.gap_3()
|
||||
.child(Sidebar::left().w(px(180.0)).child(
|
||||
SidebarGroup::new("Nebula").child(
|
||||
SidebarMenu::new().children([
|
||||
SidebarMenuItem::new("会话"),
|
||||
SidebarMenuItem::new("文件"),
|
||||
SidebarMenuItem::new("设置").active(true),
|
||||
]),
|
||||
),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded_md()
|
||||
.overflow_hidden()
|
||||
.child(self.dock_area.clone()),
|
||||
),
|
||||
)
|
||||
.child(div().text_sm().child("Code Editor / Tree-sitter 基础"))
|
||||
.child(Input::new(&self.editor).h(px(220.0)))
|
||||
.child(
|
||||
div()
|
||||
.id("context-menu-target")
|
||||
.w_full()
|
||||
.p_3()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().muted)
|
||||
.child("在此区域点击右键验证 Context Menu")
|
||||
.context_menu(|menu: PopupMenu, _, _| {
|
||||
menu.menu("增加计数", Box::new(Increment))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod component_gallery;
|
||||
Reference in new issue
Block a user