gmod-rs/crates/lua/src/ui.rs
Niko Marmeladkov a3b884b7c6 Q-меню: hold/pin, кэш иконок, размер окна и сохранение скорости
Иконки: видимая сетка оставалась белыми заглушками, потому что при
бюджете 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 до/после.
2026-10-24 22:56:26 +03:00

637 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Слой 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);
}
}