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:
parent
58daab03be
commit
1b24f280d3
8 changed files with 1070 additions and 147 deletions
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
56
docs/LUA.md
56
docs/LUA.md
|
|
@ -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 (защита от крашедла)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue