Иконки: видимая сетка оставалась белыми заглушками, потому что при бюджете 8 мс на кадр успевало загрузиться всего 1-2 превью. Бюджет поднят до 12 штук / 40 мс, кэш увеличен с 512 до 1024 текстур и живёт весь сеанс (LRU, ключ — нормализованный путь модели). Добавлены IconStats (cached/rendered/reused/evicted/failed) и логи при открытии, закрытии и выходе: повторное открытие показывает rendered_since_open=0. Размер меню: прогрев до создания окна задавал корню 1280×720, а ui_set_screen не обновлял размер (Rust звал несуществующий __sr_resize_to_screen). ui.set_screen/ui.sync_root_size теперь растягивают g_SpawnMenu на ScrW×ScrH при открытии, resize и раскладке. Скорость: при открытии меню больше не обнуляются velocity/sprint. Игрок заморожен (update не вызывается), но импульс сохраняется: до открытия и после закрытия speed одинаковый, включая noclip. Hold/pin Q: Q down открывает, Q up закрывает только без взаимодействий. Клик, колесо, касание и ввод закрепляют меню (pinned), после чего Q работает как toggle. Esc закрывает всегда. Для текстовых полей добавлены ui.focus/RequestFocus/GetKeyboardFocus, SDL3 TextInput, Backspace/стрелки/ Enter и input.IsKeyTrapping: в фокусе Q печатается, а не закрывает меню. Попутно исправлена передача аргументов hook.Run: колбэки hook.Add больше не получают имя хука первым аргументом, как в GMod. Из-за этого OnTextEntryGetFocus падал на pnl:HasParent, и StartKeyFocus не вызывался. Проверки: cargo fmt, clippy -D warnings, cargo test --workspace (28 сьютов) зелёные. Кадры 1280×720 и 1600×900: images=88/104, placeholders=0; xdotool: hold open/close, клик+keyup, ввод q, повторный Q, скорость 250.45 до/после.
637 lines
25 KiB
Rust
637 lines
25 KiB
Rust
//! Слой VGUI/Derma для Lua-скриптов Sandbox.
|
||
//!
|
||
//! Rust-сторона этого модуля намеренно тонкая: панели, раскладка и `Paint`
|
||
//! живут в Lua (`bootstrap.lua`), потому что скрипты игры создают и рисуют их
|
||
//! сами. Здесь только:
|
||
//!
|
||
//! - снимок кадра [`UiCommand`] для рендера (не больше [`MAX_COMMANDS`]);
|
||
//! - открытие/закрытие корневого меню и состояние видимости;
|
||
//! - передача мыши в дерево панелей.
|
||
//!
|
||
//! `__sr_ui.snapshot()` — настоящий обход панелей из Lua: он вызывает
|
||
//! `Panel:Paint(w, h)`, а `surface.DrawRect`/`draw.SimpleText` складывают
|
||
//! команды в список. Rust лишь читает этот список и превращает его в
|
||
//! [`UiCommand`], отбрасывая всё, что выходит за лимиты.
|
||
|
||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||
|
||
use mlua::{Function, Lua, Table, Value};
|
||
|
||
use crate::LuaError;
|
||
|
||
/// Максимум команд в одном кадре. Скрипт-аддон не должен уметь залить
|
||
/// рендер миллионами прямоугольников (правило #1 AGENTS.md).
|
||
pub const MAX_COMMANDS: usize = 4096;
|
||
|
||
/// Максимум аргументов одного снимка — тот же предел, но отдельной
|
||
/// константой, чтобы рендер мог заранее выделить буфер.
|
||
pub const MAX_UI_DEPTH: usize = 32;
|
||
|
||
/// Что рисует команда.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum UiKind {
|
||
/// Залитый прямоугольник (`surface.DrawRect`).
|
||
Rect,
|
||
/// Контур (`surface.DrawOutlinedRect`).
|
||
Outline,
|
||
/// Текст (`draw.SimpleText`/`draw.DrawText`).
|
||
Text,
|
||
/// Изображение: 3D-превью модели (`ModelImage`/`SpawnIcon`).
|
||
Image,
|
||
}
|
||
|
||
/// Максимальная длина пути модели в команде. Реальные пути MDL короче;
|
||
/// ограничение не даёт скрипту прислать строку на мегабайт.
|
||
pub const MAX_MODEL_PATH: usize = 260;
|
||
|
||
/// Одна команда кадра в пикселях экрана.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct UiCommand {
|
||
pub kind: UiKind,
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub w: f32,
|
||
pub h: f32,
|
||
/// RGBA, компоненты 0..1.
|
||
pub color: [f32; 4],
|
||
pub text: Option<String>,
|
||
/// Путь `models/...mdl` для [`UiKind::Image`].
|
||
pub model: Option<String>,
|
||
/// Прямоугольник обрезки `[x0, y0, x1, y1]` в пикселях экрана.
|
||
///
|
||
/// Снимок берёт его из границ панели-родителя: длинная подпись дерева
|
||
/// не должна вылезать на соседнюю панель. `None` — без обрезки.
|
||
pub clip: Option<[f32; 4]>,
|
||
}
|
||
|
||
/// Устанавливает `__sr_ui` и базовый Lua-слой (панели, surface, draw).
|
||
pub(crate) fn install(lua: &Lua) -> Result<(), mlua::Error> {
|
||
let ui = lua.create_table()?;
|
||
ui.set("visible", false)?;
|
||
ui.set("recording", false)?;
|
||
ui.set("commands", Value::Nil)?;
|
||
ui.set("command_limit", MAX_COMMANDS)?;
|
||
ui.set("depth_limit", MAX_UI_DEPTH)?;
|
||
ui.set("screen_w", 1280.0)?;
|
||
ui.set("screen_h", 720.0)?;
|
||
ui.set("mouse_x", 0.0)?;
|
||
ui.set("mouse_y", 0.0)?;
|
||
ui.set("pressed", Value::Nil)?;
|
||
ui.set("root", Value::Nil)?;
|
||
ui.set("paint_errors", 0)?;
|
||
// Грязный флаг: снимок пересобирается только после реального изменения
|
||
// состояния (ввод, таймер, сеттеры панелей), а не по таймеру кадров.
|
||
ui.set("dirty", true)?;
|
||
ui.set("layout_dirty", true)?;
|
||
ui.set("animating", false)?;
|
||
ui.set("animated_frame", false)?;
|
||
ui.set("hovered", Value::Nil)?;
|
||
ui.set("capture", Value::Nil)?;
|
||
ui.set("hover_chain", Value::Nil)?;
|
||
lua.globals().set("__sr_ui", &ui)?;
|
||
|
||
// Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором.
|
||
let chunk = lua
|
||
.load(include_str!("ui/bootstrap.lua"))
|
||
.set_name("ui/bootstrap.lua");
|
||
chunk.exec()?;
|
||
Ok(())
|
||
}
|
||
|
||
impl crate::LuaRuntime {
|
||
/// Открыть или закрыть корневое меню (Sandbox spawnmenu).
|
||
///
|
||
/// Если меню ещё не создано скриптами, состояние всё равно запоминается:
|
||
/// когда `OnGamemodeLoaded` построит `g_SpawnMenu`, он откроется.
|
||
pub fn ui_toggle(&self, open: bool) {
|
||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||
let Ok(ui) = self.lua.globals().get::<Table>("__sr_ui") else {
|
||
return;
|
||
};
|
||
let Ok(toggle) = ui.get::<Function>("toggle") else {
|
||
return;
|
||
};
|
||
let _ = toggle.call::<()>(open);
|
||
}));
|
||
}
|
||
|
||
/// Видимо ли корневое меню на текущий кадр.
|
||
pub fn ui_visible(&self) -> bool {
|
||
catch_unwind(AssertUnwindSafe(|| {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
ui.get::<bool>("visible")
|
||
}))
|
||
.ok()
|
||
.and_then(Result::ok)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Нужен ли новый снимок панелей: Lua выставляет флаг при вводе,
|
||
/// срабатывании таймера и изменении свойств панели.
|
||
pub fn ui_dirty(&self) -> bool {
|
||
catch_unwind(AssertUnwindSafe(|| {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
ui.get::<bool>("dirty")
|
||
}))
|
||
.ok()
|
||
.and_then(Result::ok)
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// Прокачивает таймеры Lua вне снимка: тогда их изменения поднимают
|
||
/// dirty-флаг заранее, а не сваливаются в один дорогой снимок по
|
||
/// страховочному таймеру.
|
||
pub fn ui_tick(&self) {
|
||
let _ = catch_unwind(AssertUnwindSafe(|| -> Result<(), LuaError> {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
let pump: Function = ui.get("pump")?;
|
||
pump.call::<()>(())?;
|
||
Ok(())
|
||
}));
|
||
}
|
||
|
||
/// Забирает ошибки Paint/PerformLayout/ввода, накопленные Lua-слоем.
|
||
///
|
||
/// Ошибка одной панели не должна ронять меню и кадр: клиент показывает
|
||
/// их в консоли и продолжает рисовать остальные.
|
||
pub fn ui_take_errors(&self) -> Vec<String> {
|
||
catch_unwind(AssertUnwindSafe(|| -> Option<Vec<String>> {
|
||
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
|
||
let take: Function = ui.get("take_errors").ok()?;
|
||
let Value::Table(list) = take.call::<Value>(()).ok()? else {
|
||
return None;
|
||
};
|
||
let mut errors = Vec::new();
|
||
for pair in list.sequence_values::<Value>() {
|
||
if errors.len() >= 64 {
|
||
break;
|
||
}
|
||
if let Ok(Value::String(text)) = pair
|
||
&& let Ok(text) = text.to_str()
|
||
{
|
||
errors.push(text.to_string());
|
||
}
|
||
}
|
||
Some(errors)
|
||
}))
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Снимок видимого дерева панелей на текущий кадр.
|
||
///
|
||
/// Пустой список, если меню закрыто или скрипты ещё не создали панели.
|
||
/// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`]
|
||
/// (проверяется в Lua, здесь — вторая линия защиты).
|
||
pub fn ui_snapshot(&self) -> Vec<UiCommand> {
|
||
let mut commands = Vec::new();
|
||
self.ui_snapshot_into(&mut commands);
|
||
commands
|
||
}
|
||
|
||
/// То же, что [`Self::ui_snapshot`], но переиспользует буфер вызывающего:
|
||
/// рендер снимает меню каждый кадр, и лишние аллокации ему не нужны.
|
||
/// Буфер очищается даже при ошибке Lua.
|
||
pub fn ui_snapshot_into(&self, commands: &mut Vec<UiCommand>) {
|
||
commands.clear();
|
||
let result = catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner(commands)));
|
||
if result.is_err() {
|
||
commands.clear();
|
||
}
|
||
}
|
||
|
||
fn ui_snapshot_inner(&self, commands: &mut Vec<UiCommand>) {
|
||
// Отложенные `timer.Simple(0, ...)` создают вкладки и контент
|
||
// spawnmenu; без этого шага снимок был бы пустым деревом.
|
||
let _ = self.tick();
|
||
let Ok(ui) = self.lua.globals().get::<Table>("__sr_ui") else {
|
||
return;
|
||
};
|
||
let Ok(snapshot) = ui.get::<Function>("snapshot") else {
|
||
return;
|
||
};
|
||
let Ok(Value::Table(list)) = snapshot.call::<Value>(()) else {
|
||
return;
|
||
};
|
||
for pair in list.sequence_values::<Value>() {
|
||
if commands.len() >= MAX_COMMANDS {
|
||
break;
|
||
}
|
||
let Ok(Value::Table(entry)) = pair else {
|
||
continue;
|
||
};
|
||
let Ok(kind) = entry.get::<String>("kind") else {
|
||
continue;
|
||
};
|
||
let kind = match kind.as_str() {
|
||
"rect" => UiKind::Rect,
|
||
"outline" => UiKind::Outline,
|
||
"text" => UiKind::Text,
|
||
"image" => UiKind::Image,
|
||
_ => continue,
|
||
};
|
||
let number = |key: &str| -> f32 {
|
||
entry
|
||
.get::<Value>(key)
|
||
.ok()
|
||
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
|
||
.filter(|value| value.is_finite())
|
||
.unwrap_or(0.0) as f32
|
||
};
|
||
let color = entry
|
||
.get::<Value>("color")
|
||
.ok()
|
||
.and_then(|value| value.as_table().cloned())
|
||
.map(|color| {
|
||
let component = |index: usize| -> f32 {
|
||
color
|
||
.get::<Value>(index)
|
||
.ok()
|
||
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
|
||
.filter(|value| value.is_finite())
|
||
.unwrap_or(0.0)
|
||
.clamp(0.0, 1.0) as f32
|
||
};
|
||
[component(1), component(2), component(3), component(4)]
|
||
})
|
||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||
let text = entry
|
||
.get::<Option<String>>("text")
|
||
.ok()
|
||
.flatten()
|
||
.map(|text| {
|
||
// Текст рисуется на экране: длиннее пары тысяч символов
|
||
// он всё равно не влезет, а память съест.
|
||
if text.chars().count() > 2048 {
|
||
text.chars().take(2048).collect()
|
||
} else {
|
||
text
|
||
}
|
||
});
|
||
let model = entry
|
||
.get::<Option<String>>("model")
|
||
.ok()
|
||
.flatten()
|
||
.map(|model| {
|
||
let model = model.trim().replace('\\', "/");
|
||
model.chars().take(MAX_MODEL_PATH).collect::<String>()
|
||
})
|
||
.filter(|model| !model.is_empty());
|
||
let clip = entry
|
||
.get::<Value>("clip")
|
||
.ok()
|
||
.and_then(|value| value.as_table().cloned())
|
||
.and_then(|clip| {
|
||
let component = |index: usize| -> Option<f32> {
|
||
clip.get::<Value>(index)
|
||
.ok()
|
||
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
|
||
.map(|value| value as f32)
|
||
.filter(|value| value.is_finite())
|
||
};
|
||
let rect = [component(1)?, component(2)?, component(3)?, component(4)?];
|
||
(rect[2] > rect[0] && rect[3] > rect[1]).then_some(rect)
|
||
});
|
||
commands.push(UiCommand {
|
||
kind,
|
||
x: number("x"),
|
||
y: number("y"),
|
||
w: number("w"),
|
||
h: number("h"),
|
||
color,
|
||
text,
|
||
model,
|
||
clip,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Передаёт мышь в Lua-дерево панелей.
|
||
///
|
||
/// `kind`: `"move"`, `"down"`, `"up"`, `"wheel"`; `x`/`y` — пиксели от
|
||
/// левого верхнего угла экрана; `button` — 1 (ЛКМ) или 2 (ПКМ), для
|
||
/// колеса — направление (+1 вверх, -1 вниз).
|
||
pub fn ui_mouse(&self, kind: &str, x: f32, y: f32, button: i32) {
|
||
let _ = catch_unwind(AssertUnwindSafe(|| -> Result<(), LuaError> {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
let mouse: Function = ui.get("mouse")?;
|
||
mouse.call::<()>((kind.to_string(), x, y, button))?;
|
||
Ok(())
|
||
}));
|
||
}
|
||
|
||
/// Задаёт размер экрана для `ScrW()`/`ScrH()` и раскладки.
|
||
///
|
||
/// Рендер-агент вызывает это при изменении окна; по умолчанию 1280×720.
|
||
/// Размер корня меню синхронизирует Lua-половина (`ui.set_screen`): иначе
|
||
/// после прогрева до реального размера окна меню оставалось маленьким.
|
||
pub fn ui_set_screen(&self, width: u32, height: u32) {
|
||
let _ = catch_unwind(AssertUnwindSafe(|| -> mlua::Result<()> {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
let width = width.clamp(1, 16384);
|
||
let height = height.clamp(1, 16384);
|
||
ui.set("screen_w", width as f64)?;
|
||
ui.set("screen_h", height as f64)?;
|
||
if let Ok(set_screen) = ui.get::<Function>("set_screen") {
|
||
set_screen.call::<()>((width as f64, height as f64))?;
|
||
} else {
|
||
ui.set("dirty", true)?;
|
||
ui.set("layout_dirty", true)?;
|
||
}
|
||
Ok(())
|
||
}));
|
||
}
|
||
|
||
/// Есть ли сейчас фокус в текстовом поле (`DTextEntry`).
|
||
///
|
||
/// Клиент использует это, чтобы Q печатался в поле, а не закрывал меню,
|
||
/// как `input.IsKeyTrapping()` в GMod.
|
||
pub fn ui_text_focus(&self) -> bool {
|
||
catch_unwind(AssertUnwindSafe(|| {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
let focus: Function = ui.get("text_focus")?;
|
||
focus.call::<bool>(())
|
||
}))
|
||
.ok()
|
||
.and_then(Result::ok)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Передаёт набранный текст в панель с клавиатурным фокусом.
|
||
///
|
||
/// Возвращает true, если текст принят (значит, пользователь взаимо-
|
||
/// действует с меню и его нужно закрепить).
|
||
pub fn ui_text_input(&self, text: &str) -> bool {
|
||
catch_unwind(AssertUnwindSafe(|| {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
let input: Function = ui.get("text_input")?;
|
||
input.call::<bool>(text.to_string())
|
||
}))
|
||
.ok()
|
||
.and_then(Result::ok)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Передаёт служебную клавишу (Backspace/Delete/стрелки/Enter) в поле.
|
||
pub fn ui_key(&self, name: &str) -> bool {
|
||
catch_unwind(AssertUnwindSafe(|| {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
let key: Function = ui.get("key")?;
|
||
key.call::<bool>((name.to_string(), true))
|
||
}))
|
||
.ok()
|
||
.and_then(Result::ok)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Снимает клавиатурный фокус (закрытие меню).
|
||
pub fn ui_clear_focus(&self) {
|
||
let _ = catch_unwind(AssertUnwindSafe(|| -> mlua::Result<()> {
|
||
let ui: Table = self.lua.globals().get("__sr_ui")?;
|
||
if let Ok(clear) = ui.get::<Function>("clear_focus") {
|
||
clear.call::<()>(())?;
|
||
}
|
||
Ok(())
|
||
}));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use crate::LuaRuntime;
|
||
|
||
fn runtime() -> LuaRuntime {
|
||
LuaRuntime::new().expect("рантайм")
|
||
}
|
||
|
||
#[test]
|
||
fn ui_starts_closed_and_empty() {
|
||
let rt = runtime();
|
||
assert!(!rt.ui_visible());
|
||
assert!(rt.ui_snapshot().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_records_panels_and_text() {
|
||
let rt = runtime();
|
||
// Тестовая панель: рисует фон и текст, как настоящий DLabel.
|
||
rt.run(
|
||
"ui_test.lua",
|
||
br#"
|
||
local p = vgui.Create( "Panel" )
|
||
p:SetPos( 10, 20 )
|
||
p:SetSize( 200, 50 )
|
||
function p:Paint( w, h )
|
||
surface.SetDrawColor( 255, 0, 0, 255 )
|
||
surface.DrawRect( 0, 0, w, h )
|
||
draw.SimpleText( "Hello", "DermaDefault", 5, 5, Color( 255, 255, 255, 255 ) )
|
||
end
|
||
__sr_ui.root = p
|
||
__sr_ui.visible = true
|
||
"#,
|
||
)
|
||
.expect("панель создана");
|
||
let commands = rt.ui_snapshot();
|
||
assert!(commands.len() >= 2, "команды: {commands:?}");
|
||
assert!(
|
||
commands
|
||
.iter()
|
||
.any(|command| command.kind == crate::ui::UiKind::Rect)
|
||
);
|
||
assert!(
|
||
commands
|
||
.iter()
|
||
.any(|command| command.text.as_deref() == Some("Hello"))
|
||
);
|
||
// Координаты детей складываются с позицией родителя.
|
||
let text = commands
|
||
.iter()
|
||
.find(|command| command.text.is_some())
|
||
.expect("текст");
|
||
assert!((text.x - 15.0).abs() < 0.01, "x={}", text.x);
|
||
assert!((text.y - 25.0).abs() < 0.01, "y={}", text.y);
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_into_reuses_the_buffer() {
|
||
let rt = runtime();
|
||
rt.run(
|
||
"ui_reuse.lua",
|
||
br#"
|
||
local p = vgui.Create( "Panel" )
|
||
p:SetSize( 100, 50 )
|
||
function p:Paint( w, h )
|
||
surface.DrawRect( 0, 0, w, h )
|
||
draw.SimpleText( "Hi", "DermaDefault", 0, 0, color_white )
|
||
end
|
||
__sr_ui.root = p
|
||
__sr_ui.visible = true
|
||
"#,
|
||
)
|
||
.expect("панель создана");
|
||
let mut commands = Vec::new();
|
||
rt.ui_snapshot_into(&mut commands);
|
||
let first = commands.len();
|
||
assert!(first >= 2, "команды: {commands:?}");
|
||
rt.ui_snapshot_into(&mut commands);
|
||
assert_eq!(commands.len(), first, "буфер не должен накапливать команды");
|
||
}
|
||
|
||
#[test]
|
||
fn ui_errors_are_taken_once() {
|
||
let rt = runtime();
|
||
rt.run("ui_error.lua", br#"__sr_ui.log_error( "boom" )"#)
|
||
.expect("ошибка записана");
|
||
assert_eq!(rt.ui_take_errors(), vec!["boom".to_string()]);
|
||
assert!(rt.ui_take_errors().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn model_image_becomes_image_command_with_clip() {
|
||
let rt = runtime();
|
||
// Снимок панелей подменяем таблицей: проверяем разбор контракта
|
||
// (image + model + clip), не поднимая весь VGUI/Derma.
|
||
rt.run(
|
||
"ui_image.lua",
|
||
br#"
|
||
__sr_ui.visible = true
|
||
__sr_ui.snapshot = function()
|
||
return {
|
||
{
|
||
kind = "image",
|
||
x = 10, y = 20, w = 64, h = 64,
|
||
color = { 1, 1, 1, 1 },
|
||
model = "models/props_c17/oildrum001.mdl",
|
||
clip = { 0, 0, 200, 100 },
|
||
},
|
||
{
|
||
kind = "rect",
|
||
x = 0, y = 0, w = 10, h = 10,
|
||
color = { 0.5, 0.5, 0.5, 1 },
|
||
clip = { 500, 500, 100, 100 },
|
||
},
|
||
}
|
||
end
|
||
"#,
|
||
)
|
||
.expect("снимок задан");
|
||
let commands = rt.ui_snapshot();
|
||
let image = commands
|
||
.iter()
|
||
.find(|command| command.kind == crate::ui::UiKind::Image)
|
||
.expect("команда image");
|
||
assert_eq!(
|
||
image.model.as_deref(),
|
||
Some("models/props_c17/oildrum001.mdl")
|
||
);
|
||
assert!(image.text.is_none());
|
||
assert_eq!(image.clip, Some([0.0, 0.0, 200.0, 100.0]));
|
||
// Инвертированный прямоугольник обрезки отбрасывается, а не режет
|
||
// всё подряд.
|
||
let rect = commands
|
||
.iter()
|
||
.find(|command| command.kind == crate::ui::UiKind::Rect)
|
||
.expect("команда rect");
|
||
assert_eq!(rect.clip, None);
|
||
}
|
||
|
||
#[test]
|
||
fn screen_size_resizes_root_to_full_window() {
|
||
let rt = runtime();
|
||
rt.run(
|
||
"ui_root_size.lua",
|
||
br#"
|
||
local p = vgui.Create( "Panel" )
|
||
p.__sr_fullscreen_root = true
|
||
__sr_ui.root = p
|
||
__sr_ui.visible = true
|
||
"#,
|
||
)
|
||
.expect("корень создан");
|
||
rt.ui_set_screen(1600, 900);
|
||
let size = rt
|
||
.probe("return tostring(__sr_ui.root:GetWide()) .. 'x' .. tostring(__sr_ui.root:GetTall())")
|
||
.expect("probe")
|
||
.unwrap_or_default();
|
||
assert_eq!(size, "1600x900", "корень меню обязан занять всё окно");
|
||
// Смена размера окна тоже двигает корень: раньше он застревал в
|
||
// размере прогрева (1280×720) и меню выглядело маленьким.
|
||
rt.ui_set_screen(1024, 768);
|
||
let size = rt
|
||
.probe("return tostring(__sr_ui.root:GetWide()) .. 'x' .. tostring(__sr_ui.root:GetTall())")
|
||
.expect("probe")
|
||
.unwrap_or_default();
|
||
assert_eq!(size, "1024x768");
|
||
}
|
||
|
||
#[test]
|
||
fn text_entry_focus_takes_input_and_blocks_menu_key() {
|
||
let rt = runtime();
|
||
rt.run(
|
||
"ui_text_input.lua",
|
||
br#"
|
||
rawset( _G, "g_SpawnMenu", nil )
|
||
local p = vgui.Create( "Panel" )
|
||
p:SetSize( 300, 100 )
|
||
local entry = vgui.Create( "TextEntry", p )
|
||
entry:SetPos( 10, 10 )
|
||
entry:SetSize( 200, 20 )
|
||
_G.__test_entry = entry
|
||
__sr_ui.root = p
|
||
__sr_ui.visible = true
|
||
__sr_ui.mark_dirty( true )
|
||
"#,
|
||
)
|
||
.expect("поле создано");
|
||
let _ = rt.ui_snapshot();
|
||
assert!(!rt.ui_text_focus(), "без клика фокуса быть не должно");
|
||
rt.ui_mouse("move", 20.0, 20.0, 0);
|
||
rt.ui_mouse("down", 20.0, 20.0, 1);
|
||
rt.ui_mouse("up", 20.0, 20.0, 1);
|
||
assert!(rt.ui_text_focus(), "клик по полю не дал фокус");
|
||
assert!(
|
||
rt.probe("return tostring(input.IsKeyTrapping())")
|
||
.expect("probe")
|
||
.as_deref()
|
||
== Some("true"),
|
||
"пока поле в фокусе, Q должен печататься, а не закрывать меню"
|
||
);
|
||
assert!(rt.ui_text_input("q"));
|
||
assert_eq!(
|
||
rt.probe("return __test_entry:GetText()")
|
||
.expect("probe")
|
||
.as_deref(),
|
||
Some("q")
|
||
);
|
||
assert!(rt.ui_key("backspace"));
|
||
assert_eq!(
|
||
rt.probe("return __test_entry:GetText()")
|
||
.expect("probe")
|
||
.as_deref(),
|
||
Some("")
|
||
);
|
||
rt.ui_clear_focus();
|
||
assert!(!rt.ui_text_focus());
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_respects_command_limit() {
|
||
let rt = runtime();
|
||
rt.run(
|
||
"ui_limit.lua",
|
||
br#"
|
||
local p = vgui.Create( "Panel" )
|
||
p:SetSize( 100, 100 )
|
||
function p:Paint( w, h )
|
||
for i = 1, 100000 do surface.DrawRect( 0, 0, 1, 1 ) end
|
||
end
|
||
__sr_ui.root = p
|
||
__sr_ui.visible = true
|
||
"#,
|
||
)
|
||
.expect("панель создана");
|
||
let commands = rt.ui_snapshot();
|
||
assert_eq!(commands.len(), crate::ui::MAX_COMMANDS);
|
||
}
|
||
}
|