add panels

This commit is contained in:
2026-10-30 01:02:48 +03:00
parent e00f63e5da
commit 56115302df
7 changed files with 421 additions and 5 deletions
+108 -1
View File
@@ -12,8 +12,10 @@ use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap};
use crate::panel::{PanelHandle, PanelState};
thread_local! {
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
@@ -78,6 +80,57 @@ pub struct AppApi {
workspace: Rc<RefCell<Workspace>>,
#[lua(skip)]
keymap: Rc<RefCell<KeyMap>>,
#[lua(skip)]
left_panel: PanelHandle,
#[lua(skip)]
right_panel: PanelHandle,
#[lua(skip)]
bottom_panel: PanelHandle,
}
/// API одной панели окна.
///
/// Один и тот же тип используется для `app.left_panel`, `app.right_panel` и
/// `app.bottom_panel`: отличается только состояние, на которое ссылается
/// объект. Поэтому панели управляются одинаково: `app.left_panel:open()`,
/// `app.bottom_panel:toggle()` и так далее.
#[derive(mlua::UserData)]
pub struct PanelApi {
#[lua(skip)]
state: PanelHandle,
}
impl PanelApi {
fn new(state: PanelHandle) -> Self {
Self { state }
}
}
#[mlua::userdata_impl]
impl PanelApi {
/// Открывает панель.
#[lua(infallible)]
pub fn open(&self) {
self.state.borrow_mut().open();
}
/// Закрывает панель.
#[lua(infallible)]
pub fn close(&self) {
self.state.borrow_mut().close();
}
/// Переключает панель и возвращает новое состояние.
#[lua(infallible)]
pub fn toggle(&self) -> bool {
self.state.borrow_mut().toggle()
}
/// Открыта ли панель прямо сейчас.
#[lua(infallible)]
pub fn is_open(&self) -> bool {
self.state.borrow().is_open()
}
}
#[mlua::userdata_impl]
@@ -86,7 +139,13 @@ impl AppApi {
/// [`AppApi::global`].
#[lua(skip)]
pub fn install(workspace: Rc<RefCell<Workspace>>, keymap: Rc<RefCell<KeyMap>>) -> Self {
let api = Self { workspace, keymap };
let api = Self {
workspace,
keymap,
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api
}
@@ -104,6 +163,24 @@ impl AppApi {
})
}
/// Состояние левой панели (для вида).
#[lua(skip)]
pub fn left_panel_handle(&self) -> PanelHandle {
self.left_panel.clone()
}
/// Состояние правой панели (для вида).
#[lua(skip)]
pub fn right_panel_handle(&self) -> PanelHandle {
self.right_panel.clone()
}
/// Состояние нижней панели (для вида).
#[lua(skip)]
pub fn bottom_panel_handle(&self) -> PanelHandle {
self.bottom_panel.clone()
}
/// Все открытые вкладки в порядке слева направо.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
@@ -287,6 +364,36 @@ impl AppApi {
self.workspace.borrow_mut().clear_language(index)
}
/// Левая панель окна.
///
/// В Lua доступна как `app.left_panel` с методами `open`, `close`,
/// `toggle` и `is_open`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn left_panel(&self) -> PanelApi {
PanelApi::new(self.left_panel.clone())
}
/// Правая панель окна.
///
/// В Lua доступна как `app.right_panel` с методами `open`, `close`,
/// `toggle` и `is_open`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn right_panel(&self) -> PanelApi {
PanelApi::new(self.right_panel.clone())
}
/// Нижняя панель окна.
///
/// В Lua доступна как `app.bottom_panel` с теми же методами, что и
/// `app.right_panel`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn bottom_panel(&self) -> PanelApi {
PanelApi::new(self.bottom_panel.clone())
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
+112 -4
View File
@@ -10,16 +10,37 @@ use std::rc::Rc;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Editor, EditorState, InputEvent, TabSize};
use gpui_kit::component::resizable::HandleEdge;
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{ActiveTheme, IconName, Sizable, StyledExt, TitleBar, h_flex};
use gpui_kit::component::{
ActiveTheme, IconName, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel,
resize_handle, v_resizable,
};
use gpui_kit::*;
use crate::api::{self, AppApi, WindowCommand};
use crate::constants::{TAB_SIZE, WINDOW_TITLE};
use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE};
use crate::editor::Workspace;
use crate::keymap::KeyMap;
use crate::panel::PanelHandle;
use crate::script::ScriptEngine;
/// Payload перетаскивания разделителя левой панели.
///
/// Тип payload-а нужен `on_drag_move`, чтобы отличать этот drag от
/// перетаскивания других разделителей.
#[derive(Clone)]
struct LeftPanelDrag;
/// Пустой предпросмотр: при перетаскивании виден только разделитель.
struct PanelDragPreview;
impl Render for PanelDragPreview {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
/// Приложение текстового редактора.
pub struct MyApp {
/// Открытые документы, вкладки и активная вкладка.
@@ -32,6 +53,12 @@ pub struct MyApp {
/// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения)
/// уничтожились раньше самого Lua-движка.
keymap: Rc<RefCell<KeyMap>>,
/// Состояние левой панели, общее с `app.left_panel` в Lua.
left_panel: PanelHandle,
/// Состояние правой панели, общее с `app.right_panel` в Lua.
right_panel: PanelHandle,
/// Состояние нижней панели, общее с `app.bottom_panel` в Lua.
bottom_panel: PanelHandle,
/// Виджет редактора, показывающий активный документ.
editor: Entity<EditorState>,
/// Ревизия [`Workspace`], уже перенесённая в `editor`.
@@ -97,11 +124,17 @@ impl MyApp {
);
let displayed_id = workspace.borrow().active().id;
AppApi::install(workspace.clone(), keymap.clone());
let api = AppApi::install(workspace.clone(), keymap.clone());
let left_panel = api.left_panel_handle();
let right_panel = api.right_panel_handle();
let bottom_panel = api.bottom_panel_handle();
Self {
workspace,
keymap,
left_panel,
right_panel,
bottom_panel,
editor,
synced_revision: 0,
displayed_id,
@@ -190,6 +223,81 @@ impl MyApp {
});
}
/// Редактор с левой, правой и нижней панелями.
///
/// Все панели меняют размер перетаскиванием разделителя, а закрытые
/// панели не занимают места. Сами панели пока пустые.
fn render_workspace(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
let (left_open, left_size) = {
let panel = self.left_panel.borrow();
(panel.is_open(), panel.size())
};
// Левая панель не входит в ResizablePanelGroup и не сжимается вместе
// с окном, поэтому её ширину ограничиваем сами.
let max_left =
(window.bounds().size.width - px(MIN_MAIN_AREA_WIDTH)).max(px(MIN_LEFT_PANEL_WIDTH));
let left_width = px(left_size).min(max_left);
let (right_open, right_size) = {
let panel = self.right_panel.borrow();
(panel.is_open(), panel.size())
};
let (bottom_open, bottom_size) = {
let panel = self.bottom_panel.borrow();
(panel.is_open(), panel.size())
};
// Нижняя панель занимает ширину редактора, а правая тянется на всю
// высоту — как в большинстве редакторов кода.
let editor_area = v_resizable("editor-area")
.child(resizable_panel().child(Editor::new(&self.editor).bordered(false).size_full()))
.child(
resizable_panel()
.size(px(bottom_size))
.visible(bottom_open)
.child(div().size_full().bg(cx.theme().sidebar)),
);
let area = h_resizable("main-area")
.child(resizable_panel().child(editor_area))
.child(
resizable_panel()
.size(px(right_size))
.visible(right_open)
.child(div().size_full().bg(cx.theme().sidebar)),
);
let mut row = div().size_full().flex().flex_row();
if left_open {
row = row.child(
div()
.w(left_width)
.h_full()
.flex_shrink_0()
.relative()
.bg(cx.theme().sidebar)
.child(
resize_handle("left-panel-handle", Axis::Horizontal)
.inside(HandleEdge::Trailing)
.on_drag(LeftPanelDrag, |_, _, _, cx| cx.new(|_| PanelDragPreview)),
),
);
}
row.child(div().flex_1().min_w_0().h_full().child(area))
.on_drag_move::<LeftPanelDrag>({
let entity = cx.entity();
move |event: &DragMoveEvent<LeftPanelDrag>, _window, cx| {
let min = px(MIN_LEFT_PANEL_WIDTH);
let max = (event.bounds.size.width - px(MIN_MAIN_AREA_WIDTH)).max(min);
let width = (event.event.position.x - event.bounds.left()).clamp(min, max);
entity.update(cx, |this, cx| {
this.left_panel.borrow_mut().set_size(width.as_f32());
cx.notify();
});
}
})
}
/// Полоса вкладок: активная вкладка, маркер несохранённых изменений и «+».
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
let (tabs, active) = {
@@ -343,7 +451,7 @@ impl Render for MyApp {
.flex_1()
.w_full()
.min_h_0()
.child(Editor::new(&self.editor).bordered(false).size_full()),
.child(self.render_workspace(window, cx)),
)
}
}
+15
View File
@@ -18,6 +18,21 @@ pub const WINDOW_TITLE: &str = "Custom Window App";
/// Ширина одного уровня отступа в редакторе.
pub const TAB_SIZE: usize = 4;
/// Ширина правой панели при открытии.
pub const RIGHT_PANEL_WIDTH: f32 = 240.0;
/// Ширина левой панели при открытии.
pub const LEFT_PANEL_WIDTH: f32 = 220.0;
/// Минимальная ширина левой панели при перетаскивании разделителя.
pub const MIN_LEFT_PANEL_WIDTH: f32 = 120.0;
/// Минимальная ширина области редактора при изменении левой панели.
pub const MIN_MAIN_AREA_WIDTH: f32 = 240.0;
/// Высота нижней панели при открытии.
pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0;
/// Каталог, в котором приложение ищет Lua-сценарии.
///
/// По умолчанию это текущий рабочий каталог процесса — именно там приложение
+1
View File
@@ -10,6 +10,7 @@ mod app;
mod constants;
mod editor;
mod keymap;
mod panel;
mod script;
#[cfg(test)]
+63
View File
@@ -0,0 +1,63 @@
//! Состояние боковых панелей окна.
//!
//! Модель общая с API: Lua-сценарии меняют [`PanelState`] через `app.right_panel`
//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при
//! отрисовке. Как и документы, состояние лежит в `Rc<RefCell<_>>`, чтобы вид и
//! API видели одни и те же панели.
use std::cell::RefCell;
use std::rc::Rc;
/// Общая ссылка на состояние панели.
pub type PanelHandle = Rc<RefCell<PanelState>>;
/// Открыта ли панель и каким размером она раскрывается.
///
/// Размер — начальный: после перетаскивания разделителя виджет
/// `ResizablePanelGroup` запоминает фактический размер панели сам.
#[derive(Clone, Copy, Debug)]
pub struct PanelState {
visible: bool,
size: f32,
}
impl PanelState {
/// Создаёт закрытую панель с начальным размером `size` пикселей.
pub fn new(size: f32) -> Self {
Self {
visible: false,
size: size.max(0.0),
}
}
/// Открыта ли панель сейчас.
pub fn is_open(&self) -> bool {
self.visible
}
/// Начальный размер панели в пикселях.
pub fn size(&self) -> f32 {
self.size
}
/// Запоминает новый размер панели в пикселях.
pub fn set_size(&mut self, size: f32) {
self.size = size.max(0.0);
}
/// Открывает панель.
pub fn open(&mut self) {
self.visible = true;
}
/// Закрывает панель.
pub fn close(&mut self) {
self.visible = false;
}
/// Переключает видимость и возвращает новое состояние.
pub fn toggle(&mut self) -> bool {
self.visible = !self.visible;
self.visible
}
}
+105
View File
@@ -184,6 +184,90 @@ fn window_commands_are_queued_for_the_view() {
assert!(drain_window_commands().is_empty());
}
#[test]
fn panel_api_opens_closes_and_toggles_the_same_panel_state() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let right = api.right_panel_handle();
let bottom = api.bottom_panel_handle();
let left = api.left_panel_handle();
assert!(!left.borrow().is_open());
assert!(!right.borrow().is_open());
assert!(!bottom.borrow().is_open());
api.left_panel().open();
assert!(left.borrow().is_open());
assert!(api.left_panel().is_open());
api.left_panel().close();
assert!(!left.borrow().is_open());
api.right_panel().open();
assert!(right.borrow().is_open());
assert!(api.right_panel().is_open());
// toggle возвращает новое состояние и не трогает вторую панель.
assert!(!api.right_panel().toggle());
assert!(!right.borrow().is_open());
assert!(api.right_panel().toggle());
assert!(right.borrow().is_open());
api.right_panel().close();
assert!(!right.borrow().is_open());
api.bottom_panel().open();
assert!(bottom.borrow().is_open());
assert!(!right.borrow().is_open());
}
#[test]
fn lua_script_can_control_panels() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let right = api.right_panel_handle();
let bottom = api.bottom_panel_handle();
let left = api.left_panel_handle();
let dir = temp_script_dir("panels");
let script = dir.join("panels.lua");
std::fs::write(
&script,
r#"
app.left_panel:open()
assert(app.left_panel:is_open())
app.left_panel:close()
assert(not app.left_panel:is_open())
assert(app.left_panel:toggle() == true)
app.right_panel:open()
assert(app.right_panel:is_open())
assert(not app.bottom_panel:is_open())
app.bottom_panel:open()
app.right_panel:toggle()
assert(not app.right_panel:is_open())
assert(app.bottom_panel:toggle() == false)
app.bottom_panel:close()
assert(not app.bottom_panel:is_open())
assert(app.right_panel:toggle() == true)
"#,
)
.expect("panels.lua");
let engine = ScriptEngine::new(api).expect("движок Lua");
engine
.run_file(&script)
.expect("панели должны управляться из Lua");
assert!(left.borrow().is_open());
assert!(right.borrow().is_open());
assert!(!bottom.borrow().is_open());
let _ = std::fs::remove_dir_all(&dir);
}
/// Временный каталог с уникальным именем для Lua-сценариев.
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
@@ -461,6 +545,27 @@ fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext
let _ = std::fs::remove_dir_all(&dir);
}
#[gpui_kit::test]
fn opened_panels_render_without_panicking(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(|window, cx| MyApp::new(window, cx));
let api = AppApi::global();
api.left_panel().open();
api.right_panel().open();
api.bottom_panel().open();
visual.update(|_window, cx| {
app.update(cx, |_, cx| cx.notify());
});
visual.run_until_parked();
// Состояние осталось у панелей, которые видит вид.
assert!(api.left_panel_handle().borrow().is_open());
assert!(api.right_panel_handle().borrow().is_open());
assert!(api.bottom_panel_handle().borrow().is_open());
}
#[gpui_kit::test]
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);