use gpui
This commit is contained in:
+265
-417
@@ -1,196 +1,29 @@
|
||||
//! Проверки UI-логики на «сырых» событиях egui (без реального окна).
|
||||
//! Тесты доменной модели, Lua-интеграции и клавиатурных привязок.
|
||||
//!
|
||||
//! UI-часть (заголовок, перетаскивание, изменение размера) теперь принадлежит
|
||||
//! gpui-kit, поэтому её отдельно не проверяем. Остаются тесты, которые
|
||||
//! фиксируют поведение самого приложения: документы, API, Lua и KeyMap.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use eframe::egui;
|
||||
use gpui_kit::{Keystroke, Modifiers, TestAppContext};
|
||||
|
||||
use crate::api::AppApi;
|
||||
use crate::api::{AppApi, WindowCommand, drain_window_commands};
|
||||
use crate::app::MyApp;
|
||||
use crate::editor::EditorState;
|
||||
use crate::keymap::KeyMap;
|
||||
use crate::editor::{Language, Workspace};
|
||||
use crate::keymap::{KeyBinding, KeyMap, Modifiers as KeyModifiers};
|
||||
use crate::script::ScriptEngine;
|
||||
use crate::syntax::{HighlightStyle, Language, SyntaxState};
|
||||
use crate::ui::title_bar::TitleBarResponses;
|
||||
|
||||
const SCREEN: egui::Vec2 = egui::vec2(600.0, 400.0);
|
||||
|
||||
/// Один проход кадра для данного экземпляра приложения:
|
||||
/// возвращает ответы заголовка и команды окна.
|
||||
fn step_app(
|
||||
ctx: &egui::Context,
|
||||
app: &mut MyApp,
|
||||
events: Vec<egui::Event>,
|
||||
) -> (TitleBarResponses, Vec<egui::ViewportCommand>) {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN)),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut bar = None;
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
bar = Some(app.show(ui));
|
||||
});
|
||||
|
||||
let commands = out
|
||||
.viewport_output
|
||||
.get(&egui::ViewportId::ROOT)
|
||||
.map(|output| output.commands.clone())
|
||||
.unwrap_or_default();
|
||||
out.textures_delta.clear();
|
||||
|
||||
(bar.expect("заголовок отрисован"), commands)
|
||||
}
|
||||
|
||||
/// Один проход кадра на свежем приложении.
|
||||
fn step(
|
||||
ctx: &egui::Context,
|
||||
events: Vec<egui::Event>,
|
||||
) -> (TitleBarResponses, Vec<egui::ViewportCommand>) {
|
||||
let mut app = MyApp::default();
|
||||
step_app(ctx, &mut app, events)
|
||||
}
|
||||
|
||||
fn button_event(pos: egui::Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn click_at(ctx: &egui::Context, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
|
||||
let mut commands = Vec::new();
|
||||
commands.extend(step(ctx, vec![egui::Event::PointerMoved(pos)]).1);
|
||||
commands.extend(step(ctx, vec![button_event(pos, true)]).1);
|
||||
commands.extend(step(ctx, vec![]).1);
|
||||
commands.extend(step(ctx, vec![button_event(pos, false)]).1);
|
||||
commands.extend(step(ctx, vec![]).1);
|
||||
commands
|
||||
}
|
||||
|
||||
fn press_at(ctx: &egui::Context, pos: egui::Pos2) -> Vec<egui::ViewportCommand> {
|
||||
let mut commands = step(ctx, vec![egui::Event::PointerMoved(pos)]).1;
|
||||
commands.extend(step(ctx, vec![button_event(pos, true)]).1);
|
||||
commands
|
||||
}
|
||||
|
||||
/// Событие клавиатуры, у которого логическая клавиша может отличаться от
|
||||
/// физической (как при смене раскладки).
|
||||
fn key_event(
|
||||
physical: egui::Key,
|
||||
logical: egui::Key,
|
||||
pressed: bool,
|
||||
modifiers: egui::Modifiers,
|
||||
) -> egui::Event {
|
||||
egui::Event::Key {
|
||||
key: logical,
|
||||
physical_key: Some(physical),
|
||||
pressed,
|
||||
repeat: false,
|
||||
modifiers,
|
||||
}
|
||||
fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>>) {
|
||||
let keymap = Rc::new(RefCell::new(KeyMap::default()));
|
||||
let api = AppApi::install(workspace, keymap.clone());
|
||||
(api, keymap)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_buttons_are_hoverable_and_clickable() {
|
||||
let ctx = egui::Context::default();
|
||||
// прогреваем: egui считает раскладку по предыдущему кадру
|
||||
step(&ctx, vec![]);
|
||||
let (bar, _) = step(&ctx, vec![]);
|
||||
|
||||
let close_pos = bar.close.rect.center();
|
||||
let maximize_pos = bar.maximize.rect.center();
|
||||
let minimize_pos = bar.minimize.rect.center();
|
||||
|
||||
// Кнопки должны находиться в заголовке, а не где-то ещё
|
||||
assert!(bar.drag.rect.contains(close_pos));
|
||||
|
||||
// Наведение даёт подсветку
|
||||
step(&ctx, vec![egui::Event::PointerMoved(close_pos)]);
|
||||
let (hovered, _) = step(&ctx, vec![]);
|
||||
assert!(
|
||||
hovered.close.hovered(),
|
||||
"кнопка «закрыть» не подсвечивается"
|
||||
);
|
||||
|
||||
let close_cmds = click_at(&ctx, close_pos);
|
||||
assert!(
|
||||
close_cmds.contains(&egui::ViewportCommand::Close),
|
||||
"клик по «закрыть» не отправил Close: {close_cmds:?}"
|
||||
);
|
||||
|
||||
let max_cmds = click_at(&ctx, maximize_pos);
|
||||
assert!(
|
||||
max_cmds.contains(&egui::ViewportCommand::Maximized(true)),
|
||||
"клик по «развернуть» не отправил Maximized: {max_cmds:?}"
|
||||
);
|
||||
|
||||
let min_cmds = click_at(&ctx, minimize_pos);
|
||||
assert!(
|
||||
min_cmds.contains(&egui::ViewportCommand::Minimized(true)),
|
||||
"клик по «свернуть» не отправил Minimized: {min_cmds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_title_bar_starts_window_drag_even_over_the_text() {
|
||||
for (name, x_offset) in [("по тексту", 40.0), ("по пустому месту", 300.0)]
|
||||
{
|
||||
let ctx = egui::Context::default();
|
||||
step(&ctx, vec![]);
|
||||
let (bar, _) = step(&ctx, vec![]);
|
||||
|
||||
let start = egui::pos2(bar.drag.rect.left() + x_offset, bar.drag.rect.center().y);
|
||||
let moved = start + egui::vec2(24.0, 12.0);
|
||||
|
||||
step(&ctx, vec![egui::Event::PointerMoved(start)]);
|
||||
let mut commands = step(&ctx, vec![button_event(start, true)]).1;
|
||||
commands.extend(step(&ctx, vec![egui::Event::PointerMoved(moved)]).1);
|
||||
|
||||
assert!(
|
||||
commands.contains(&egui::ViewportCommand::StartDrag),
|
||||
"драг заголовка {name} не инициировал перемещение окна: {commands:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_and_corners_start_resize() {
|
||||
use egui::viewport::ResizeDirection as Dir;
|
||||
|
||||
let cases = [
|
||||
(egui::pos2(300.0, 2.0), Dir::North),
|
||||
(egui::pos2(300.0, SCREEN.y - 2.0), Dir::South),
|
||||
(egui::pos2(2.0, 200.0), Dir::West),
|
||||
(egui::pos2(SCREEN.x - 2.0, 200.0), Dir::East),
|
||||
(egui::pos2(2.0, 2.0), Dir::NorthWest),
|
||||
(egui::pos2(SCREEN.x - 2.0, 2.0), Dir::NorthEast),
|
||||
(egui::pos2(SCREEN.x - 2.0, SCREEN.y - 2.0), Dir::SouthEast),
|
||||
(egui::pos2(2.0, SCREEN.y - 2.0), Dir::SouthWest),
|
||||
];
|
||||
|
||||
for (pos, expected) in cases {
|
||||
let ctx = egui::Context::default();
|
||||
step(&ctx, vec![]);
|
||||
step(&ctx, vec![]);
|
||||
let commands = press_at(&ctx, pos);
|
||||
assert!(
|
||||
commands.contains(&egui::ViewportCommand::BeginResize(expected)),
|
||||
"в точке {pos:?} ожидался BeginResize({expected:?}), получено: {commands:?}"
|
||||
);
|
||||
assert!(
|
||||
!commands.contains(&egui::ViewportCommand::StartDrag),
|
||||
"в точке {pos:?} вместо ресайза началось перемещение окна"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_state_keeps_at_least_one_tab() {
|
||||
let mut state = EditorState::default();
|
||||
fn workspace_keeps_at_least_one_tab() {
|
||||
let mut state = Workspace::default();
|
||||
assert_eq!(state.documents().len(), 2);
|
||||
assert_eq!(state.active().name, "main.rs");
|
||||
|
||||
@@ -213,58 +46,51 @@ fn editor_state_keeps_at_least_one_tab() {
|
||||
|
||||
#[test]
|
||||
fn closing_tab_before_active_keeps_the_same_document_active() {
|
||||
let mut state = EditorState::default();
|
||||
let mut state = Workspace::default();
|
||||
state.open_new();
|
||||
let name = state.active().name.clone();
|
||||
let id = state.active().id;
|
||||
|
||||
state.close(0);
|
||||
assert_eq!(state.active().name, name);
|
||||
assert_eq!(state.active().id, id);
|
||||
assert_eq!(state.active_index(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_marks_document_dirty_and_saving_clears_it() {
|
||||
let mut state = EditorState::default();
|
||||
let mut state = Workspace::default();
|
||||
assert!(!state.active().is_dirty());
|
||||
|
||||
state.active_mut().content.push_str("\n// правка\n");
|
||||
state.set_active_text("// правка\n");
|
||||
assert!(state.active().is_dirty());
|
||||
|
||||
state.active_mut().mark_saved();
|
||||
state.mark_active_saved();
|
||||
assert!(!state.active().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_into_editor_updates_active_document() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = MyApp::default();
|
||||
fn revision_changes_only_for_external_edits() {
|
||||
let mut state = Workspace::default();
|
||||
let revision = state.revision();
|
||||
|
||||
// Прогреваем раскладку и кликаем в область редактора ниже панели вкладок.
|
||||
step_app(&ctx, &mut app, vec![]);
|
||||
let editor_pos = egui::pos2(SCREEN.x / 2.0, SCREEN.y - 80.0);
|
||||
step_app(&ctx, &mut app, vec![egui::Event::PointerMoved(editor_pos)]);
|
||||
step_app(&ctx, &mut app, vec![button_event(editor_pos, true)]);
|
||||
step_app(&ctx, &mut app, vec![button_event(editor_pos, false)]);
|
||||
// Набор текста в виджете ревизию не двигает: редактор уже показывает текст.
|
||||
state.replace_active_content("typed".to_owned());
|
||||
assert_eq!(state.revision(), revision);
|
||||
|
||||
step_app(&ctx, &mut app, vec![egui::Event::Text("ZZZ".to_owned())]);
|
||||
// Внешняя замена (Lua) — двигает.
|
||||
state.set_active_text("from lua");
|
||||
assert_ne!(state.revision(), revision);
|
||||
|
||||
let editor = app.editor();
|
||||
let doc = editor.active();
|
||||
assert!(
|
||||
doc.content.contains("ZZZ"),
|
||||
"введённый текст не попал в документ: {:?}",
|
||||
doc.content
|
||||
);
|
||||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||||
// Переключение вкладки — тоже.
|
||||
let before_switch = state.revision();
|
||||
state.set_active(1);
|
||||
assert_ne!(state.revision(), before_switch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_lists_tabs_and_closes_them_by_number() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = MyApp::default();
|
||||
step_app(&ctx, &mut app, vec![]);
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
let api = AppApi::global();
|
||||
let tabs = api.tabs();
|
||||
assert_eq!(
|
||||
tabs.iter()
|
||||
@@ -279,52 +105,44 @@ fn api_lists_tabs_and_closes_them_by_number() {
|
||||
|
||||
assert!(api.close_tab(1));
|
||||
assert_eq!(api.tabs().len(), 1);
|
||||
assert_eq!(app.editor().documents().len(), 1);
|
||||
assert_eq!(workspace.borrow().documents().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_save_writes_active_tab_to_file_named_after_it() {
|
||||
// Тест пишет во временный файл, а не в каталог проекта.
|
||||
let mut state = EditorState::default();
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"egui-rust-test-save-{}-{}.rs",
|
||||
"gpui-editor-test-save-{}-{}.rs",
|
||||
std::process::id(),
|
||||
state.active().id
|
||||
workspace.borrow().active().id
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
{
|
||||
let mut state = workspace.borrow_mut();
|
||||
let doc = state.active_mut();
|
||||
doc.name = path.to_string_lossy().into_owned();
|
||||
doc.content = "fn main() {}\n".to_owned();
|
||||
}
|
||||
assert!(state.active().is_dirty());
|
||||
assert!(workspace.borrow().active().is_dirty());
|
||||
|
||||
let editor = Rc::new(RefCell::new(state));
|
||||
let api = AppApi::install(
|
||||
egui::Context::default(),
|
||||
editor.clone(),
|
||||
Rc::new(RefCell::new(KeyMap::default())),
|
||||
);
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
api.save().expect("сохранение должно удаться");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("файл должен существовать"),
|
||||
"fn main() {}\n"
|
||||
);
|
||||
assert!(!editor.borrow().active().is_dirty());
|
||||
assert!(!workspace.borrow().active().is_dirty());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_switches_tabs_forward_and_back_in_a_cycle() {
|
||||
let editor = Rc::new(RefCell::new(EditorState::default()));
|
||||
let api = AppApi::install(
|
||||
egui::Context::default(),
|
||||
editor.clone(),
|
||||
Rc::new(RefCell::new(KeyMap::default())),
|
||||
);
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
assert_eq!(api.active_tab_index(), 0);
|
||||
|
||||
@@ -337,18 +155,39 @@ fn api_switches_tabs_forward_and_back_in_a_cycle() {
|
||||
// С первой вкладки предыдущая — последняя.
|
||||
assert_eq!(api.previous_tab(), 1);
|
||||
assert_eq!(api.previous_tab(), 0);
|
||||
assert_eq!(editor.borrow().active_index(), 0);
|
||||
assert_eq!(workspace.borrow().active_index(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_script_can_include_files_and_control_the_app() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = MyApp::default();
|
||||
// Первый кадр публикует AppApi, который использует сценарий.
|
||||
step_app(&ctx, &mut app, vec![]);
|
||||
fn window_commands_are_queued_for_the_view() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace);
|
||||
|
||||
// Очередь могла остаться от другого теста на этом потоке — очищаем.
|
||||
let _ = drain_window_commands();
|
||||
|
||||
api.minimize();
|
||||
api.toggle_maximize();
|
||||
api.set_maximized(false);
|
||||
api.close();
|
||||
|
||||
assert_eq!(
|
||||
drain_window_commands(),
|
||||
vec![
|
||||
WindowCommand::Minimize,
|
||||
WindowCommand::ToggleMaximize,
|
||||
WindowCommand::SetMaximized(false),
|
||||
WindowCommand::Close,
|
||||
]
|
||||
);
|
||||
// Повторное чтение пустой очереди ничего не возвращает.
|
||||
assert!(drain_window_commands().is_empty());
|
||||
}
|
||||
|
||||
/// Временный каталог с уникальным именем для Lua-сценариев.
|
||||
fn temp_script_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"egui-rust-test-lua-{}-{}",
|
||||
"gpui-editor-test-{prefix}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -356,7 +195,15 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_script_can_include_files_and_control_the_app() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
let dir = temp_script_dir("lua");
|
||||
let helper = dir.join("helper.lua");
|
||||
let main = dir.join("main.lua");
|
||||
std::fs::write(
|
||||
@@ -373,22 +220,22 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
)
|
||||
.expect("main.lua");
|
||||
|
||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||
engine.run_file(&main).expect("сценарий должен выполниться");
|
||||
|
||||
let editor = app.editor();
|
||||
let state = workspace.borrow();
|
||||
assert_eq!(
|
||||
editor.documents()[0].content,
|
||||
state.documents()[0].content,
|
||||
"from lua",
|
||||
"include должен выполнить подключённый файл в общем окружении"
|
||||
);
|
||||
assert_eq!(
|
||||
editor.active_index(),
|
||||
state.active_index(),
|
||||
1,
|
||||
"app:next_tab() не переключил вкладку"
|
||||
);
|
||||
assert_eq!(
|
||||
editor.documents()[1].language,
|
||||
state.documents()[1].language,
|
||||
Some(Language::Python),
|
||||
"app:set_language() не включил подсветку из Lua"
|
||||
);
|
||||
@@ -397,22 +244,11 @@ fn lua_script_can_include_files_and_control_the_app() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_key_bindings_use_physical_keys_and_modifiers() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = MyApp::default();
|
||||
// Первый кадр публикует AppApi с картой клавиш приложения.
|
||||
step_app(&ctx, &mut app, vec![]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"egui-rust-test-keys-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("системные часы")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("временный каталог сценариев");
|
||||
fn lua_key_bindings_match_press_release_and_modifiers() {
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, keymap) = install_api(workspace.clone());
|
||||
|
||||
let dir = temp_script_dir("keys");
|
||||
let script = dir.join("keys.lua");
|
||||
std::fs::write(
|
||||
&script,
|
||||
@@ -426,100 +262,80 @@ fn lua_key_bindings_use_physical_keys_and_modifiers() {
|
||||
)
|
||||
.expect("keys.lua");
|
||||
|
||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||
let engine = ScriptEngine::new(api).expect("движок Lua");
|
||||
engine
|
||||
.run_file(&script)
|
||||
.expect("привязки должны назначиться");
|
||||
|
||||
let text = |app: &MyApp| app.editor().active().content.clone();
|
||||
let text = || workspace.borrow().active().content.clone();
|
||||
let stroke = |key: &str, modifiers: gpui_kit::Modifiers| Keystroke {
|
||||
modifiers,
|
||||
key: key.to_owned(),
|
||||
key_char: None,
|
||||
};
|
||||
let fire = |keystroke: &Keystroke, pressed: bool| {
|
||||
for callback in keymap.borrow().callbacks_for(keystroke, pressed) {
|
||||
callback.call::<()>(()).expect("Lua-обработчик");
|
||||
}
|
||||
};
|
||||
|
||||
// Логическая клавиша Z, физическая A: срабатывает именно физическая.
|
||||
step_app(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![key_event(
|
||||
egui::Key::A,
|
||||
egui::Key::Z,
|
||||
true,
|
||||
egui::Modifiers::default(),
|
||||
)],
|
||||
);
|
||||
assert_eq!(text(&app), "a pressed");
|
||||
// Нажатие A.
|
||||
fire(&stroke("a", Modifiers::default()), true);
|
||||
assert_eq!(text(), "a pressed");
|
||||
|
||||
// Shift+A не должна срабатывать как обычная A.
|
||||
step_app(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![key_event(
|
||||
egui::Key::A,
|
||||
egui::Key::A,
|
||||
true,
|
||||
egui::Modifiers {
|
||||
fire(
|
||||
&stroke(
|
||||
"a",
|
||||
Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
)],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert_eq!(text(&app), "a pressed");
|
||||
assert_eq!(text(), "a pressed");
|
||||
|
||||
// Отпускание цифры 1, логическая клавиша при этом другая.
|
||||
step_app(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![key_event(
|
||||
egui::Key::Num1,
|
||||
egui::Key::Num2,
|
||||
false,
|
||||
egui::Modifiers::default(),
|
||||
)],
|
||||
);
|
||||
assert_eq!(text(&app), "1 released");
|
||||
// Отпускание цифры 1.
|
||||
fire(&stroke("1", Modifiers::default()), false);
|
||||
assert_eq!(text(), "1 released");
|
||||
|
||||
step_app(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![key_event(
|
||||
egui::Key::C,
|
||||
egui::Key::C,
|
||||
true,
|
||||
egui::Modifiers {
|
||||
fire(
|
||||
&stroke(
|
||||
"c",
|
||||
Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
)],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert_eq!(text(&app), "shift c");
|
||||
assert_eq!(text(), "shift c");
|
||||
|
||||
step_app(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![key_event(
|
||||
egui::Key::Z,
|
||||
egui::Key::Z,
|
||||
true,
|
||||
egui::Modifiers {
|
||||
ctrl: true,
|
||||
fire(
|
||||
&stroke(
|
||||
"z",
|
||||
Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
},
|
||||
)],
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert_eq!(text(&app), "ctrl z");
|
||||
assert_eq!(text(), "ctrl z");
|
||||
|
||||
// Отпускание R при зажатом Alt.
|
||||
step_app(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![key_event(
|
||||
egui::Key::R,
|
||||
egui::Key::R,
|
||||
false,
|
||||
egui::Modifiers {
|
||||
fire(
|
||||
&stroke(
|
||||
"r",
|
||||
Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
)],
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert_eq!(text(&app), "alt r released");
|
||||
assert_eq!(text(), "alt r released");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -528,119 +344,151 @@ fn lua_key_bindings_use_physical_keys_and_modifiers() {
|
||||
fn language_names_cover_all_supported_grammars() {
|
||||
for (name, expected) in [
|
||||
("rust", Language::Rust),
|
||||
("rs", Language::Rust),
|
||||
("Rust", Language::Rust),
|
||||
("go", Language::Go),
|
||||
("golang", Language::Go),
|
||||
("js", Language::JavaScript),
|
||||
("javascript", Language::JavaScript),
|
||||
("json", Language::Json),
|
||||
("python", Language::Python),
|
||||
("py", Language::Python),
|
||||
] {
|
||||
assert_eq!(Language::from_name(name), Some(expected), "язык {name}");
|
||||
assert_eq!(Language::from_name(&name.to_uppercase()), Some(expected));
|
||||
assert_eq!(Language::from_name(name), Some(expected), "язык {name:?}");
|
||||
}
|
||||
|
||||
// Распространённые короткие имена.
|
||||
assert_eq!(Language::from_name("rs"), Some(Language::Rust));
|
||||
assert_eq!(Language::from_name("golang"), Some(Language::Go));
|
||||
assert_eq!(
|
||||
Language::from_name("javascript"),
|
||||
Some(Language::JavaScript)
|
||||
);
|
||||
assert_eq!(Language::from_name("py"), Some(Language::Python));
|
||||
assert_eq!(Language::from_name("unknown"), None);
|
||||
assert_eq!(Language::from_name(""), None);
|
||||
|
||||
assert_eq!(Language::from_name("cobol"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_sitter_highlights_all_supported_languages() {
|
||||
let samples = [
|
||||
(
|
||||
Language::Rust,
|
||||
"fn main() {\n let x = 42; // комментарий\n println!(\"hi\");\n}\n",
|
||||
HighlightStyle::Keyword,
|
||||
),
|
||||
(
|
||||
Language::Go,
|
||||
"package main\n\nfunc main() {\n\tx := 42 // комментарий\n\t_ = x\n}\n",
|
||||
HighlightStyle::Keyword,
|
||||
),
|
||||
(
|
||||
Language::JavaScript,
|
||||
"function main() {\n const x = 42; // comment\n console.log(\"hi\");\n}\n",
|
||||
HighlightStyle::Keyword,
|
||||
),
|
||||
(
|
||||
Language::Json,
|
||||
"{\"name\": \"value\", \"count\": 42, \"ok\": true}\n",
|
||||
HighlightStyle::Property,
|
||||
),
|
||||
(
|
||||
Language::Python,
|
||||
"def main():\n x = 42 # comment\n print(\"hi\")\n",
|
||||
HighlightStyle::Keyword,
|
||||
),
|
||||
];
|
||||
|
||||
for (language, source, expected) in samples {
|
||||
let mut syntax = SyntaxState::default();
|
||||
let styles: Vec<_> = syntax
|
||||
.spans(language, source)
|
||||
.iter()
|
||||
.map(|span| span.style)
|
||||
.collect();
|
||||
|
||||
assert!(!styles.is_empty(), "{language:?}: подсветка пуста");
|
||||
assert!(
|
||||
styles.contains(&expected),
|
||||
"{language:?}: нет стиля {expected:?} в {styles:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_tree_is_available_for_tasks_beyond_highlighting() {
|
||||
let mut syntax = SyntaxState::default();
|
||||
let tree = syntax
|
||||
.parse(Language::Rust, "fn main() {}\n")
|
||||
.expect("дерево должно разобраться");
|
||||
|
||||
let root = tree.root_node();
|
||||
assert_eq!(root.kind(), "source_file");
|
||||
|
||||
let mut cursor = root.walk();
|
||||
assert!(
|
||||
root.children(&mut cursor)
|
||||
.any(|node| node.kind() == "function_item"),
|
||||
"в дереве должна находиться функция — его можно обходить, а не только красить"
|
||||
);
|
||||
// Канонические имена, которые возвращает API и Lua.
|
||||
assert_eq!(Language::Rust.name(), "rust");
|
||||
assert_eq!(Language::JavaScript.name(), "js");
|
||||
assert_eq!(Language::Python.name(), "python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighting_is_off_by_default_and_enabled_through_api() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = MyApp::default();
|
||||
step_app(&ctx, &mut app, vec![]);
|
||||
let workspace = Rc::new(RefCell::new(Workspace::default()));
|
||||
let (api, _keymap) = install_api(workspace.clone());
|
||||
|
||||
assert!(
|
||||
app.editor()
|
||||
.documents()
|
||||
.iter()
|
||||
.all(|doc| doc.language.is_none()),
|
||||
"подсветка должна быть выключена по умолчанию"
|
||||
assert_eq!(api.language(0), None);
|
||||
assert_eq!(workspace.borrow().documents()[0].language, None);
|
||||
|
||||
assert!(api.set_language(0, "rs"));
|
||||
assert_eq!(api.language(0), Some("rust".to_owned()));
|
||||
assert_eq!(
|
||||
workspace.borrow().documents()[0].language,
|
||||
Some(Language::Rust)
|
||||
);
|
||||
|
||||
let api = AppApi::global();
|
||||
assert!(api.set_language(0, "rust"));
|
||||
assert_eq!(api.language(0).as_deref(), Some("rust"));
|
||||
assert_eq!(app.editor().documents()[0].language, Some(Language::Rust));
|
||||
|
||||
// Неизвестный язык и несуществующая вкладка не меняют состояние.
|
||||
// Неизвестный язык и несуществующая вкладка.
|
||||
assert!(!api.set_language(0, "cobol"));
|
||||
assert!(!api.set_language(99, "rust"));
|
||||
assert_eq!(api.language(0).as_deref(), Some("rust"));
|
||||
|
||||
// Кадр с включённой подсветкой должен отрисовываться без паники.
|
||||
step_app(&ctx, &mut app, vec![]);
|
||||
|
||||
assert!(api.clear_language(0));
|
||||
assert_eq!(api.language(0), None);
|
||||
assert!(!api.clear_language(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keymap_bindings_are_parsed_and_replaced() {
|
||||
let plain = KeyBinding::parse("a").expect("a");
|
||||
let ctrl = KeyBinding::parse("<C-s>").expect("ctrl-s");
|
||||
assert_eq!(plain.modifiers, KeyModifiers::default());
|
||||
assert!(ctrl.modifiers.ctrl);
|
||||
assert!(!ctrl.on_release);
|
||||
|
||||
// Повторное назначение той же клавиши не увеличивает число привязок:
|
||||
// проверить это можно только через KeyMap, поэтому создаём пару функций.
|
||||
let lua = mlua::Lua::new();
|
||||
let mut keymap = KeyMap::default();
|
||||
let first = lua
|
||||
.load("return function() end")
|
||||
.eval::<mlua::Function>()
|
||||
.unwrap();
|
||||
let second = lua
|
||||
.load("return function() end")
|
||||
.eval::<mlua::Function>()
|
||||
.unwrap();
|
||||
keymap.bind(plain.clone(), first);
|
||||
keymap.bind(plain, second);
|
||||
|
||||
let stroke = Keystroke {
|
||||
modifiers: Modifiers::default(),
|
||||
key: "a".to_owned(),
|
||||
key_char: None,
|
||||
};
|
||||
assert_eq!(keymap.callbacks_for(&stroke, true).len(), 1);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn lua_key_bindings_receive_keys_while_editor_is_focused(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let mut workspace = None;
|
||||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||
let app = MyApp::new(window, cx);
|
||||
workspace = Some(app.workspace_handle());
|
||||
app
|
||||
});
|
||||
let workspace = workspace.expect("вид создан");
|
||||
|
||||
// Регистрируем привязку через Lua — так же, как это делает init.lua.
|
||||
let dir = temp_script_dir("ui-keys");
|
||||
let script = dir.join("keys.lua");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"app:map_key("<A-r>", function() app:set_active_text("alt r pressed") end)"#,
|
||||
)
|
||||
.expect("keys.lua");
|
||||
let engine = ScriptEngine::new(AppApi::global()).expect("движок Lua");
|
||||
engine.run_file(&script).expect("привязка");
|
||||
|
||||
visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
this.editor_handle()
|
||||
.update(cx, |state, cx| state.focus(window, cx));
|
||||
});
|
||||
});
|
||||
|
||||
// Событие клавиатуры идёт через фокус редактора вверх до корня вида,
|
||||
// где его видит KeyMap.
|
||||
visual.simulate_keystrokes("alt-r");
|
||||
visual.run_until_parked();
|
||||
|
||||
assert_eq!(workspace.borrow().active().content, "alt r pressed");
|
||||
|
||||
drop(engine);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
|
||||
cx.update(gpui_kit::init);
|
||||
|
||||
let mut workspace = None;
|
||||
let (app, visual) = cx.add_window_view(|window, cx| {
|
||||
let app = MyApp::new(window, cx);
|
||||
workspace = Some(app.workspace_handle());
|
||||
app
|
||||
});
|
||||
let workspace = workspace.expect("вид создан");
|
||||
|
||||
// Фокус нужен до первого ввода: simulate_input отправляет текст в
|
||||
// сфокусированный ввод.
|
||||
visual.update(|window, cx| {
|
||||
app.update(cx, |this, cx| {
|
||||
this.editor_handle()
|
||||
.update(cx, |state, cx| state.focus(window, cx));
|
||||
});
|
||||
});
|
||||
visual.simulate_input("ZZZ");
|
||||
|
||||
let state = workspace.borrow();
|
||||
let doc = state.active();
|
||||
assert!(
|
||||
doc.content.contains("ZZZ"),
|
||||
"введённый текст не попал в документ: {:?}",
|
||||
doc.content
|
||||
);
|
||||
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user