Q-меню: dirty-снимок, слайдеры, колесо и выравнивание текста

Снимок Derma пересобирается по dirty-флагу (ввод, таймеры, сеттеры), а не раз в 6 кадров: в покое 0.26-0.5 мс вместо 4-6 мс, без пиков 47-84 мс. Невидимые поддеревья и обрезанные команды не попадают в снимок; сортировка детей без ZPos не аллоцирует; hover не перезапускает раскладку. Меню прогревается на загрузке карты.

Слайдеры: загружается dnumberscratch.lua, добавлены NoClipping/GetCaretPos/SetCaretPos/input.SetCursorPos, OnCursorMoved получает локальные координаты, MouseCapture удерживает перетаскивание; есть fallback DSlider/DNumSlider/DNumberScratch.

Колесо всплывает от иконки/кнопки до DScrollPanel/DTree; починен корень DTree (высота ChildNodes до VBar). Клик/колесо проверяются тестами и отладочными SOURCE_RS_UI_CLICK/WHEEL.

Текст: метрики surface.GetTextSize совпадают с bitmap-рендером, вертикаль по высоте глифа, ContentAlignment как в GMod, вкладки центрируются, дерево слева с отступом, заглушки иконок - две строки по центру с обрезкой.

Иконки: очередь видимых моделей по площади, до 8/~8 мс за кадр, LRU-кэш переиспользуется.
This commit is contained in:
Niko Marmeladkov 2026-10-24 20:35:56 +03:00
parent 58daab03be
commit 1b24f280d3
8 changed files with 1070 additions and 147 deletions

View file

@ -551,6 +551,21 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
if lua_ui_ready { if lua_ui_ready {
log.push_back("ui: Lua Sandbox".to_string()); log.push_back("ui: Lua Sandbox".to_string());
info!("Q-меню: настоящий Sandbox из Lua"); info!("Q-меню: настоящий Sandbox из Lua");
// Прогрев: таймеры spawnmenu создают вкладки и содержимое.
// Делаем это на загрузке карты, а не на первом Q, иначе
// первый кадр меню стоит ~100 мс.
if std::env::var_os("SOURCE_RS_UI_NO_PREWARM").is_none() {
let started = Instant::now();
lua.ui_toggle(true);
let mut warm = Vec::new();
lua.ui_snapshot_into(&mut warm);
lua.ui_toggle(false);
info!(
elapsed_ms = started.elapsed().as_secs_f64() * 1000.0,
commands = warm.len(),
"Lua UI: меню прогрето"
);
}
} else { } else {
log.push_back("ui: fallback Q-меню".to_string()); log.push_back("ui: fallback Q-меню".to_string());
warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню"); warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню");
@ -774,12 +789,29 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw)); pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw));
} }
// `SOURCE_RS_UI_CLICK` — отладочный клик по Lua-меню: «x,y» или текст // `SOURCE_RS_UI_CLICK` — отладочный клик по Lua-меню: «x,y» или текст
// вкладки/модели. Нужен, потому что скрытое окно не получает xdotool. // вкладки/модели. Несколько целей разделяются `;` и кликаются по очереди
let ui_click = std::env::var("SOURCE_RS_UI_CLICK") // (например, «Weapons;barrel»). Нужен, потому что скрытое окно не
// получает xdotool.
let ui_clicks: Vec<String> = std::env::var("SOURCE_RS_UI_CLICK")
.ok() .ok()
.map(|value| value.trim().to_string()) .map(|value| {
.filter(|value| !value.is_empty()); value
let mut ui_click_done = false; .split(';')
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect()
})
.unwrap_or_default();
let mut ui_click_index = 0usize;
// `SOURCE_RS_UI_WHEEL=x,y,delta` — колесо после отладочных кликов.
let ui_wheel = std::env::var("SOURCE_RS_UI_WHEEL").ok().and_then(|value| {
let mut parts = value.split(',').map(|part| part.trim().parse::<f32>());
let x = parts.next()?.ok()?;
let y = parts.next()?.ok()?;
let delta = parts.next()?.ok()? as i32;
Some((x, y, delta))
});
let mut ui_wheel_done = false;
// `SOURCE_RS_UI_CLOSE=<кадр>` — закрыть меню после отладочного клика, // `SOURCE_RS_UI_CLOSE=<кадр>` — закрыть меню после отладочного клика,
// чтобы на скриншоте был виден заспавненный проп, а не только панели. // чтобы на скриншоте был виден заспавненный проп, а не только панели.
let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE") let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE")
@ -792,6 +824,16 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Снимок панелей Derma — дорогой (Lua + строки), поэтому обновляем его // Снимок панелей Derma — дорогой (Lua + строки), поэтому обновляем его
// не каждый кадр: по событиям ввода и раз в несколько кадров для анимаций. // не каждый кадр: по событиям ввода и раз в несколько кадров для анимаций.
let mut ui_snapshot_age = u32::MAX; let mut ui_snapshot_age = u32::MAX;
// `SOURCE_RS_UI_PROFILE=1` — раз в 120 кадров печатать стоимость снимка,
// загрузки иконок и отрисовки. Без цифр «меню лагает» — только ощущение.
let ui_profile = std::env::var_os("SOURCE_RS_UI_PROFILE").is_some();
let mut profile_snapshot_us: u128 = 0;
let mut profile_icons_us: u128 = 0;
let mut profile_draw_us: u128 = 0;
let mut profile_snapshot_max_us: u128 = 0;
let mut profile_draw_max_us: u128 = 0;
let mut profile_frames: u32 = 0;
let mut profile_icon_loads: usize = 0;
let mut lua_ui_open = false; let mut lua_ui_open = false;
// Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик). // Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик).
let mut ui_touch_finger: Option<u64> = None; let mut ui_touch_finger: Option<u64> = None;
@ -1161,8 +1203,8 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
} }
} }
Event::MouseMotion { x, y, .. } if lua_ui_open => { Event::MouseMotion { x, y, .. } if lua_ui_open => {
ui_snapshot_age = u32::MAX; // Настоящее меню само решает, какая панель под курсором;
// Настоящее меню само решает, какая панель под курсором. // снимок закажет только смена hover или перетаскивание.
if let Some(lua) = &lua { if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0); lua.ui_mouse("move", x, y, 0);
} }
@ -1296,29 +1338,37 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
} }
} }
// Отладочный клик по снимку прошлого кадра: вкладка, категория или // Отладочные клики по снимку прошлого кадра: вкладка, категория или
// модель. Скрытое окно не получает внешних кликов, поэтому проверка // модель. Скрытое окно не получает внешних кликов, поэтому проверка
// спавна идёт тем же `ui_mouse`, что и настоящая мышь. // идёт тем же `ui_mouse`, что и настоящая мышь.
if lua_ui_open if lua_ui_open
&& !ui_click_done && ui_click_index < ui_clicks.len()
&& frames_drawn >= 8 && frames_drawn >= 8 + ui_click_index as u64 * 8
&& let Some(spec) = ui_click.as_deref()
{ {
ui_click_done = true; let spec = ui_clicks[ui_click_index].clone();
match ui_overlay::click_point(&ui_commands, spec) { ui_click_index += 1;
match ui_overlay::click_point(&ui_commands, &spec) {
Some((x, y)) => { Some((x, y)) => {
info!(spec, x, y, "Lua UI: отладочный клик"); info!(spec = %spec, x, y, "Lua UI: отладочный клик");
if let Some(lua) = &lua { if let Some(lua) = &lua {
lua.ui_mouse("move", x, y, 0); lua.ui_mouse("move", x, y, 0);
lua.ui_mouse("down", x, y, 1); lua.ui_mouse("down", x, y, 1);
lua.ui_mouse("up", x, y, 1); lua.ui_mouse("up", x, y, 1);
} }
} }
None => warn!(spec, "Lua UI: цель отладочного клика не найдена"), None => warn!(spec = %spec, "Lua UI: цель отладочного клика не найдена"),
} }
} }
if ui_click.is_none() { if lua_ui_open
ui_click_done = true; && !ui_wheel_done
&& let Some((x, y, delta)) = ui_wheel
&& frames_drawn >= 8 + ui_clicks.len() as u64 * 8
{
ui_wheel_done = true;
if let Some(lua) = &lua {
info!(x, y, delta, "Lua UI: отладочное колесо");
lua.ui_mouse("wheel", x, y, delta);
}
} }
// Отладочное закрытие: нужно для кадра со спавном (меню закрыто). // Отладочное закрытие: нужно для кадра со спавном (меню закрыто).
if lua_ui_open if lua_ui_open
@ -1968,43 +2018,101 @@ pub fn map(assets: Option<PathBuf>, args: MapArgs) -> anyhow::Result<()> {
// Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента. // Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента.
if lua_ui_open { if lua_ui_open {
ui_snapshot_age = ui_snapshot_age.saturating_add(1); ui_snapshot_age = ui_snapshot_age.saturating_add(1);
// 6 кадров (~0.1 с) — компромисс: меню отзывчиво, но Lua не // Снимок пересобирается только когда Lua пометил состояние
// пересобирает дерево на каждый кадр (это и давало лаги). // грязным (ввод, таймер, сеттер панели, анимация). Раз в 60
if (ui_snapshot_age >= 6 || ui_commands.is_empty()) // кадров — страховка от забытого флага, не рабочий режим.
if let Some(lua) = &lua {
lua.ui_tick();
}
let dirty = lua.as_ref().is_some_and(|lua| lua.ui_dirty());
if (dirty || ui_commands.is_empty() || ui_snapshot_age >= 60)
&& let Some(lua) = &lua && let Some(lua) = &lua
{ {
let started = Instant::now();
lua.ui_snapshot_into(&mut ui_commands); lua.ui_snapshot_into(&mut ui_commands);
let elapsed = started.elapsed().as_micros();
profile_snapshot_us += elapsed;
profile_snapshot_max_us = profile_snapshot_max_us.max(elapsed);
ui_snapshot_age = 0; ui_snapshot_age = 0;
} }
// 3D-иконки: не больше трёх новых моделей за кадр. Загрузка MDL // 3D-иконки: грузим видимые модели по убыванию площади на экране,
// и распаковка текстур дорогие, а на экране одновременно видно // не больше 8 за кадр и не дольше ~8 мс. Так очередь доходит до
// несколько десятков иконок — кэш наполняется за пару секунд без // всех иконок, а не только до первых трёх, и не даёт рывка.
// рывков. Невидимые (за краем содержимого) не грузим вовсе. const MAX_ICON_LOADS_PER_FRAME: usize = 8;
const MAX_ICON_LOADS_PER_FRAME: usize = 3; const ICON_LOAD_BUDGET_US: u128 = 8_000;
let mut icon_loads = 0usize; let icons_started = Instant::now();
let mut candidates: Vec<(f32, &str)> = Vec::new();
for command in &ui_commands { for command in &ui_commands {
if command.kind != glua::UiKind::Image { if command.kind != glua::UiKind::Image {
continue; continue;
} }
if icon_loads >= MAX_ICON_LOADS_PER_FRAME {
break;
}
let Some(model) = command.model.as_deref() else { let Some(model) = command.model.as_deref() else {
continue; continue;
}; };
let visible = command.x < width as f32 if renderer.has_model_icon(model) {
&& command.y < height as f32
&& command.x + command.w > 0.0
&& command.y + command.h > 0.0;
if !visible || renderer.has_model_icon(model) {
continue; continue;
} }
let mut x0 = command.x.max(0.0);
let mut y0 = command.y.max(0.0);
let mut x1 = (command.x + command.w).min(width as f32);
let mut y1 = (command.y + command.h).min(height as f32);
if let Some([cx0, cy0, cx1, cy1]) = command.clip {
x0 = x0.max(cx0);
y0 = y0.max(cy0);
x1 = x1.min(cx1);
y1 = y1.min(cy1);
}
let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
if area > 0.0 && !candidates.iter().any(|(_, path)| *path == model) {
candidates.push((area, model));
}
}
candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut icon_loads = 0usize;
for (_, model) in candidates {
if icon_loads >= MAX_ICON_LOADS_PER_FRAME {
break;
}
if icon_loads > 0 && icons_started.elapsed().as_micros() >= ICON_LOAD_BUDGET_US {
break;
}
icon_loads += 1; icon_loads += 1;
if !renderer.ensure_model_icon(&gfx, &vfs, model) { if !renderer.ensure_model_icon(&gfx, &vfs, model) {
debug!(model, "Lua UI: иконка недоступна"); debug!(model, "Lua UI: иконка недоступна");
} }
} }
let icons_elapsed = icons_started.elapsed().as_micros();
profile_icons_us += icons_elapsed;
profile_icon_loads += icon_loads;
let draw_started = Instant::now();
let stats = ui_overlay::draw(&mut renderer, &ui_commands, width as f32, height as f32); let stats = ui_overlay::draw(&mut renderer, &ui_commands, width as f32, height as f32);
let draw_elapsed = draw_started.elapsed().as_micros();
profile_draw_us += draw_elapsed;
profile_draw_max_us = profile_draw_max_us.max(draw_elapsed);
profile_frames += 1;
if ui_profile && profile_frames >= 120 {
let frames = profile_frames.max(1) as u128;
info!(
frames = profile_frames,
snapshot_avg_ms = profile_snapshot_us as f32 / frames as f32 / 1000.0,
snapshot_max_ms = profile_snapshot_max_us as f32 / 1000.0,
icons_avg_ms = profile_icons_us as f32 / frames as f32 / 1000.0,
icons_loaded = profile_icon_loads,
draw_avg_ms = profile_draw_us as f32 / frames as f32 / 1000.0,
draw_max_ms = profile_draw_max_us as f32 / 1000.0,
commands = ui_commands.len(),
glyphs = stats.glyphs,
placeholders = stats.placeholders,
"Lua UI: профиль"
);
profile_snapshot_us = 0;
profile_icons_us = 0;
profile_draw_us = 0;
profile_snapshot_max_us = 0;
profile_draw_max_us = 0;
profile_frames = 0;
profile_icon_loads = 0;
}
if frames_drawn % 120 == 0 { if frames_drawn % 120 == 0 {
debug!( debug!(
commands = ui_commands.len(), commands = ui_commands.len(),

View file

@ -149,28 +149,61 @@ pub fn draw(
stats.skipped += 1; stats.skipped += 1;
continue; continue;
} }
// Заглушка не должна вылезать за свою иконку: иначе имена
// соседних моделей сливаются в одну строку поверх сетки.
let icon_clip = [
command.x,
command.y,
command.x + command.w,
command.y + command.h,
];
let clip = Some(match command.clip {
Some([cx0, cy0, cx1, cy1]) => [
icon_clip[0].max(cx0),
icon_clip[1].max(cy0),
icon_clip[2].min(cx1),
icon_clip[3].min(cy1),
],
None => icon_clip,
});
renderer.push_rect_clipped( renderer.push_rect_clipped(
command.x, command.x,
command.y, command.y,
command.w, command.w,
command.h, command.h,
linear_color([0.93, 0.93, 0.93, 1.0]), linear_color([0.93, 0.93, 0.93, 1.0]),
command.clip, clip,
); );
let name = model let name = model
.rsplit(['/', '\\']) .rsplit(['/', '\\'])
.next() .next()
.unwrap_or(model) .unwrap_or(model)
.trim_end_matches(".mdl"); .trim_end_matches(".mdl");
let name: String = name.chars().take(24).collect(); let scale = 0.8f32;
renderer.push_text_clipped( let glyph = 8.0 * scale;
command.x + 3.0, let max_chars = ((command.w / glyph).floor() as usize).max(1);
command.y + (command.h - 10.0).max(0.0), let mut chars = name.chars();
1.0, let first: String = chars.by_ref().take(max_chars).collect();
linear_color([0.12, 0.12, 0.12, 1.0]), let second: String = chars.take(max_chars).collect();
name, let lines: Vec<&str> = if second.is_empty() {
command.clip, vec![first.as_str()]
); } else {
vec![first.as_str(), second.as_str()]
};
let line_height = glyph + 2.0;
let total_height = lines.len() as f32 * line_height - 2.0;
let top = command.y + (command.h - total_height).max(0.0) * 0.5;
for (index, line) in lines.iter().enumerate() {
let line_width = line.chars().count() as f32 * glyph;
renderer.push_text_clipped(
command.x + (command.w - line_width).max(0.0) * 0.5,
top + index as f32 * line_height,
scale,
linear_color([0.12, 0.12, 0.12, 1.0]),
*line,
clip,
);
}
stats.placeholders += 1; stats.placeholders += 1;
stats.glyphs += cost; stats.glyphs += cost;
} }

View file

@ -92,6 +92,7 @@ const VGUI_FILES: &[&str] = &[
"dlabeleditable.lua", "dlabeleditable.lua",
"dslider.lua", "dslider.lua",
"dnumberwang.lua", "dnumberwang.lua",
"dnumberscratch.lua",
"dnumslider.lua", "dnumslider.lua",
"dcolorbutton.lua", "dcolorbutton.lua",
"dcolormixer.lua", "dcolormixer.lua",

View file

@ -79,6 +79,15 @@ pub(crate) fn install(lua: &Lua) -> Result<(), mlua::Error> {
ui.set("pressed", Value::Nil)?; ui.set("pressed", Value::Nil)?;
ui.set("root", Value::Nil)?; ui.set("root", Value::Nil)?;
ui.set("paint_errors", 0)?; 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.globals().set("__sr_ui", &ui)?;
// Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором. // Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором.
@ -117,6 +126,30 @@ impl crate::LuaRuntime {
.unwrap_or(false) .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-слоем. /// Забирает ошибки Paint/PerformLayout/ввода, накопленные Lua-слоем.
/// ///
/// Ошибка одной панели не должна ронять меню и кадр: клиент показывает /// Ошибка одной панели не должна ронять меню и кадр: клиент показывает
@ -296,6 +329,8 @@ impl crate::LuaRuntime {
let ui: Table = self.lua.globals().get("__sr_ui")?; let ui: Table = self.lua.globals().get("__sr_ui")?;
ui.set("screen_w", width.clamp(1, 16384) as f64)?; ui.set("screen_w", width.clamp(1, 16384) as f64)?;
ui.set("screen_h", height.clamp(1, 16384) as f64)?; ui.set("screen_h", height.clamp(1, 16384) as f64)?;
ui.set("dirty", true)?;
ui.set("layout_dirty", true)?;
// Корневое меню в GMod занимает весь экран. // Корневое меню в GMod занимает весь экран.
if let Ok(Value::Table(root)) = ui.get::<Value>("root") if let Ok(Value::Table(root)) = ui.get::<Value>("root")
&& let Ok(resize) = root.get::<Function>("__sr_resize_to_screen") && let Ok(resize) = root.get::<Function>("__sr_resize_to_screen")

View file

@ -40,6 +40,18 @@ function ui.take_errors()
return list return list
end end
-- Грязный флаг снимка. `layout = false` — изменилась только картинка
-- (hover), геометрию пересчитывать не нужно. Сеттеры панелей и ввод зовут
-- это сами, поэтому Rust пересобирает снимок только по факту изменения.
function ui.mark_dirty(layout)
ui.dirty = true
if layout ~= false then ui.layout_dirty = true end
end
function ui.is_dirty()
return ui.dirty == true
end
-- Константы GMod. Значения совпадают с движком и нужны скриптам как данные. -- Константы GMod. Значения совпадают с движком и нужны скриптам как данные.
NODOCK = 0 NODOCK = 0
FILL = 1 FILL = 1
@ -196,6 +208,20 @@ local function font_size(name)
return number(fonts[name], 13) return number(fonts[name], 13)
end end
-- Масштаб bitmap-глифа. Должен совпадать с `ui_overlay::text_scale` в
-- клиенте: иначе Lua центрует строку по одной ширине, а рендер рисует
-- другую, и текст «пляшет» относительно кнопок и иконок.
local function glyph_scale(size)
return math.Clamp(number(size, 13) / 12, 0.8, 3.0)
end
local function glyph_height(size)
return 8 * glyph_scale(size)
end
ui.glyph_scale = glyph_scale
ui.glyph_height = glyph_height
local function emit(kind, x, y, width, height, color, text, model) local function emit(kind, x, y, width, height, color, text, model)
if not in_snapshot or ui.commands == nil then return end if not in_snapshot or ui.commands == nil then return end
local commands = ui.commands local commands = ui.commands
@ -204,16 +230,26 @@ local function emit(kind, x, y, width, height, color, text, model)
if color ~= nil then if color ~= nil then
r, g, b, a = color_parts(color, 255) r, g, b, a = color_parts(color, 255)
end end
local px = number(x, 0) + draw_origin_x
local py = number(y, 0) + draw_origin_y
local pw = math.max(number(width, 0), 0)
local ph = math.max(number(height, 0), 0)
local clip = nil local clip = nil
if current_clip ~= nil and clip_disabled <= 0 then if current_clip ~= nil and clip_disabled <= 0 then
-- Полностью обрезанную команду не отправляем в Rust: иначе длинные
-- подписи дерева и иконки за краем канваса съедают лимит команд.
if px + pw <= current_clip[1] or py + ph <= current_clip[2]
or px >= current_clip[3] or py >= current_clip[4] then
return
end
clip = { current_clip[1], current_clip[2], current_clip[3], current_clip[4] } clip = { current_clip[1], current_clip[2], current_clip[3], current_clip[4] }
end end
commands[#commands + 1] = { commands[#commands + 1] = {
kind = kind, kind = kind,
x = number(x, 0) + draw_origin_x, x = px,
y = number(y, 0) + draw_origin_y, y = py,
w = math.max(number(width, 0), 0), w = pw,
h = math.max(number(height, 0), 0), h = ph,
color = { r / 255, g / 255, b / 255, a / 255 }, color = { r / 255, g / 255, b / 255, a / 255 },
text = text, text = text,
model = model, model = model,
@ -244,20 +280,12 @@ end
-- Приблизительная метрика: рендер-агент не обязан совпадать пиксель в -- Приблизительная метрика: рендер-агент не обязан совпадать пиксель в
-- пиксель, но раскладка и центрирование должны быть правдоподобными. -- пиксель, но раскладка и центрирование должны быть правдоподобными.
-- Ширина — ровно столько, сколько займёт bitmap-строка в рендере: один
-- байт = один глиф 8×8 с тем же масштабом, что и в `ui_overlay`.
function surface.GetTextSize(value) function surface.GetTextSize(value)
local text = text_of(value) or "" local text = text_of(value) or ""
local size = font_size(current_font) local size = font_size(current_font)
local width = 0 local width = #text * 8 * glyph_scale(size)
for index = 1, #text do
local byte = text:byte(index)
if byte >= 128 then
width = width + size * 0.6
elseif text:sub(index, index) == " " then
width = width + size * 0.28
else
width = width + size * 0.52
end
end
return width, size return width, size
end end
@ -492,11 +520,14 @@ function draw.SimpleText(value, font, x, y, color, align_x, align_y)
elseif align_x == TEXT_ALIGN_RIGHT then elseif align_x == TEXT_ALIGN_RIGHT then
px = px - w px = px - w
end end
local rendered_h = glyph_height(h)
if align_y == TEXT_ALIGN_CENTER then if align_y == TEXT_ALIGN_CENTER then
py = py - h * 0.5 py = py - rendered_h * 0.5
elseif align_y == TEXT_ALIGN_BOTTOM then elseif align_y == TEXT_ALIGN_BOTTOM then
py = py - h py = py - rendered_h
end end
-- Высота команды — размер шрифта: по ней `ui_overlay` выбирает масштаб
-- глифа. Верх строки уже сдвинут по фактической высоте глифа.
emit("text", px, py, w, h, color, text_of(value)) emit("text", px, py, w, h, color, text_of(value))
end end
function draw.DrawText(value, font, x, y, color, align_x) function draw.DrawText(value, font, x, y, color, align_x)
@ -577,6 +608,10 @@ function input.IsAltDown() return false end
function input.IsMouseDown() return false end function input.IsMouseDown() return false end
function input.IsKeyDown() return false end function input.IsKeyDown() return false end
function input.GetCursorPos() return number(ui.mouse_x, 0), number(ui.mouse_y, 0) end function input.GetCursorPos() return number(ui.mouse_x, 0), number(ui.mouse_y, 0) end
function input.SetCursorPos(x, y)
ui.mouse_x = number(x, ui.mouse_x)
ui.mouse_y = number(y, ui.mouse_y)
end
function input.LookupBinding() return nil end function input.LookupBinding() return nil end
function input.GetKeyName(code) return tostring(code) end function input.GetKeyName(code) return tostring(code) end
function input.IsKeyTrapping() return false end function input.IsKeyTrapping() return false end
@ -1259,6 +1294,7 @@ function ui.pump()
if now >= entry.at then due[#due + 1] = name end if now >= entry.at then due[#due + 1] = name end
end end
table.sort(due) table.sort(due)
if #due > 0 then ui.mark_dirty(true) end
for _, name in ipairs(due) do for _, name in ipairs(due) do
local entry = lua_timers[name] local entry = lua_timers[name]
if entry ~= nil then if entry ~= nil then
@ -1405,6 +1441,7 @@ function panel_meta:SetParent(parent)
local ok, err = pcall(parent.OnChildAdded, parent, self) local ok, err = pcall(parent.OnChildAdded, parent, self)
if not ok then ui.log_error("OnChildAdded: " .. tostring(err)) end if not ok then ui.log_error("OnChildAdded: " .. tostring(err)) end
end end
ui.mark_dirty(true)
end end
function panel_meta:GetChildren() function panel_meta:GetChildren()
local out = {} local out = {}
@ -1414,20 +1451,22 @@ end
function panel_meta:MoveToFront() function panel_meta:MoveToFront()
local parent = self.__parent local parent = self.__parent
if parent == nil then return end if parent == nil then return end
local list = parent.__children or {} local top = 0
for index = #list, 1, -1 do for _, child in ipairs(parent.__children or {}) do
if list[index] == self then table.remove(list, index) end if child ~= self then top = math.max(top, number(child.__zpos, 0)) end
end end
list[#list + 1] = self self.__zpos = top + 1
ui.mark_dirty(true)
end end
function panel_meta:MoveToBack() function panel_meta:MoveToBack()
local parent = self.__parent local parent = self.__parent
if parent == nil then return end if parent == nil then return end
local list = parent.__children or {} local bottom = 0
for index = #list, 1, -1 do for _, child in ipairs(parent.__children or {}) do
if list[index] == self then table.remove(list, index) end if child ~= self then bottom = math.min(bottom, number(child.__zpos, 0)) end
end end
table.insert(list, 1, self) self.__zpos = bottom - 1
ui.mark_dirty(true)
end end
function panel_meta:InsertAtTop(child) function panel_meta:InsertAtTop(child)
if child == nil then return end if child == nil then return end
@ -1506,12 +1545,14 @@ function panel_meta:GetTall() return self.h end
function panel_meta:SetPos(x, y) function panel_meta:SetPos(x, y)
self.x = number(x, self.x) self.x = number(x, self.x)
self.y = number(y, self.y) self.y = number(y, self.y)
ui.mark_dirty(true)
end end
function panel_meta:SetX(x) self.x = number(x, self.x) end function panel_meta:SetX(x) self.x = number(x, self.x) ui.mark_dirty(true) end
function panel_meta:SetY(y) self.y = number(y, self.y) end function panel_meta:SetY(y) self.y = number(y, self.y) ui.mark_dirty(true) end
function panel_meta:SetSize(w, h) function panel_meta:SetSize(w, h)
self.w = math.max(number(w, self.w), 0) self.w = math.max(number(w, self.w), 0)
self.h = math.max(number(h, self.h), 0) self.h = math.max(number(h, self.h), 0)
ui.mark_dirty(true)
end end
function panel_meta:SetWide(w) self:SetSize(w, self.h) end function panel_meta:SetWide(w) self:SetSize(w, self.h) end
function panel_meta:SetTall(h) self:SetSize(self.w, h) end function panel_meta:SetTall(h) self:SetSize(self.w, h) end
@ -1548,6 +1589,9 @@ end
function panel_meta:SetVisible(visible) function panel_meta:SetVisible(visible)
self.__visible = visible and true or false self.__visible = visible and true or false
-- Видимость не меняет геометрию: раскладку не заказываем, иначе первое
-- открытие меню снова гоняет DTileLayout по тысячам иконок.
ui.mark_dirty(false)
end end
function panel_meta:IsVisible() function panel_meta:IsVisible()
return self.__visible ~= false and not self.__removed return self.__visible ~= false and not self.__removed
@ -1562,9 +1606,9 @@ function panel_meta:IsVisibleChain()
end end
return true return true
end end
function panel_meta:SetAlpha(alpha) self.__alpha = math.Clamp(number(alpha, 255), 0, 255) end function panel_meta:SetAlpha(alpha) self.__alpha = math.Clamp(number(alpha, 255), 0, 255) ui.mark_dirty(false) end
function panel_meta:GetAlpha() return self.__alpha end function panel_meta:GetAlpha() return self.__alpha end
function panel_meta:SetZPos(z) self.__zpos = number(z, 0) end function panel_meta:SetZPos(z) self.__zpos = number(z, 0) ui.mark_dirty(true) end
function panel_meta:GetZPos() return self.__zpos end function panel_meta:GetZPos() return self.__zpos end
function panel_meta:SetMouseInputEnabled(value) self.__mouse_input = value and true or false end function panel_meta:SetMouseInputEnabled(value) self.__mouse_input = value and true or false end
function panel_meta:IsMouseInputEnabled() return self.__mouse_input ~= false end function panel_meta:IsMouseInputEnabled() return self.__mouse_input ~= false end
@ -1587,12 +1631,14 @@ function panel_meta:SetExpensiveShadow() end
function panel_meta:Dock(mode) function panel_meta:Dock(mode)
self.__dock = number(mode, NODOCK) self.__dock = number(mode, NODOCK)
ui.mark_dirty(true)
end end
function panel_meta:GetDock() return self.__dock end function panel_meta:GetDock() return self.__dock end
function panel_meta:DockMargin(top, right, bottom, left) function panel_meta:DockMargin(top, right, bottom, left)
self.__dock_margin = { self.__dock_margin = {
number(top, 0), number(right, 0), number(bottom, 0), number(left, 0), number(top, 0), number(right, 0), number(bottom, 0), number(left, 0),
} }
ui.mark_dirty(true)
end end
function panel_meta:GetDockMargin() function panel_meta:GetDockMargin()
local m = self.__dock_margin local m = self.__dock_margin
@ -1602,6 +1648,7 @@ function panel_meta:DockPadding(top, right, bottom, left)
self.__dock_padding = { self.__dock_padding = {
number(top, 0), number(right, 0), number(bottom, 0), number(left, 0), number(top, 0), number(right, 0), number(bottom, 0), number(left, 0),
} }
ui.mark_dirty(true)
end end
function panel_meta:GetDockPadding() function panel_meta:GetDockPadding()
local p = self.__dock_padding local p = self.__dock_padding
@ -1638,10 +1685,13 @@ end
function panel_meta:LoadCookies() end function panel_meta:LoadCookies() end
function panel_meta:SaveCookies() end function panel_meta:SaveCookies() end
function panel_meta:InvalidateLayout() end function panel_meta:InvalidateLayout()
ui.mark_dirty(true)
end
function panel_meta:InvalidateParent() function panel_meta:InvalidateParent()
local parent = self.__parent local parent = self.__parent
if parent ~= nil then parent.__layout_dirty = true end if parent ~= nil then parent.__layout_dirty = true end
ui.mark_dirty(true)
end end
function panel_meta:InvalidateChildren() end function panel_meta:InvalidateChildren() end
function panel_meta:PerformLayout() end function panel_meta:PerformLayout() end
@ -1659,8 +1709,17 @@ function panel_meta:GetTooltip() return self.__tooltip end
function panel_meta:SetTooltipPanel() end function panel_meta:SetTooltipPanel() end
function panel_meta:SetMouseInputEnabledDefault() end function panel_meta:SetMouseInputEnabledDefault() end
function panel_meta:MouseCapture(value) self.__mouse_capture = value and true or false end function panel_meta:MouseCapture(value)
self.__mouse_capture = value and true or false
if self.__mouse_capture then
ui.capture = self
elseif ui.capture == self then
ui.capture = nil
end
end
function panel_meta:IsMouseCaptured() return self.__mouse_capture == true end function panel_meta:IsMouseCaptured() return self.__mouse_capture == true end
function panel_meta:NoClipping(value) self.__no_clipping = value and true or false end
function panel_meta:GetNoClipping() return self.__no_clipping == true end
function panel_meta:DragMousePress() end function panel_meta:DragMousePress() end
function panel_meta:DragMouseRelease() return false end function panel_meta:DragMouseRelease() return false end
function panel_meta:IsDraggable() return self.__draggable == true end function panel_meta:IsDraggable() return self.__draggable == true end
@ -1817,10 +1876,14 @@ function panel_meta:PaintOver() end
function panel_meta:PaintAt() end function panel_meta:PaintAt() end
-- Базовый Label: текст рисуем сами, потому что движкового рендера у нас нет. -- Базовый Label: текст рисуем сами, потому что движкового рендера у нас нет.
function panel_meta:SetText(value) self.__text = text_of(value) or "" end function panel_meta:SetText(value) self.__text = text_of(value) or "" ui.mark_dirty(true) end
function panel_meta:GetText() return self.__text or "" end function panel_meta:GetText() return self.__text or "" end
function panel_meta:GetCaretPos() return number(self.__caret, 0) end
function panel_meta:SetCaretPos(value) self.__caret = math.max(number(value, 0), 0) ui.mark_dirty(false) end
function panel_meta:SetValue(value) self.__value = value ui.mark_dirty(true) end
function panel_meta:GetValue() return self.__value end
function panel_meta:SetFont(value) self.__font = value or "DermaDefault" self:SetFontInternal(self.__font) end function panel_meta:SetFont(value) self.__font = value or "DermaDefault" self:SetFontInternal(self.__font) end
function panel_meta:SetFontInternal(value) self.__font = value or self.__font end function panel_meta:SetFontInternal(value) self.__font = value or self.__font ui.mark_dirty(true) end
function panel_meta:GetFont() return self.__font end function panel_meta:GetFont() return self.__font end
function panel_meta:SetFGColor(r, g, b, a) function panel_meta:SetFGColor(r, g, b, a)
if type(r) == "table" or type(r) == "userdata" then if type(r) == "table" or type(r) == "userdata" then
@ -1829,27 +1892,28 @@ function panel_meta:SetFGColor(r, g, b, a)
else else
self.__fg = { number(r, 255), number(g, 255), number(b, 255), number(a, 255) } self.__fg = { number(r, 255), number(g, 255), number(b, 255), number(a, 255) }
end end
ui.mark_dirty(false)
end end
function panel_meta:GetFGColor() function panel_meta:GetFGColor()
local c = self.__fg or { 255, 255, 255, 255 } local c = self.__fg or { 255, 255, 255, 255 }
return Color(c[1], c[2], c[3], c[4]) return Color(c[1], c[2], c[3], c[4])
end end
function panel_meta:SetTextColor(color) self.__text_color = to_color_table(color) end function panel_meta:SetTextColor(color) self.__text_color = to_color_table(color) ui.mark_dirty(false) end
function panel_meta:GetTextColor() function panel_meta:GetTextColor()
local c = self.__text_color or self.__fg local c = self.__text_color or self.__fg
if c == nil then return nil end if c == nil then return nil end
if type(c) == "table" and c.r ~= nil then return c end if type(c) == "table" and c.r ~= nil then return c end
return Color(c[1], c[2], c[3], c[4]) return Color(c[1], c[2], c[3], c[4])
end end
function panel_meta:SetTextStyleColor(color) self.__text_style_color = to_color_table(color) end function panel_meta:SetTextStyleColor(color) self.__text_style_color = to_color_table(color) ui.mark_dirty(false) end
function panel_meta:GetTextStyleColor() function panel_meta:GetTextStyleColor()
local c = self.__text_style_color local c = self.__text_style_color
if c == nil then return nil end if c == nil then return nil end
return Color(c.r, c.g, c.b, c.a) return Color(c.r, c.g, c.b, c.a)
end end
function panel_meta:SetContentAlignment(value) self.__content_alignment = number(value, 0) end function panel_meta:SetContentAlignment(value) self.__content_alignment = number(value, 0) ui.mark_dirty(false) end
function panel_meta:GetContentAlignment() return self.__content_alignment end function panel_meta:GetContentAlignment() return self.__content_alignment end
function panel_meta:SetTextInset(x, y) self.__text_inset = { number(x, 0), number(y, 0) } end function panel_meta:SetTextInset(x, y) self.__text_inset = { number(x, 0), number(y, 0) } ui.mark_dirty(true) end
function panel_meta:GetTextInset() function panel_meta:GetTextInset()
return self.__text_inset[1], self.__text_inset[2] return self.__text_inset[1], self.__text_inset[2]
end end
@ -1880,9 +1944,10 @@ function panel_meta:DrawTextEntryText(text_color)
surface.SetTextColor(text_color or color_black) surface.SetTextColor(text_color or color_black)
local _, text_height = surface.GetTextSize(value) local _, text_height = surface.GetTextSize(value)
local inset_x, inset_y = self:GetTextInset() local inset_x, inset_y = self:GetTextInset()
local rendered_h = ui.glyph_height(text_height)
surface.SetTextPos( surface.SetTextPos(
inset_x, inset_x,
inset_y + math.max((self:GetTall() - text_height) * 0.5, 0) inset_y + math.max((self:GetTall() - rendered_h) * 0.5, 0)
) )
surface.DrawText(value) surface.DrawText(value)
end end
@ -1970,6 +2035,8 @@ function IsValid(value)
end end
-- Рисование текста Label/DLabel. В GMod это делает движок поверх Lua-Paint. -- Рисование текста Label/DLabel. В GMod это делает движок поверх Lua-Paint.
-- `ContentAlignment` — «цифровая клавиатура» GMod: 1..3 низ, 4..6 середина,
-- 7..9 верх; внутри ряда 1/4/7 — лево, 2/5/8 — центр, 3/6/9 — право.
function ui.draw_label_text(panel, width, height) function ui.draw_label_text(panel, width, height)
local value = panel:GetText() local value = panel:GetText()
if value == nil or value == "" then return end if value == nil or value == "" then return end
@ -1979,19 +2046,30 @@ function ui.draw_label_text(panel, width, height)
local text_width, text_height = surface.GetTextSize(value) local text_width, text_height = surface.GetTextSize(value)
local inset_x, inset_y = panel:GetTextInset() local inset_x, inset_y = panel:GetTextInset()
local align = panel:GetContentAlignment() local align = panel:GetContentAlignment()
-- Вкладки DPropertySheet в оригинале позиционируют текст по левому краю,
-- но в меню Sandbox он должен стоять по центру вкладки.
if panel.ClassName == "DTab" then align = 5 end
align = number(align, 0)
local horizontal = 0
local vertical = 2
if align > 0 then
horizontal = (align - 1) % 3
vertical = math.floor((align - 1) / 3)
end
local x local x
local y if horizontal == 1 then
if align == 1 or align == 4 or align == 5 then x = inset_x + math.max((width - inset_x * 2 - text_width) * 0.5, 0)
x = math.max((width - text_width) * 0.5, inset_x) elseif horizontal == 2 then
elseif align == 2 or align == 6 or align == 8 then
x = math.max(width - text_width - inset_x, inset_x) x = math.max(width - text_width - inset_x, inset_x)
else else
x = inset_x x = inset_x
end end
if align >= 6 then local rendered_h = ui.glyph_height(text_height)
y = height - text_height - inset_y local y
elseif align == 4 or align == 5 or align == 1 then if vertical == 1 then
y = (height - text_height) * 0.5 y = math.max((height - rendered_h) * 0.5, 0)
elseif vertical == 0 then
y = math.max(height - rendered_h - inset_y, 0)
else else
y = inset_y y = inset_y
end end
@ -2017,8 +2095,19 @@ local function absolute_pos(panel)
end end
local function sort_children(panel) local function sort_children(panel)
local list = panel.__children or {}
-- У большинства панелей ZPos нулевой: не сортируем и не копируем
-- тысячи иконок сетки на каждый снимок, если порядок и так стабилен.
local has_z = false
for index = 1, #list do
if number(list[index].__zpos, 0) ~= 0 then
has_z = true
break
end
end
if not has_z then return list end
local children = {} local children = {}
for index, child in ipairs(panel.__children or {}) do for index, child in ipairs(list) do
children[index] = { child = child, index = index } children[index] = { child = child, index = index }
end end
table.sort(children, function(a, b) table.sort(children, function(a, b)
@ -2038,7 +2127,7 @@ local function arrange_children(panel)
local top = padding_top local top = padding_top
local right = panel.w - padding_right local right = panel.w - padding_right
local bottom = panel.h - padding_bottom local bottom = panel.h - padding_bottom
for _, child in ipairs(sort_children(panel)) do for _, child in ipairs(panel.__children or {}) do
if child.__dock ~= NODOCK then if child.__dock ~= NODOCK then
local margin_top, margin_right, margin_bottom, margin_left = child:GetDockMargin() local margin_top, margin_right, margin_bottom, margin_left = child:GetDockMargin()
local dock = child.__dock local dock = child.__dock
@ -2073,6 +2162,7 @@ local function arrange_children(panel)
end end
end end
end end
ui.arrange_children = arrange_children
function ui.layout_tree(panel, depth, seen) function ui.layout_tree(panel, depth, seen)
depth = depth or 0 depth = depth or 0
@ -2088,6 +2178,9 @@ function ui.layout_tree(panel, depth, seen)
if canvas ~= nil and canvas.__parent ~= panel then if canvas ~= nil and canvas.__parent ~= panel then
canvas:SetParent(panel) canvas:SetParent(panel)
end end
-- Dock-раскладка должна быть применена до PerformLayout: DScrollPanel
-- вызывает VBar:SetUp с высотой скроллбара, а её выставляет Dock(RIGHT).
arrange_children(panel)
if panel.PerformLayout ~= nil then if panel.PerformLayout ~= nil then
local ok, err = pcall(panel.PerformLayout, panel, panel.w, panel.h) local ok, err = pcall(panel.PerformLayout, panel, panel.w, panel.h)
if not ok then if not ok then
@ -2100,6 +2193,20 @@ function ui.layout_tree(panel, depth, seen)
end end
end end
-- Раскладка только когда она действительно устарела. Снимок после движения
-- мыши перерисовывает панели, но не гоняет DTileLayout по тысячам иконок.
function ui.ensure_layout(root)
if root == nil then return end
if root.w <= 0 or root.h <= 0 then
root:SetPos(0, 0)
root:SetSize(ScrW(), ScrH())
ui.layout_dirty = true
end
if not ui.layout_dirty then return end
ui.layout_tree(root, 0, {})
ui.layout_dirty = false
end
function ui.resolve_root() function ui.resolve_root()
local root = rawget(G, "g_SpawnMenu") local root = rawget(G, "g_SpawnMenu")
if root ~= nil and istable(root) and root.__sr_ui_panel and not root.__removed then if root ~= nil and istable(root) and root.__sr_ui_panel and not root.__removed then
@ -2112,15 +2219,27 @@ function ui.resolve_root()
return nil return nil
end end
local function intersects_clip(x, y, w, h)
local clip = current_clip
if clip == nil then return true end
return x < clip[3] and y < clip[4] and x + w > clip[1] and y + h > clip[2]
end
local function paint_panel(panel, origin_x, origin_y, depth, count) local function paint_panel(panel, origin_x, origin_y, depth, count)
if depth > ui.depth_limit or count >= ui.command_limit then return count end if depth > ui.depth_limit or count >= ui.command_limit then return count end
if not panel:IsVisible() then return count end if not panel:IsVisible() then return count end
local x = origin_x + (panel.x or 0) local x = origin_x + (panel.x or 0)
local y = origin_y + (panel.y or 0) local y = origin_y + (panel.y or 0)
local w, h = number(panel.w, 0), number(panel.h, 0)
-- Панель целиком за границей родителя: её Paint и всё поддерево не
-- могут попасть в кадр. Раньше снимок честно рисовал тысячи иконок за
-- краем канваса и упирался в лимит команд.
if w > 0 and h > 0 and not intersects_clip(x, y, w, h) then
return count
end
-- Дети обрезаются по границам панели, как в GMod. Панели без размера -- Дети обрезаются по границам панели, как в GMod. Панели без размера
-- (служебные контейнеры) не сужают область: иначе их содержимое исчезло бы. -- (служебные контейнеры) не сужают область: иначе их содержимое исчезло бы.
local previous_clip = current_clip local previous_clip = current_clip
local w, h = number(panel.w, 0), number(panel.h, 0)
if w > 0 and h > 0 then if w > 0 and h > 0 then
local x0 = previous_clip ~= nil and math.max(previous_clip[1], x) or x local x0 = previous_clip ~= nil and math.max(previous_clip[1], x) or x
local y0 = previous_clip ~= nil and math.max(previous_clip[2], y) or y local y0 = previous_clip ~= nil and math.max(previous_clip[2], y) or y
@ -2162,6 +2281,42 @@ local function think_panel(panel, depth, seen)
ui.log_error("Think " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err)) ui.log_error("Think " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err))
end end
end end
-- Анимации панели (MoveTo/SizeTo/DTree) двигают геометрию между кадрами.
-- Пока они активны, снимок обязан пересобираться каждый кадр.
if panel.AnimationThink ~= nil then
local ok, err = pcall(panel.AnimationThink, panel)
if not ok then
ui.log_error("AnimationThink " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err))
end
if (panel.m_AnimList ~= nil and next(panel.m_AnimList) ~= nil) or panel.Term ~= nil then
ui.animating = true
end
end
-- Derma_Anim (DTree_Node.animSlide, DPropertySheet.animFade) сам не
-- тикает: в GMod его зовёт Think владельца. Без этого анимация ветки
-- дерева навсегда оставалась активной и PerformLayout не раскладывал
-- подписи (ширина Label = 0).
local function run_derma_anim(anim)
if type(anim) ~= "table" or type(anim.Active) ~= "function" then return end
-- Анимация завершилась в прошлом кадре: её Func уже выставил новые
-- размеры, но раскладка была до Think. Просим пересчитать геометрию.
if anim.Finished then
anim.Finished = nil
ui.animated_frame = true
ui.layout_dirty = true
return
end
if not anim:Active() then return end
local ok, err = pcall(anim.Run, anim)
if not ok then
ui.log_error("Derma_Anim: " .. tostring(err))
end
ui.animated_frame = true
ui.layout_dirty = true
if anim:Active() then ui.animating = true end
end
run_derma_anim(panel.animSlide)
run_derma_anim(panel.animFade)
for _, child in ipairs(sort_children(panel)) do for _, child in ipairs(sort_children(panel)) do
think_panel(child, depth + 1, seen) think_panel(child, depth + 1, seen)
end end
@ -2172,14 +2327,19 @@ function ui.snapshot()
ui.pump() ui.pump()
local root = ui.resolve_root() local root = ui.resolve_root()
if not ui.visible or root == nil or not root:IsVisible() then if not ui.visible or root == nil or not root:IsVisible() then
ui.dirty = false
ui.animating = false
ui.animated_frame = false
return out return out
end end
if root.w <= 0 or root.h <= 0 then ui.animating = false
root:SetPos(0, 0) ui.animated_frame = false
root:SetSize(ScrW(), ScrH()) ui.ensure_layout(root)
end
ui.layout_tree(root, 0, {})
think_panel(root, 0, {}) think_panel(root, 0, {})
-- Think/анимации могли изменить размеры (раскрытие ветки дерева,
-- переключение вкладки): раскладываем ещё раз до Paint, иначе подпись
-- останется с шириной 0 на один кадр.
if ui.layout_dirty then ui.ensure_layout(root) end
ui.commands = out ui.commands = out
in_snapshot = true in_snapshot = true
draw_origin_x, draw_origin_y = 0, 0 draw_origin_x, draw_origin_y = 0, 0
@ -2189,12 +2349,20 @@ function ui.snapshot()
current_clip = nil current_clip = nil
in_snapshot = false in_snapshot = false
ui.commands = nil ui.commands = nil
-- Сеттеры, которые зовёт раскладка/рисование, не должны сами по себе
-- заказывать следующий снимок. Грязным остаётся только кадр, в котором
-- реально идёт анимация.
ui.dirty = ui.animating
if not ui.animated_frame then
ui.layout_dirty = false
end
return out return out
end end
function ui.toggle(open) function ui.toggle(open)
open = open and true or false open = open and true or false
ui.visible = open ui.visible = open
ui.mark_dirty(false)
local root = ui.resolve_root() local root = ui.resolve_root()
if root == nil then return end if root == nil then return end
if open then if open then
@ -2222,7 +2390,7 @@ local function point_inside(panel, px, py)
end end
local function find_target(panel, px, py, depth) local function find_target(panel, px, py, depth)
if depth > ui.depth_limit or not panel:IsVisible() then return nil end if depth > ui.depth_limit or not panel:IsVisibleChain() then return nil end
local children = sort_children(panel) local children = sort_children(panel)
for index = #children, 1, -1 do for index = #children, 1, -1 do
local child = children[index] local child = children[index]
@ -2237,54 +2405,104 @@ local function find_target(panel, px, py, depth)
return nil return nil
end end
local function clear_hovered(panel, depth) local function clear_hover_chain()
if depth > ui.depth_limit then return end local chain = ui.hover_chain
panel.__hovered = false if chain ~= nil then
panel.Hovered = false for _, panel in ipairs(chain) do
for _, child in ipairs(panel.__children or {}) do panel.__hovered = false
clear_hovered(child, depth + 1) panel.Hovered = false
end
end end
ui.hover_chain = nil
end
local function set_hovered(target)
if ui.hovered == target then return false end
clear_hover_chain()
local chain = {}
local current = target
local depth = 0
while current ~= nil and depth < 64 do
current.__hovered = true
current.Hovered = true
chain[#chain + 1] = current
current = current.__parent
depth = depth + 1
end
ui.hover_chain = chain
ui.hovered = target
-- OnCursorEntered/Exited в DLabel зовут InvalidateLayout: на каждом
-- переходе мыши это заставляло заново раскладывать всё дерево. Скины
-- и так читают `Hovered`, поэтому достаточно dirty-флага отрисовки.
ui.mark_dirty(false)
return true
end
local function wheel_target(target, delta)
local current = target
local depth = 0
while current ~= nil and depth < 64 do
local handler = current.OnMouseWheeled
if handler ~= nil then
local ok, handled = pcall(handler, current, delta)
if not ok then
ui.log_error("OnMouseWheeled: " .. tostring(handled))
elseif handled then
return true
end
end
handler = current.OnMouseWheel
if handler ~= nil then
local ok, handled = pcall(handler, current, delta)
if not ok then
ui.log_error("OnMouseWheel: " .. tostring(handled))
elseif handled then
return true
end
end
current = current.__parent
depth = depth + 1
end
return false
end end
function ui.mouse(kind, x, y, button) function ui.mouse(kind, x, y, button)
ui.pump() ui.pump()
-- Новые панели могли появиться после прошлого кадра: перед попаданием -- Новые панели могли появиться после прошлого кадра: перед попаданием
-- нужна та же раскладка, что и при рисовании. -- нужна та же раскладка, что и при рисовании. Если геометрия не менялась,
-- повторный обход дерева не нужен.
local root = ui.resolve_root() local root = ui.resolve_root()
if not ui.visible or root == nil or not root:IsVisible() then return end if not ui.visible or root == nil or not root:IsVisible() then return end
ui.layout_tree(root, 0, {}) ui.ensure_layout(root)
ui.mouse_x = number(x, 0) ui.mouse_x = number(x, 0)
ui.mouse_y = number(y, 0) ui.mouse_y = number(y, 0)
button = number(button, 1) button = number(button, 1)
clear_hovered(root, 0)
local target = find_target(root, ui.mouse_x, ui.mouse_y, 0) local target = find_target(root, ui.mouse_x, ui.mouse_y, 0)
ui.hovered = target
if target ~= nil then
target.__hovered = true
target.Hovered = true
local ancestor = target.__parent
local depth = 0
while ancestor ~= nil and depth < 64 do
ancestor.__hovered = true
ancestor.Hovered = true
ancestor = ancestor.__parent
depth = depth + 1
end
end
if kind == "move" then if kind == "move" then
if target ~= nil and target.OnCursorMoved ~= nil then set_hovered(target)
local ok, err = pcall(target.OnCursorMoved, target, ui.mouse_x, ui.mouse_y) local receiver = ui.capture
if receiver ~= nil and (receiver.__removed or not receiver:IsVisible()) then
ui.capture = nil
receiver = nil
end
if receiver == nil then receiver = target end
if receiver ~= nil and receiver.OnCursorMoved ~= nil then
-- GMod передаёт OnCursorMoved координаты относительно панели, а
-- не экрана: DSlider внутри делает LocalToScreen/ScreenToLocal.
local local_x, local_y = receiver:ScreenToLocal(ui.mouse_x, ui.mouse_y)
local ok, err = pcall(receiver.OnCursorMoved, receiver, local_x, local_y)
if not ok then ui.log_error("OnCursorMoved: " .. tostring(err)) end if not ok then ui.log_error("OnCursorMoved: " .. tostring(err)) end
end end
elseif kind == "down" then elseif kind == "down" then
set_hovered(target)
ui.pressed = target ui.pressed = target
if target ~= nil then if target ~= nil and target.OnMousePressed ~= nil then
if target.OnMousePressed ~= nil then local ok, err = pcall(target.OnMousePressed, target, button)
local ok, err = pcall(target.OnMousePressed, target, button) if not ok then ui.log_error("OnMousePressed: " .. tostring(err)) end
if not ok then ui.log_error("OnMousePressed: " .. tostring(err)) end
end
end end
ui.mark_dirty(false)
elseif kind == "up" then elseif kind == "up" then
set_hovered(target)
local pressed = ui.pressed local pressed = ui.pressed
ui.pressed = nil ui.pressed = nil
if pressed ~= nil and not pressed.__removed then if pressed ~= nil and not pressed.__removed then
@ -2300,19 +2518,17 @@ function ui.mouse(kind, x, y, button)
local ok, err = pcall(target.OnMouseReleased, target, button) local ok, err = pcall(target.OnMouseReleased, target, button)
if not ok then ui.log_error("OnMouseReleased: " .. tostring(err)) end if not ok then ui.log_error("OnMouseReleased: " .. tostring(err)) end
end end
ui.capture = nil
ui.mark_dirty(false)
elseif kind == "wheel" then elseif kind == "wheel" then
set_hovered(target)
local delta = button local delta = button
if delta == 0 then delta = 1 end if delta == 0 then delta = 1 end
if delta ~= 1 and delta ~= -1 then if delta ~= 1 and delta ~= -1 then
if delta > 1 then delta = 1 else delta = -1 end if delta > 1 then delta = 1 else delta = -1 end
end end
if target ~= nil then wheel_target(target, delta)
local handler = target.OnMouseWheeled or target.OnMouseWheel ui.mark_dirty(true)
if handler ~= nil then
local ok, err = pcall(handler, target, delta)
if not ok then ui.log_error("OnMouseWheel: " .. tostring(err)) end
end
end
end end
end end
@ -3140,6 +3356,163 @@ function __sr_ui_install_fallbacks()
register("ModelImage", ModelImage, "DPanel") register("ModelImage", ModelImage, "DPanel")
register("DModelPanel", ModelImage, "ModelImage") register("DModelPanel", ModelImage, "ModelImage")
-- DSlider / DNumSlider: минимальная рабочая реализация на случай, если
-- настоящий dslider.lua не загрузился. Полоса, ползунок и перетаскивание.
local SliderFallback = {}
function SliderFallback:Init()
self:SetPaintBackground(false)
self:SetMouseInputEnabled(true)
self.__value = 0.5
self.__notches = nil
self.__notch_color = Color(160, 160, 160, 180)
end
function SliderFallback:SetValue(value) self:SetSlideX(value) end
function SliderFallback:GetValue() return self.__value end
function SliderFallback:SetSlideX(value)
local next_value = math.Clamp(number(value, 0.5), 0, 1)
if next_value == self.__value then return end
self.__value = next_value
self:OnValueChanged(self.__value)
self:InvalidateLayout()
end
function SliderFallback:GetSlideX() return self.__value end
function SliderFallback:SetSlideY() end
function SliderFallback:GetSlideY() return 0.5 end
function SliderFallback:SetNotches(value) self.__notches = value end
function SliderFallback:GetNotches() return self.__notches end
function SliderFallback:SetNotchColor(color) self.__notch_color = color end
function SliderFallback:GetNotchColor() return self.__notch_color end
function SliderFallback:IsEditing() return self.Dragging == true end
function SliderFallback:OnValueChanged() end
function SliderFallback:TranslateValues(x, y) return x, y end
function SliderFallback:ResetToDefaultValue() self:SetSlideX(0.5) end
function SliderFallback:UpdateFromCursor()
local w = math.max(self:GetWide(), 1)
local x = select(1, self:CursorPos())
self:SetSlideX(x / w)
end
function SliderFallback:OnMousePressed(code)
if code ~= MOUSE_LEFT then return end
self.Dragging = true
self:MouseCapture(true)
self:UpdateFromCursor()
end
function SliderFallback:OnCursorMoved()
if self.Dragging then self:UpdateFromCursor() end
end
function SliderFallback:OnMouseReleased()
self.Dragging = false
self:MouseCapture(false)
end
function SliderFallback:Paint(w, h)
local y = math.max(h * 0.5 - 2, 0)
surface.SetDrawColor(205, 205, 205, 255)
surface.DrawRect(0, y, w, 4)
surface.SetDrawColor(100, 170, 220, 255)
surface.DrawRect(0, y, w * self.__value, 4)
local knob = 12
surface.SetDrawColor(245, 245, 245, 255)
surface.DrawRect(math.Clamp(w * self.__value - knob * 0.5, 0, math.max(w - knob, 0)), math.max((h - knob) * 0.5, 0), knob, knob)
surface.SetDrawColor(120, 120, 120, 255)
surface.DrawOutlinedRect(math.Clamp(w * self.__value - knob * 0.5, 0, math.max(w - knob, 0)), math.max((h - knob) * 0.5, 0), knob, knob)
end
register("DSlider", SliderFallback, "Panel")
local NumSliderFallback = {}
function NumSliderFallback:Init()
self.__min = 0
self.__max = 1
self.__decimals = 2
self.__value = 0
self.Slider = vgui.Create("DSlider", self)
self.Slider:Dock(FILL)
self.Label = vgui.Create("DLabel", self)
self.Label:Dock(RIGHT)
self.Label:SetWide(52)
self.Label:SetContentAlignment(5)
self.Slider.OnValueChanged = function(slider, fraction)
if self.__updating then return end
self:SetValue(self.__min + fraction * (self.__max - self.__min))
end
self:SetTall(24)
end
function NumSliderFallback:SetMin(value) self.__min = number(value, 0) self:SetValue(self.__value) end
function NumSliderFallback:GetMin() return self.__min end
function NumSliderFallback:SetMax(value) self.__max = number(value, 1) self:SetValue(self.__value) end
function NumSliderFallback:GetMax() return self.__max end
function NumSliderFallback:GetRange() return self.__max - self.__min end
function NumSliderFallback:SetMinMax(min, max) self.__min = number(min, 0) self.__max = number(max, 1) self:SetValue(self.__value) end
function NumSliderFallback:SetDecimals(value) self.__decimals = math.max(math.floor(number(value, 2)), 0) self:SetValue(self.__value) end
function NumSliderFallback:GetDecimals() return self.__decimals end
function NumSliderFallback:SetText(value) self.__text = text_of(value) or "" self:SetValue(self.__value) end
function NumSliderFallback:GetText() return self.__text or "" end
function NumSliderFallback:SetValue(value)
local next_value = math.Clamp(number(value, self.__min), self.__min, self.__max)
self.__value = next_value
local range = self:GetRange()
local fraction = range ~= 0 and (next_value - self.__min) / range or 0
self.__updating = true
if self.Slider ~= nil then self.Slider:SetSlideX(fraction) end
self.__updating = false
local pattern = "%." .. tostring(self.__decimals) .. "f"
if self.Label ~= nil then
local text = string.format(pattern, next_value)
if (self.__text or "") ~= "" then text = self.__text .. " " .. text end
self.Label:SetText(text)
end
self:OnValueChanged(next_value)
end
function NumSliderFallback:GetValue() return self.__value end
function NumSliderFallback:SetConVar() end
function NumSliderFallback:GetConVar() return nil end
function NumSliderFallback:IsEditing() return self.Slider ~= nil and self.Slider:IsEditing() end
function NumSliderFallback:PerformLayout()
if self.Label ~= nil then self.Label:SetWide(52) end
end
function NumSliderFallback:OnValueChanged() end
register("DNumSlider", NumSliderFallback, "Panel")
register("Slider", NumSliderFallback, "Panel")
-- DNumberScratch: только данные и текст, без интерактивного «скретча».
local NumberScratchFallback = {}
function NumberScratchFallback:Init()
self.__min = 0
self.__max = 1
self.__decimals = 2
self.__value = 0
self:SetMouseInputEnabled(false)
self:SetPaintBackground(false)
end
function NumberScratchFallback:SetMin(value) self.__min = number(value, 0) end
function NumberScratchFallback:GetMin() return self.__min end
function NumberScratchFallback:SetMax(value) self.__max = number(value, 1) end
function NumberScratchFallback:GetMax() return self.__max end
function NumberScratchFallback:GetRange() return self.__max - self.__min end
function NumberScratchFallback:SetDecimals(value) self.__decimals = math.max(math.floor(number(value, 2)), 0) end
function NumberScratchFallback:GetDecimals() return self.__decimals end
function NumberScratchFallback:SetValue(value)
local next_value = math.Clamp(number(value, 0), self.__min, self.__max)
if next_value == self.__value then return end
self.__value = next_value
self:OnValueChanged(next_value)
end
function NumberScratchFallback:GetValue() return self.__value end
function NumberScratchFallback:GetFloatValue() return self.__value end
function NumberScratchFallback:SetFloatValue(value) self.__value = number(value, 0) end
function NumberScratchFallback:GetFraction()
local range = self:GetRange()
return range ~= 0 and (self.__value - self.__min) / range or 0
end
function NumberScratchFallback:SetFraction(fraction) self:SetValue(self.__min + number(fraction, 0) * self:GetRange()) end
function NumberScratchFallback:GetTextValue()
local pattern = "%." .. tostring(self.__decimals) .. "f"
return string.format(pattern, self.__value)
end
function NumberScratchFallback:SetConVar() end
function NumberScratchFallback:OnValueChanged() end
function NumberScratchFallback:IsEditing() return false end
register("DNumberScratch", NumberScratchFallback, "DImageButton")
-- Заглушки оставшихся контролов, которые встречаются в панелях инструментов. -- Заглушки оставшихся контролов, которые встречаются в панелях инструментов.
local function simple_panel(name, base) local function simple_panel(name, base)
local panel = {} local panel = {}
@ -3377,6 +3750,35 @@ function __sr_ui_finalize()
end end
end end
-- DTree_Node:PerformRootNodeLayout берёт высоту ChildNodes до того, как
-- DListLayout успел посчитать размер по детям. В результате корень
-- дерева оставался высотой 0, а VBar — выключенным, и колесо не листало
-- список. Считаем размер детей до родителя.
local tree_node = vgui.GetControlTable("DTree_Node")
if tree_node ~= nil and type(tree_node.PerformRootNodeLayout) == "function" then
local original_root_layout = tree_node.PerformRootNodeLayout
tree_node.PerformRootNodeLayout = function(self)
if self.ChildNodes ~= nil then
ui.arrange_children(self.ChildNodes)
self.ChildNodes:SizeToChildren(false, true)
end
local result = original_root_layout(self)
-- Родитель-канвас и скроллбар уже прошли PerformLayout до детей.
-- Обновляем их сразу, иначе высота канваса остаётся старой, VBar
-- выключен, и колесо не листает дерево до следующего изменения.
local canvas = self.__parent
if canvas ~= nil then
canvas:SizeToChildren(false, true)
local scroll = canvas.__parent
if scroll ~= nil and scroll.VBar ~= nil then
scroll.VBar:SetUp(scroll:GetTall(), canvas:GetTall())
canvas:SetPos(0, scroll.VBar:GetOffset())
end
end
return result
end
end
-- Копия настоящего CreateFromTable, но с pcall вокруг Init и без -- Копия настоящего CreateFromTable, но с pcall вокруг Init и без
-- зависимости от устройства scriptedpanels. -- зависимости от устройства scriptedpanels.
function vgui.CreateFromTable(class_table, parent, name) function vgui.CreateFromTable(class_table, parent, name)
@ -3414,4 +3816,5 @@ end
function ui.set_screen(width, height) function ui.set_screen(width, height)
ui.screen_w = number(width, ui.screen_w) ui.screen_w = number(width, ui.screen_w)
ui.screen_h = number(height, ui.screen_h) ui.screen_h = number(height, ui.screen_h)
ui.mark_dirty(true)
end end

View file

@ -89,6 +89,11 @@ fn ui_spawnmenu_snapshot_and_click() {
// Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают // Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают
// вкладки и содержимое спавн-меню; второй — уже для проверки. // вкладки и содержимое спавн-меню; второй — уже для проверки.
let _ = runtime.ui_snapshot(); let _ = runtime.ui_snapshot();
// Ветки DTree раскрываются анимацией 0.3 с (как в GMod). Даём ей
// завершиться и делаем ещё два снимка: первый завершает анимацию и
// заказывает раскладку, второй уже видит подписи веток.
std::thread::sleep(std::time::Duration::from_millis(400));
let _ = runtime.ui_snapshot();
let first = runtime.ui_snapshot(); let first = runtime.ui_snapshot();
dump("первый", &first); dump("первый", &first);
assert!( assert!(
@ -172,6 +177,259 @@ fn ui_snapshot_has_a_bounded_command_count() {
assert!(commands.len() > 20, "меню пустое: {}", commands.len()); assert!(commands.len() > 20, "меню пустое: {}", commands.len());
} }
/// Настоящие DSlider/DNumSlider/DNumberScratch из `lua/vgui` загружены и
/// работают: значение меняется от перетаскивания, текст числа виден.
#[test]
fn ui_num_slider_drags_and_updates_value() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"slider_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "Panel" )
p:SetPos( 100, 100 )
p:SetSize( 300, 60 )
local slider = vgui.Create( "DNumSlider", p )
slider:SetPos( 10, 10 )
slider:SetSize( 220, 32 )
slider:SetMin( 0 )
slider:SetMax( 10 )
slider:SetDecimals( 1 )
slider:SetValue( 5 )
_G.__test_slider = slider
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("слайдер создан");
let commands = runtime.ui_snapshot();
assert!(
commands
.iter()
.any(|command| command.text.as_deref() == Some("5.0")),
"DNumSlider не нарисовал число: {:?}",
texts(&commands)
);
let before = runtime
.probe("return tostring(__test_slider:GetValue())")
.expect("probe")
.unwrap_or_default();
assert_eq!(before, "5", "начальное значение");
// Тянем ползунок вправо: значение должно вырасти.
let y = 126.0;
runtime.ui_mouse("move", 230.0, y, 0);
runtime.ui_mouse("down", 230.0, y, 1);
runtime.ui_mouse("move", 270.0, y, 0);
runtime.ui_mouse("up", 270.0, y, 1);
let _ = runtime.ui_snapshot();
let after: f32 = runtime
.probe("return tostring(__test_slider:GetValue())")
.expect("probe")
.unwrap_or_default()
.parse()
.unwrap_or(0.0);
assert!(after > 6.0, "перетаскивание не изменило значение: {after}");
}
/// Колесо над содержимым DScrollPanel прокручивает канвас (событие
/// всплывает от иконки/кнопки до панели со скроллбаром).
#[test]
fn ui_wheel_scrolls_scroll_panel() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"scroll_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "DScrollPanel" )
p:SetPos( 50, 50 )
p:SetSize( 300, 200 )
local canvas = p:GetCanvas()
local item = vgui.Create( "DButton", canvas )
item:SetPos( 0, 0 )
item:SetSize( 120, 800 )
item:SetText( "long" )
_G.__test_scroll = p
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("скролл-панель создана");
let _ = runtime.ui_snapshot();
let enabled = runtime
.probe("return tostring(__test_scroll.VBar.Enabled)")
.expect("probe")
.unwrap_or_default();
assert_eq!(enabled, "true", "скроллбар не включился");
runtime.ui_mouse("wheel", 100.0, 100.0, -1);
let _ = runtime.ui_snapshot();
let offset = runtime
.probe("return tostring(__test_scroll:GetCanvas().y)")
.expect("probe")
.unwrap_or_default();
let offset: f32 = offset.parse().unwrap_or(0.0);
assert!(offset < -10.0, "колесо не прокрутило канвас: y={offset}");
}
/// Колесо над узлом DTree прокручивает дерево: событие всплывает от кнопки
/// узла до DScrollPanel, а не теряется в базовой заглушке.
#[test]
fn ui_wheel_scrolls_tree() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"tree_scroll_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local tree = vgui.Create( "DTree" )
tree:SetPos( 20, 20 )
tree:SetSize( 200, 160 )
for i = 1, 40 do
tree:AddNode( "Node " .. i )
end
tree:Root():SetExpanded( true, true )
_G.__test_tree = tree
__sr_ui.root = tree
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("дерево создано");
let _ = runtime.ui_snapshot();
let _ = runtime.ui_snapshot();
runtime.ui_mouse("wheel", 100.0, 60.0, -1);
let _ = runtime.ui_snapshot();
let offset = runtime
.probe("return tostring(__test_tree:GetCanvas().y)")
.expect("probe")
.unwrap_or_default();
let offset: f32 = offset.parse().unwrap_or(0.0);
assert!(offset < -10.0, "колесо не прокрутило дерево: y={offset}");
}
/// Клик по заголовку DCollapsibleCategory сворачивает/разворачивает
/// категорию: по этому пути открываются панели инструментов со слайдерами.
#[test]
fn ui_collapsible_category_toggles() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"category_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local list = vgui.Create( "DCategoryList" )
list:SetPos( 20, 20 )
list:SetSize( 220, 300 )
local category = list:Add( "Constraints" )
category:Add( "DButton" ):SetText( "Slider" )
_G.__test_category = category
__sr_ui.root = list
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("категория создана");
let _ = runtime.ui_snapshot();
let expanded = runtime
.probe("return tostring(__test_category:GetExpanded())")
.expect("probe")
.unwrap_or_default();
assert_eq!(expanded, "true", "категория должна быть раскрыта");
runtime.ui_mouse("move", 60.0, 28.0, 0);
runtime.ui_mouse("down", 60.0, 28.0, 1);
runtime.ui_mouse("up", 60.0, 28.0, 1);
let _ = runtime.ui_snapshot();
let expanded = runtime
.probe("return tostring(__test_category:GetExpanded())")
.expect("probe")
.unwrap_or_default();
assert_eq!(expanded, "false", "клик по заголовку не свернул категорию");
}
/// Текст с ContentAlignment 5 (центр) действительно центрируется, а не
/// уезжает по старой таблице выравниваний.
#[test]
fn ui_label_alignment_centers_text() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
runtime
.run(
"align_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local label = vgui.Create( "DLabel" )
label:SetPos( 0, 0 )
label:SetSize( 200, 20 )
label:SetContentAlignment( 5 )
label:SetText( "center" )
__sr_ui.root = label
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("метка создана");
let commands = runtime.ui_snapshot();
let text = commands
.iter()
.find(|command| command.text.as_deref() == Some("center"))
.expect("текст метки");
let expected = (200.0 - text.w) * 0.5;
assert!(
(text.x - expected).abs() < 0.6,
"x={} ожидали {} (w={})",
text.x,
expected,
text.w
);
}
/// Снимок заказывается по dirty-флагу: после снимка флаг снят, сеттер
/// панели снова его поднимает.
#[test]
fn ui_snapshot_dirty_flag_round_trip() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime
.run(
"dirty_test.lua",
br#"
rawset( _G, "g_SpawnMenu", nil )
local p = vgui.Create( "Panel" )
p:SetSize( 100, 100 )
__sr_ui.root = p
__sr_ui.visible = true
__sr_ui.mark_dirty( true )
"#,
)
.expect("панель создана");
assert!(runtime.ui_dirty(), "новое меню должно быть грязным");
let _ = runtime.ui_snapshot();
assert!(!runtime.ui_dirty(), "после снимка флаг должен сняться");
runtime
.run("dirty_test_set.lua", br#"__sr_ui.root:SetSize( 120, 100 )"#)
.expect("сеттер");
assert!(runtime.ui_dirty(), "SetSize должен поднять dirty-флаг");
}
/// Категории спавн-меню (settings/spawnlist_default/*.txt) реально читаются /// Категории спавн-меню (settings/spawnlist_default/*.txt) реально читаются
/// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой. /// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой.
#[test] #[test]

View file

@ -8,6 +8,57 @@
--- ---
## 0.11. Q-меню: отзывчивость, слайдеры, колесо и выравнивание (2026-10-24, ветка `feat/menu`)
Жалобы пользователя: лаги Q-меню на release, не работают слайдеры, колесо не
листает списки/дерево, текст «пляшет», часть иконок — заглушки.
- **Профиль до.** `SOURCE_RS_UI_PROFILE=1`: снимок Lua пересобирался раз в 6
кадров, avg 6,4/4,1 мс, max 84/47 мс; 1445 команд; отрисовка 0,17 мс;
FPS с меню 16,8–17,3 против 20,7–20,9 без меню (Xvfb, замеры шли
параллельно и мешали друг другу).
- **Причина.** Дорогой обход всего дерева и `DTileLayout` по тысячам иконок
каждые 6 кадров; `emit` создавал команды для полностью обрезанных панелей;
`sort_children` сортировал всех детей сетки; hover на каждом движении мыши
звал `OnCursorEntered` → `InvalidateLayout`; снимок не знал, изменилось ли
что-то на самом деле.
- **Снимок по dirty-флагу.** `ui.mark_dirty(layout)` зовут сеттеры, ввод и
таймеры; Rust каждый кадр делает `ui_tick()` (таймеры) и `ui_dirty()`, а
`ui_snapshot_into` — только при изменении. `layout_dirty` отделён от
отрисовки: hover не пересчитывает геометрию, колесо/перетаскивание — да.
`paint_panel` не заходит в поддеревья вне clip, `emit` отбрасывает полностью
невидимые команды, `sort_children` не сортирует панели без ZPos.
- **Профиль после.** В покое снимок avg 0,26–0,5 мс, max 18–27 мс (редкая
страховочная пересборка), 677 команд; отрисовка 0,13–0,17 мс. FPS с меню
18,9/20,5, без меню 17,6/16,9 — разница в пределах разброса. Первый кадр
после открытия — до ~95 мс (раскладка + первая порция иконок); для этого
меню прогревается на загрузке карты (`SOURCE_RS_UI_NO_PREWARM=1` отключает).
- **Слайдеры.** В `VGUI_FILES` добавлен `dnumberscratch.lua`; добавлены
`NoClipping`, `GetCaretPos/SetCaretPos`, `input.SetCursorPos`. Реальные
`DSlider`/`DNumSlider` теперь создаются и тянутся: `OnCursorMoved` получает
локальные координаты, `MouseCapture` удерживает ползунок вне панели. Тест
`ui_num_slider_drags_and_updates_value` тянет настоящий DNumSlider.
- **Колесо.** Обработчик всплывает от кнопки/иконки к родителям до
`DScrollPanel`/`DTree`, пока не вернёт `true`. Починен корень `DTree`:
`PerformRootNodeLayout` брал высоту `ChildNodes` до `DListLayout`, из-за чего
canvas был 15 px и VBar выключен; теперь размеры детей считаются раньше.
Тесты `ui_wheel_scrolls_scroll_panel` и `ui_wheel_scrolls_tree` проверяют
сдвиг канваса.
- **Выравнивание.** `surface.GetTextSize` теперь совпадает с bitmap-метрикой
рендера (8 px × масштаб на байт), вертикаль считается по высоте глифа.
`ContentAlignment` разобран как в GMod (1–3 низ, 4–6 середина, 7–9 верх;
внутри ряда лево/центр/право), вкладки центрируются, дерево — слева с
отступом, заглушка иконки — две строки по центру с обрезкой по иконке.
- **Иконки.** Очередь видимых моделей сортируется по площади на экране и
грузится до 8 штук/~8 мс за кадр; кэш LRU переиспользуется при повторном
открытии. Заглушки остаются только для реально отсутствующих в VFS моделей.
- **Проверка.** `cargo test -p glua --test ui` (9 тестов, включая перетаскивание
DNumSlider и колесо над DTree), кадры
`SOURCE_RS_QMENU=1 ... --frames 90 --screenshot`, клики
`SOURCE_RS_UI_CLICK='Entities'`, колесо `SOURCE_RS_UI_WHEEL=x,y,d`.
---
## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`) ## 0.8. Q-меню Sandbox в клиенте (2026-10-24, ветка `feat/ui-render`)
Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs` Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs`

View file

@ -47,16 +47,45 @@
всех родителей; `surface.DisableClipping(true)` временно его снимает всех родителей; `surface.DisableClipping(true)` временно его снимает
(тени рамок рисуются за границей, как в GMod). Обрезка выполняется на CPU (тени рамок рисуются за границей, как в GMod). Обрезка выполняется на CPU
по вершинам, поэтому длинные подписи дерева не вылезают на содержимое. по вершинам, поэтому длинные подписи дерева не вылезают на содержимое.
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse` `paint_panel` не заходит в поддеревья за пределами clip, а `emit` не
(+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты — пиксели экрана. создаёт полностью обрезанные команды: снимок большой сетки не упирается
в лимит 4096 и не тратит время на невидимые иконки.
- Снимок заказывается по dirty-флагу: сеттеры панелей, ввод и таймеры зовут
`ui.mark_dirty(layout)`. Rust каждый кадр прокачивает `ui_tick()` (таймеры),
проверяет `ui_dirty()` и пересобирает снимок только при изменении; раз в
60 кадров — страховка. `layout_dirty` отдельно от отрисовки: hover меняет
только картинку, колесо/перетаскивание — геометрию. Это убрало пересборку
Lua-дерева раз в 6 кадров (было 30–80 мс на кадр, стало ~0,3 мс в покое).
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse/
ui_dirty/ui_tick` (+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты —
пиксели экрана.
- Скин: после `skins/default.lua` вызывается `ui.apply_light_skin()` — - Скин: после `skins/default.lua` вызывается `ui.apply_light_skin()` —
светло-серые панели, тёмный текст, голубые выделения. Настоящая текстура светло-серые панели, тёмный текст, голубые выделения. Настоящая текстура
`gwenskin` не загружается; `surface.DrawTexturedRectUV` подбирает цвет по `gwenskin` не загружается; `surface.DrawTexturedRectUV` подбирает цвет по
UV-области (панель/кнопка/вкладка/список), поэтому меню светлое, а не UV-области (панель/кнопка/вкладка/список), поэтому меню светлое, а не
тёмное, как было при подмене крупных белых плиток тёмным фоном. тёмное, как было при подмене крупных белых плиток тёмным фоном.
- Ввод: поиск верхней видимой панели под курсором, вызовы - Ввод: поиск верхней видимой панели под курсором (учитывается вся цепочка
`OnMousePressed/OnMouseReleased/OnCursorMoved/OnMouseWheeled`; клик по вкладке видимости, а не только сам флаг панели), вызовы `OnMousePressed/
меняет активную панель. OnMouseReleased/OnCursorMoved/OnMouseWheeled`; `OnCursorMoved` получает
координаты относительно панели, как в GMod. `MouseCapture` удерживает
перетаскивание (ползунок слайдера) вне границ панели. Колесо всплывает от
кнопки/иконки к родителю до `DScrollPanel`/`DTree`, пока обработчик не
вернёт `true`; клик по вкладке меняет активную панель. Hover не зовёт
`OnCursorEntered` (его `InvalidateLayout` раскладывал всё дерево на каждом
движении мыши) — скины читают `Hovered`.
- Слайдеры: настоящие `DSlider`/`DNumSlider`/`DNumberScratch` загружаются
(`dnumberscratch.lua` добавлен в `VGUI_FILES`); добавлены недостающие
движковые методы `NoClipping`, `GetCaretPos/SetCaretPos`, `input.SetCursorPos`.
Перетаскивание меняет `SlideX` → `SetValue` → `OnValueChanged`, число
показывается в `DTextEntry`. Если файл контрола отсутствует, в fallback
есть минимальные `DSlider`/`DNumSlider`/`DNumberScratch`.
- Текст: `surface.GetTextSize` считает ширину по тем же метрикам, что
`ui_overlay::text_scale` (8 px на байт × масштаб), а вертикальное
выравнивание — по фактической высоте глифа. `ContentAlignment` разобран по
«цифровой клавиатуре» GMod (4/5/6 — лево/центр/право, 7/8/9 — верх);
вкладки `DTab` принудительно центрируются, дерево остаётся слева с
отступом. Многострочная заглушка иконки рисуется двумя строками по центру
и обрезается прямоугольником самой иконки.
- Клиент (`crates/client/src/map.rs`, `ui_overlay.rs`) рисует снимок поверх - Клиент (`crates/client/src/map.rs`, `ui_overlay.rs`) рисует снимок поверх
кадра: прямоугольники — точными квадами, текст — bitmap-шрифтом (scale = кадра: прямоугольники — точными квадами, текст — bitmap-шрифтом (scale =
h/12), иконки — текстурированными квадами между командами снимка, цвета h/12), иконки — текстурированными квадами между командами снимка, цвета
@ -66,16 +95,21 @@
откатывается на встроенное `qmenu.rs`. откатывается на встроенное `qmenu.rs`.
- 3D-иконки (`render::ModelIcons`): модель грузится `engine::load_model`, - 3D-иконки (`render::ModelIcons`): модель грузится `engine::load_model`,
рисуется в offscreen-текстуру 128×128 камерой «три четверти» на светлом рисуется в offscreen-текстуру 128×128 камерой «три четверти» на светлом
фоне, в UI кэш LRU на 512 путей. Клиент просит не больше 3 новых иконок за фоне, в UI кэш LRU на 512 путей. Клиент каждый кадр собирает отсутствующие
кадр; отсутствующие/битые пути запоминаются, чтобы не грузить их повторно. видимые иконки, сортирует по площади на экране и грузит до 8 штук или ~8 мс
Пока текстуры нет, `ui_overlay` рисует светлую заглушку с именем файла. за кадр — очередь доходит до всей категории, а не только до первых трёх.
Отсутствующие/битые пути запоминаются, чтобы не грузить их повторно. Пока
текстуры нет, `ui_overlay` рисует светлую заглушку: имя файла в две строки
по центру, обрезанное прямоугольником иконки. При повторном открытии меню
кэш переиспользуется.
- Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь - Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь
(`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и (`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и
`gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа. `gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа.
- Ограничения реализации: нет HTML-вкладок Dupes/Saves, приблизительные - Ограничения реализации: нет HTML-вкладок Dupes/Saves, приблизительные
шрифтовые метрики, текстура скина `gwenskin` заменена палитрой по UV (не шрифтовые метрики (bitmap 8×8, не TTF), текстура скина `gwenskin` заменена
пиксель-в-пиксель с оригиналом), иконки грузятся синхронно по 3 за кадр палитрой по UV (не пиксель-в-пиксель с оригиналом). Иконки грузятся
(первый показ большой категории может дать просадку FPS). синхронно в главном потоке, но порциями по времени; первый показ большой
категории может дать несколько кадров с заглушками.
## 3. Sandbox (защита от крашедла) ## 3. Sandbox (защита от крашедла)