From 56115302df4784c6a7e37aee021f71523583102d Mon Sep 17 00:00:00 2001 From: "dedal.qq" Date: Fri, 30 Oct 2026 01:02:48 +0300 Subject: [PATCH] add panels --- init.lua | 17 +++++++ src/api.rs | 109 +++++++++++++++++++++++++++++++++++++++++++- src/app.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++-- src/constants.rs | 15 ++++++ src/main.rs | 1 + src/panel.rs | 63 +++++++++++++++++++++++++ src/tests.rs | 105 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 src/panel.rs diff --git a/init.lua b/init.lua index cf0574b..c1aab30 100644 --- a/init.lua +++ b/init.lua @@ -37,6 +37,20 @@ print("[lua] init.lua выполнен") -- app:minimize() -- app:is_maximized() -- +-- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием): +-- app.left_panel:open() +-- app.left_panel:close() +-- app.left_panel:toggle() -- вернуть true, если панель открыта +-- app.left_panel:is_open() +-- app.right_panel:open() -- те же методы, что у левой панели +-- app.right_panel:close() +-- app.right_panel:toggle() +-- app.right_panel:is_open() +-- app.bottom_panel:open() -- те же методы, что у левой панели +-- app.bottom_panel:close() +-- app.bottom_panel:toggle() +-- app.bottom_panel:is_open() +-- -- Методы добавляются в src/api.rs: всё, что есть в impl AppApi, помеченном -- #[mlua::userdata_impl], автоматически доступно из Lua. -- @@ -55,3 +69,6 @@ app:next_tab() -- app:map_key("", function() app:close() end) app:map_key("", function() app:close() end) + +app:map_key("", function() app.right_panel:toggle() end) +app:map_key("", function() app.bottom_panel:toggle() end) diff --git a/src/api.rs b/src/api.rs index 6f625a1..59be0a5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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>, #[lua(skip)] keymap: Rc>, + #[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>, keymap: Rc>) -> 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) { diff --git a/src/app.rs b/src/app.rs index 07d711d..5e5c84f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) -> impl IntoElement { + div() + } +} + /// Приложение текстового редактора. pub struct MyApp { /// Открытые документы, вкладки и активная вкладка. @@ -32,6 +53,12 @@ pub struct MyApp { /// Лежит перед `scripts`, чтобы обработчики (ссылки на Lua-значения) /// уничтожились раньше самого Lua-движка. keymap: Rc>, + /// Состояние левой панели, общее с `app.left_panel` в Lua. + left_panel: PanelHandle, + /// Состояние правой панели, общее с `app.right_panel` в Lua. + right_panel: PanelHandle, + /// Состояние нижней панели, общее с `app.bottom_panel` в Lua. + bottom_panel: PanelHandle, /// Виджет редактора, показывающий активный документ. editor: Entity, /// Ревизия [`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) -> 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::({ + let entity = cx.entity(); + move |event: &DragMoveEvent, _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) -> 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)), ) } } diff --git a/src/constants.rs b/src/constants.rs index 61df23e..e946a71 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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-сценарии. /// /// По умолчанию это текущий рабочий каталог процесса — именно там приложение diff --git a/src/main.rs b/src/main.rs index ff42312..e513c0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod app; mod constants; mod editor; mod keymap; +mod panel; mod script; #[cfg(test)] diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..eeaa68d --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,63 @@ +//! Состояние боковых панелей окна. +//! +//! Модель общая с API: Lua-сценарии меняют [`PanelState`] через `app.right_panel` +//! и `app.bottom_panel`, а вид [`MyApp`](crate::app::MyApp) читает его при +//! отрисовке. Как и документы, состояние лежит в `Rc>`, чтобы вид и +//! API видели одни и те же панели. + +use std::cell::RefCell; +use std::rc::Rc; + +/// Общая ссылка на состояние панели. +pub type PanelHandle = Rc>; + +/// Открыта ли панель и каким размером она раскрывается. +/// +/// Размер — начальный: после перетаскивания разделителя виджет +/// `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 + } +} diff --git a/src/tests.rs b/src/tests.rs index 6cfaa26..645703e 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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);