feat: add isolated GPUI component foundation

This commit is contained in:
kuddev committed 2026-08-12 15:06:26 +08:00
1 parent 05bc8313cf
commit 9863cff05b
12 files changed
+8289

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ members = [
"nebula-completions",
"nebula_hook",
]
exclude = ["nebula_gpui"]
resolver = "2"
[workspace.package]
+71
View File
@@ -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.
+7823
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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"
+20
View File
@@ -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");
}
+14
View File
@@ -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);
});
}
+7
View File
@@ -0,0 +1,7 @@
use gpui::App;
pub fn init(cx: &mut App) {
// 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。
gpui_component::init(cx);
super::theme::apply_nebula_theme(cx);
}
+3
View File
@@ -0,0 +1,3 @@
pub mod bootstrap;
pub mod prelude;
pub mod theme;
+25
View File
@@ -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 _;
+46
View File
@@ -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);
}
+254
View File
@@ -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))
}),
),
),
)
}
}
+1
View File
@@ -0,0 +1 @@
pub mod component_gallery;