From 1b24f280d3f154a42a3473d06e7b5c4b31b6fe88 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 20:35:56 +0300 Subject: [PATCH] =?UTF-8?q?Q-=D0=BC=D0=B5=D0=BD=D1=8E:=20dirty-=D1=81?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA,=20=D1=81=D0=BB=D0=B0=D0=B9?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D1=8B,=20=D0=BA=D0=BE=D0=BB=D0=B5=D1=81?= =?UTF-8?q?=D0=BE=20=D0=B8=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снимок 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-кэш переиспользуется. --- crates/client/src/map.rs | 178 ++++++++-- crates/client/src/ui_overlay.rs | 53 ++- crates/lua/src/lib.rs | 1 + crates/lua/src/ui.rs | 35 ++ crates/lua/src/ui/bootstrap.lua | 585 +++++++++++++++++++++++++++----- crates/lua/tests/ui.rs | 258 ++++++++++++++ docs/DIARY.md | 51 +++ docs/LUA.md | 56 ++- 8 files changed, 1070 insertions(+), 147 deletions(-) diff --git a/crates/client/src/map.rs b/crates/client/src/map.rs index dd1a896..b1783a9 100644 --- a/crates/client/src/map.rs +++ b/crates/client/src/map.rs @@ -551,6 +551,21 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { if lua_ui_ready { log.push_back("ui: Lua Sandbox".to_string()); 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 { log.push_back("ui: fallback Q-меню".to_string()); warn!("Q-меню: g_SpawnMenu не создан, остаётся встроенное меню"); @@ -774,12 +789,29 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { pending_spawns.push(menu_spawn_request(item, &collision, &player, camera.yaw)); } // `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 = std::env::var("SOURCE_RS_UI_CLICK") .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let mut ui_click_done = false; + .map(|value| { + value + .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::()); + 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=<кадр>` — закрыть меню после отладочного клика, // чтобы на скриншоте был виден заспавненный проп, а не только панели. let ui_close_frame = std::env::var("SOURCE_RS_UI_CLOSE") @@ -792,6 +824,16 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Снимок панелей Derma — дорогой (Lua + строки), поэтому обновляем его // не каждый кадр: по событиям ввода и раз в несколько кадров для анимаций. 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; // Палец, который сейчас нажимает в Lua-панель (остальные — камера/стик). let mut ui_touch_finger: Option = None; @@ -1161,8 +1203,8 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } Event::MouseMotion { x, y, .. } if lua_ui_open => { - ui_snapshot_age = u32::MAX; - // Настоящее меню само решает, какая панель под курсором. + // Настоящее меню само решает, какая панель под курсором; + // снимок закажет только смена hover или перетаскивание. if let Some(lua) = &lua { lua.ui_mouse("move", x, y, 0); } @@ -1296,29 +1338,37 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { } } - // Отладочный клик по снимку прошлого кадра: вкладка, категория или + // Отладочные клики по снимку прошлого кадра: вкладка, категория или // модель. Скрытое окно не получает внешних кликов, поэтому проверка - // спавна идёт тем же `ui_mouse`, что и настоящая мышь. + // идёт тем же `ui_mouse`, что и настоящая мышь. if lua_ui_open - && !ui_click_done - && frames_drawn >= 8 - && let Some(spec) = ui_click.as_deref() + && ui_click_index < ui_clicks.len() + && frames_drawn >= 8 + ui_click_index as u64 * 8 { - ui_click_done = true; - match ui_overlay::click_point(&ui_commands, spec) { + let spec = ui_clicks[ui_click_index].clone(); + ui_click_index += 1; + match ui_overlay::click_point(&ui_commands, &spec) { Some((x, y)) => { - info!(spec, x, y, "Lua UI: отладочный клик"); + info!(spec = %spec, x, y, "Lua UI: отладочный клик"); if let Some(lua) = &lua { lua.ui_mouse("move", x, y, 0); lua.ui_mouse("down", 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() { - ui_click_done = true; + if lua_ui_open + && !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 @@ -1968,43 +2018,101 @@ pub fn map(assets: Option, args: MapArgs) -> anyhow::Result<()> { // Настоящее меню Sandbox: снимок панелей из Lua, примитивами клиента. if lua_ui_open { ui_snapshot_age = ui_snapshot_age.saturating_add(1); - // 6 кадров (~0.1 с) — компромисс: меню отзывчиво, но Lua не - // пересобирает дерево на каждый кадр (это и давало лаги). - if (ui_snapshot_age >= 6 || ui_commands.is_empty()) + // Снимок пересобирается только когда Lua пометил состояние + // грязным (ввод, таймер, сеттер панели, анимация). Раз в 60 + // кадров — страховка от забытого флага, не рабочий режим. + 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 started = Instant::now(); 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; } - // 3D-иконки: не больше трёх новых моделей за кадр. Загрузка MDL - // и распаковка текстур дорогие, а на экране одновременно видно - // несколько десятков иконок — кэш наполняется за пару секунд без - // рывков. Невидимые (за краем содержимого) не грузим вовсе. - const MAX_ICON_LOADS_PER_FRAME: usize = 3; - let mut icon_loads = 0usize; + // 3D-иконки: грузим видимые модели по убыванию площади на экране, + // не больше 8 за кадр и не дольше ~8 мс. Так очередь доходит до + // всех иконок, а не только до первых трёх, и не даёт рывка. + const MAX_ICON_LOADS_PER_FRAME: usize = 8; + const ICON_LOAD_BUDGET_US: u128 = 8_000; + let icons_started = Instant::now(); + let mut candidates: Vec<(f32, &str)> = Vec::new(); for command in &ui_commands { if command.kind != glua::UiKind::Image { continue; } - if icon_loads >= MAX_ICON_LOADS_PER_FRAME { - break; - } let Some(model) = command.model.as_deref() else { continue; }; - let visible = command.x < width as f32 - && command.y < height as f32 - && command.x + command.w > 0.0 - && command.y + command.h > 0.0; - if !visible || renderer.has_model_icon(model) { + if renderer.has_model_icon(model) { 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; if !renderer.ensure_model_icon(&gfx, &vfs, model) { 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 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 { debug!( commands = ui_commands.len(), diff --git a/crates/client/src/ui_overlay.rs b/crates/client/src/ui_overlay.rs index b6cb8fb..dc49da0 100644 --- a/crates/client/src/ui_overlay.rs +++ b/crates/client/src/ui_overlay.rs @@ -149,28 +149,61 @@ pub fn draw( stats.skipped += 1; 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( command.x, command.y, command.w, command.h, linear_color([0.93, 0.93, 0.93, 1.0]), - command.clip, + clip, ); let name = model .rsplit(['/', '\\']) .next() .unwrap_or(model) .trim_end_matches(".mdl"); - let name: String = name.chars().take(24).collect(); - renderer.push_text_clipped( - command.x + 3.0, - command.y + (command.h - 10.0).max(0.0), - 1.0, - linear_color([0.12, 0.12, 0.12, 1.0]), - name, - command.clip, - ); + let scale = 0.8f32; + let glyph = 8.0 * scale; + let max_chars = ((command.w / glyph).floor() as usize).max(1); + let mut chars = name.chars(); + let first: String = chars.by_ref().take(max_chars).collect(); + let second: String = chars.take(max_chars).collect(); + let lines: Vec<&str> = if second.is_empty() { + 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.glyphs += cost; } diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index 5d44f8f..03fc977 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -92,6 +92,7 @@ const VGUI_FILES: &[&str] = &[ "dlabeleditable.lua", "dslider.lua", "dnumberwang.lua", + "dnumberscratch.lua", "dnumslider.lua", "dcolorbutton.lua", "dcolormixer.lua", diff --git a/crates/lua/src/ui.rs b/crates/lua/src/ui.rs index 7861044..fc03c5d 100644 --- a/crates/lua/src/ui.rs +++ b/crates/lua/src/ui.rs @@ -79,6 +79,15 @@ pub(crate) fn install(lua: &Lua) -> Result<(), mlua::Error> { ui.set("pressed", Value::Nil)?; ui.set("root", Value::Nil)?; ui.set("paint_errors", 0)?; + // Грязный флаг: снимок пересобирается только после реального изменения + // состояния (ввод, таймер, сеттеры панелей), а не по таймеру кадров. + ui.set("dirty", true)?; + ui.set("layout_dirty", true)?; + ui.set("animating", false)?; + ui.set("animated_frame", false)?; + ui.set("hovered", Value::Nil)?; + ui.set("capture", Value::Nil)?; + ui.set("hover_chain", Value::Nil)?; lua.globals().set("__sr_ui", &ui)?; // Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором. @@ -117,6 +126,30 @@ impl crate::LuaRuntime { .unwrap_or(false) } + /// Нужен ли новый снимок панелей: Lua выставляет флаг при вводе, + /// срабатывании таймера и изменении свойств панели. + pub fn ui_dirty(&self) -> bool { + catch_unwind(AssertUnwindSafe(|| { + let ui: Table = self.lua.globals().get("__sr_ui")?; + ui.get::("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-слоем. /// /// Ошибка одной панели не должна ронять меню и кадр: клиент показывает @@ -296,6 +329,8 @@ impl crate::LuaRuntime { let ui: Table = self.lua.globals().get("__sr_ui")?; ui.set("screen_w", width.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 занимает весь экран. if let Ok(Value::Table(root)) = ui.get::("root") && let Ok(resize) = root.get::("__sr_resize_to_screen") diff --git a/crates/lua/src/ui/bootstrap.lua b/crates/lua/src/ui/bootstrap.lua index 918d9a3..bdbde32 100644 --- a/crates/lua/src/ui/bootstrap.lua +++ b/crates/lua/src/ui/bootstrap.lua @@ -40,6 +40,18 @@ function ui.take_errors() return list 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. Значения совпадают с движком и нужны скриптам как данные. NODOCK = 0 FILL = 1 @@ -196,6 +208,20 @@ local function font_size(name) return number(fonts[name], 13) 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) if not in_snapshot or ui.commands == nil then return end local commands = ui.commands @@ -204,16 +230,26 @@ local function emit(kind, x, y, width, height, color, text, model) if color ~= nil then r, g, b, a = color_parts(color, 255) 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 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] } end commands[#commands + 1] = { kind = kind, - x = number(x, 0) + draw_origin_x, - y = number(y, 0) + draw_origin_y, - w = math.max(number(width, 0), 0), - h = math.max(number(height, 0), 0), + x = px, + y = py, + w = pw, + h = ph, color = { r / 255, g / 255, b / 255, a / 255 }, text = text, model = model, @@ -244,20 +280,12 @@ end -- Приблизительная метрика: рендер-агент не обязан совпадать пиксель в -- пиксель, но раскладка и центрирование должны быть правдоподобными. +-- Ширина — ровно столько, сколько займёт bitmap-строка в рендере: один +-- байт = один глиф 8×8 с тем же масштабом, что и в `ui_overlay`. function surface.GetTextSize(value) local text = text_of(value) or "" local size = font_size(current_font) - local width = 0 - 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 + local width = #text * 8 * glyph_scale(size) return width, size end @@ -492,11 +520,14 @@ function draw.SimpleText(value, font, x, y, color, align_x, align_y) elseif align_x == TEXT_ALIGN_RIGHT then px = px - w end + local rendered_h = glyph_height(h) 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 - py = py - h + py = py - rendered_h end + -- Высота команды — размер шрифта: по ней `ui_overlay` выбирает масштаб + -- глифа. Верх строки уже сдвинут по фактической высоте глифа. emit("text", px, py, w, h, color, text_of(value)) end 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.IsKeyDown() return false 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.GetKeyName(code) return tostring(code) end function input.IsKeyTrapping() return false end @@ -1259,6 +1294,7 @@ function ui.pump() if now >= entry.at then due[#due + 1] = name end end table.sort(due) + if #due > 0 then ui.mark_dirty(true) end for _, name in ipairs(due) do local entry = lua_timers[name] if entry ~= nil then @@ -1405,6 +1441,7 @@ function panel_meta:SetParent(parent) local ok, err = pcall(parent.OnChildAdded, parent, self) if not ok then ui.log_error("OnChildAdded: " .. tostring(err)) end end + ui.mark_dirty(true) end function panel_meta:GetChildren() local out = {} @@ -1414,20 +1451,22 @@ end function panel_meta:MoveToFront() local parent = self.__parent if parent == nil then return end - local list = parent.__children or {} - for index = #list, 1, -1 do - if list[index] == self then table.remove(list, index) end + local top = 0 + for _, child in ipairs(parent.__children or {}) do + if child ~= self then top = math.max(top, number(child.__zpos, 0)) end end - list[#list + 1] = self + self.__zpos = top + 1 + ui.mark_dirty(true) end function panel_meta:MoveToBack() local parent = self.__parent if parent == nil then return end - local list = parent.__children or {} - for index = #list, 1, -1 do - if list[index] == self then table.remove(list, index) end + local bottom = 0 + for _, child in ipairs(parent.__children or {}) do + if child ~= self then bottom = math.min(bottom, number(child.__zpos, 0)) end end - table.insert(list, 1, self) + self.__zpos = bottom - 1 + ui.mark_dirty(true) end function panel_meta:InsertAtTop(child) if child == nil then return end @@ -1506,12 +1545,14 @@ function panel_meta:GetTall() return self.h end function panel_meta:SetPos(x, y) self.x = number(x, self.x) self.y = number(y, self.y) + ui.mark_dirty(true) end -function panel_meta:SetX(x) self.x = number(x, self.x) end -function panel_meta:SetY(y) self.y = number(y, self.y) 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) ui.mark_dirty(true) end function panel_meta:SetSize(w, h) self.w = math.max(number(w, self.w), 0) self.h = math.max(number(h, self.h), 0) + ui.mark_dirty(true) end function panel_meta:SetWide(w) self:SetSize(w, self.h) end function panel_meta:SetTall(h) self:SetSize(self.w, h) end @@ -1548,6 +1589,9 @@ end function panel_meta:SetVisible(visible) self.__visible = visible and true or false + -- Видимость не меняет геометрию: раскладку не заказываем, иначе первое + -- открытие меню снова гоняет DTileLayout по тысячам иконок. + ui.mark_dirty(false) end function panel_meta:IsVisible() return self.__visible ~= false and not self.__removed @@ -1562,9 +1606,9 @@ function panel_meta:IsVisibleChain() end return true 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: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:SetMouseInputEnabled(value) self.__mouse_input = value and true or 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) self.__dock = number(mode, NODOCK) + ui.mark_dirty(true) end function panel_meta:GetDock() return self.__dock end function panel_meta:DockMargin(top, right, bottom, left) self.__dock_margin = { number(top, 0), number(right, 0), number(bottom, 0), number(left, 0), } + ui.mark_dirty(true) end function panel_meta:GetDockMargin() local m = self.__dock_margin @@ -1602,6 +1648,7 @@ function panel_meta:DockPadding(top, right, bottom, left) self.__dock_padding = { number(top, 0), number(right, 0), number(bottom, 0), number(left, 0), } + ui.mark_dirty(true) end function panel_meta:GetDockPadding() local p = self.__dock_padding @@ -1638,10 +1685,13 @@ end function panel_meta:LoadCookies() end function panel_meta:SaveCookies() end -function panel_meta:InvalidateLayout() end +function panel_meta:InvalidateLayout() + ui.mark_dirty(true) +end function panel_meta:InvalidateParent() local parent = self.__parent if parent ~= nil then parent.__layout_dirty = true end + ui.mark_dirty(true) end function panel_meta:InvalidateChildren() 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: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: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:DragMouseRelease() return false end function panel_meta:IsDraggable() return self.__draggable == true end @@ -1817,10 +1876,14 @@ function panel_meta:PaintOver() end function panel_meta:PaintAt() end -- Базовый 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: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: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:SetFGColor(r, g, b, a) if type(r) == "table" or type(r) == "userdata" then @@ -1829,27 +1892,28 @@ function panel_meta:SetFGColor(r, g, b, a) else self.__fg = { number(r, 255), number(g, 255), number(b, 255), number(a, 255) } end + ui.mark_dirty(false) end function panel_meta:GetFGColor() local c = self.__fg or { 255, 255, 255, 255 } return Color(c[1], c[2], c[3], c[4]) 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() local c = self.__text_color or self.__fg if c == nil then return nil end if type(c) == "table" and c.r ~= nil then return c end return Color(c[1], c[2], c[3], c[4]) 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() local c = self.__text_style_color if c == nil then return nil end return Color(c.r, c.g, c.b, c.a) 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: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() return self.__text_inset[1], self.__text_inset[2] end @@ -1880,9 +1944,10 @@ function panel_meta:DrawTextEntryText(text_color) surface.SetTextColor(text_color or color_black) local _, text_height = surface.GetTextSize(value) local inset_x, inset_y = self:GetTextInset() + local rendered_h = ui.glyph_height(text_height) surface.SetTextPos( 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) end @@ -1970,6 +2035,8 @@ function IsValid(value) end -- Рисование текста 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) local value = panel:GetText() 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 inset_x, inset_y = panel:GetTextInset() 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 y - if align == 1 or align == 4 or align == 5 then - x = math.max((width - text_width) * 0.5, inset_x) - elseif align == 2 or align == 6 or align == 8 then + if horizontal == 1 then + x = inset_x + math.max((width - inset_x * 2 - text_width) * 0.5, 0) + elseif horizontal == 2 then x = math.max(width - text_width - inset_x, inset_x) else x = inset_x end - if align >= 6 then - y = height - text_height - inset_y - elseif align == 4 or align == 5 or align == 1 then - y = (height - text_height) * 0.5 + local rendered_h = ui.glyph_height(text_height) + local y + if vertical == 1 then + y = math.max((height - rendered_h) * 0.5, 0) + elseif vertical == 0 then + y = math.max(height - rendered_h - inset_y, 0) else y = inset_y end @@ -2017,8 +2095,19 @@ local function absolute_pos(panel) end 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 = {} - for index, child in ipairs(panel.__children or {}) do + for index, child in ipairs(list) do children[index] = { child = child, index = index } end table.sort(children, function(a, b) @@ -2038,7 +2127,7 @@ local function arrange_children(panel) local top = padding_top local right = panel.w - padding_right 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 local margin_top, margin_right, margin_bottom, margin_left = child:GetDockMargin() local dock = child.__dock @@ -2073,6 +2162,7 @@ local function arrange_children(panel) end end end +ui.arrange_children = arrange_children function ui.layout_tree(panel, depth, seen) depth = depth or 0 @@ -2088,6 +2178,9 @@ function ui.layout_tree(panel, depth, seen) if canvas ~= nil and canvas.__parent ~= panel then canvas:SetParent(panel) end + -- Dock-раскладка должна быть применена до PerformLayout: DScrollPanel + -- вызывает VBar:SetUp с высотой скроллбара, а её выставляет Dock(RIGHT). + arrange_children(panel) if panel.PerformLayout ~= nil then local ok, err = pcall(panel.PerformLayout, panel, panel.w, panel.h) if not ok then @@ -2100,6 +2193,20 @@ function ui.layout_tree(panel, depth, seen) 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() local root = rawget(G, "g_SpawnMenu") 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 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) if depth > ui.depth_limit or count >= ui.command_limit then return count end if not panel:IsVisible() then return count end local x = origin_x + (panel.x 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. Панели без размера -- (служебные контейнеры) не сужают область: иначе их содержимое исчезло бы. local previous_clip = current_clip - local w, h = number(panel.w, 0), number(panel.h, 0) if w > 0 and h > 0 then 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 @@ -2162,6 +2281,42 @@ local function think_panel(panel, depth, seen) ui.log_error("Think " .. tostring(panel.ClassName or panel.__engine) .. ": " .. tostring(err)) 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 think_panel(child, depth + 1, seen) end @@ -2172,14 +2327,19 @@ function ui.snapshot() ui.pump() local root = ui.resolve_root() if not ui.visible or root == nil or not root:IsVisible() then + ui.dirty = false + ui.animating = false + ui.animated_frame = false return out end - if root.w <= 0 or root.h <= 0 then - root:SetPos(0, 0) - root:SetSize(ScrW(), ScrH()) - end - ui.layout_tree(root, 0, {}) + ui.animating = false + ui.animated_frame = false + ui.ensure_layout(root) think_panel(root, 0, {}) + -- Think/анимации могли изменить размеры (раскрытие ветки дерева, + -- переключение вкладки): раскладываем ещё раз до Paint, иначе подпись + -- останется с шириной 0 на один кадр. + if ui.layout_dirty then ui.ensure_layout(root) end ui.commands = out in_snapshot = true draw_origin_x, draw_origin_y = 0, 0 @@ -2189,12 +2349,20 @@ function ui.snapshot() current_clip = nil in_snapshot = false ui.commands = nil + -- Сеттеры, которые зовёт раскладка/рисование, не должны сами по себе + -- заказывать следующий снимок. Грязным остаётся только кадр, в котором + -- реально идёт анимация. + ui.dirty = ui.animating + if not ui.animated_frame then + ui.layout_dirty = false + end return out end function ui.toggle(open) open = open and true or false ui.visible = open + ui.mark_dirty(false) local root = ui.resolve_root() if root == nil then return end if open then @@ -2222,7 +2390,7 @@ local function point_inside(panel, px, py) end 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) for index = #children, 1, -1 do local child = children[index] @@ -2237,54 +2405,104 @@ local function find_target(panel, px, py, depth) return nil end -local function clear_hovered(panel, depth) - if depth > ui.depth_limit then return end - panel.__hovered = false - panel.Hovered = false - for _, child in ipairs(panel.__children or {}) do - clear_hovered(child, depth + 1) +local function clear_hover_chain() + local chain = ui.hover_chain + if chain ~= nil then + for _, panel in ipairs(chain) do + panel.__hovered = false + panel.Hovered = false + 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 function ui.mouse(kind, x, y, button) ui.pump() -- Новые панели могли появиться после прошлого кадра: перед попаданием - -- нужна та же раскладка, что и при рисовании. + -- нужна та же раскладка, что и при рисовании. Если геометрия не менялась, + -- повторный обход дерева не нужен. local root = ui.resolve_root() 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_y = number(y, 0) button = number(button, 1) - clear_hovered(root, 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 target ~= nil and target.OnCursorMoved ~= nil then - local ok, err = pcall(target.OnCursorMoved, target, ui.mouse_x, ui.mouse_y) + set_hovered(target) + 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 end elseif kind == "down" then + set_hovered(target) ui.pressed = target - if target ~= nil then - if target.OnMousePressed ~= nil then - local ok, err = pcall(target.OnMousePressed, target, button) - if not ok then ui.log_error("OnMousePressed: " .. tostring(err)) end - end + if target ~= nil and target.OnMousePressed ~= nil then + local ok, err = pcall(target.OnMousePressed, target, button) + if not ok then ui.log_error("OnMousePressed: " .. tostring(err)) end end + ui.mark_dirty(false) elseif kind == "up" then + set_hovered(target) local pressed = ui.pressed ui.pressed = nil 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) if not ok then ui.log_error("OnMouseReleased: " .. tostring(err)) end end + ui.capture = nil + ui.mark_dirty(false) elseif kind == "wheel" then + set_hovered(target) local delta = button if delta == 0 then delta = 1 end if delta ~= 1 and delta ~= -1 then if delta > 1 then delta = 1 else delta = -1 end end - if target ~= nil then - local handler = target.OnMouseWheeled or target.OnMouseWheel - if handler ~= nil then - local ok, err = pcall(handler, target, delta) - if not ok then ui.log_error("OnMouseWheel: " .. tostring(err)) end - end - end + wheel_target(target, delta) + ui.mark_dirty(true) end end @@ -3140,6 +3356,163 @@ function __sr_ui_install_fallbacks() register("ModelImage", ModelImage, "DPanel") 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 panel = {} @@ -3377,6 +3750,35 @@ function __sr_ui_finalize() 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 и без -- зависимости от устройства scriptedpanels. function vgui.CreateFromTable(class_table, parent, name) @@ -3414,4 +3816,5 @@ end function ui.set_screen(width, height) ui.screen_w = number(width, ui.screen_w) ui.screen_h = number(height, ui.screen_h) + ui.mark_dirty(true) end diff --git a/crates/lua/tests/ui.rs b/crates/lua/tests/ui.rs index 36b874a..5618615 100644 --- a/crates/lua/tests/ui.rs +++ b/crates/lua/tests/ui.rs @@ -89,6 +89,11 @@ fn ui_spawnmenu_snapshot_and_click() { // Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают // вкладки и содержимое спавн-меню; второй — уже для проверки. 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(); dump("первый", &first); assert!( @@ -172,6 +177,259 @@ fn ui_snapshot_has_a_bounded_command_count() { 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) реально читаются /// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой. #[test] diff --git a/docs/DIARY.md b/docs/DIARY.md index 5712941..2d2e1dc 100644 --- a/docs/DIARY.md +++ b/docs/DIARY.md @@ -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`) Настоящее меню из `feat/derma` подключено к клиенту; самодельный `qmenu.rs` diff --git a/docs/LUA.md b/docs/LUA.md index 62a8c35..6db8792 100644 --- a/docs/LUA.md +++ b/docs/LUA.md @@ -47,16 +47,45 @@ всех родителей; `surface.DisableClipping(true)` временно его снимает (тени рамок рисуются за границей, как в GMod). Обрезка выполняется на CPU по вершинам, поэтому длинные подписи дерева не вылезают на содержимое. -- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse` - (+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты — пиксели экрана. + `paint_panel` не заходит в поддеревья за пределами clip, а `emit` не + создаёт полностью обрезанные команды: снимок большой сетки не упирается + в лимит 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()` — светло-серые панели, тёмный текст, голубые выделения. Настоящая текстура `gwenskin` не загружается; `surface.DrawTexturedRectUV` подбирает цвет по 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`) рисует снимок поверх кадра: прямоугольники — точными квадами, текст — bitmap-шрифтом (scale = h/12), иконки — текстурированными квадами между командами снимка, цвета @@ -66,16 +95,21 @@ откатывается на встроенное `qmenu.rs`. - 3D-иконки (`render::ModelIcons`): модель грузится `engine::load_model`, рисуется в offscreen-текстуру 128×128 камерой «три четверти» на светлом - фоне, в UI кэш LRU на 512 путей. Клиент просит не больше 3 новых иконок за - кадр; отсутствующие/битые пути запоминаются, чтобы не грузить их повторно. - Пока текстуры нет, `ui_overlay` рисует светлую заглушку с именем файла. + фоне, в UI кэш LRU на 512 путей. Клиент каждый кадр собирает отсутствующие + видимые иконки, сортирует по площади на экране и грузит до 8 штук или ~8 мс + за кадр — очередь доходит до всей категории, а не только до первых трёх. + Отсутствующие/битые пути запоминаются, чтобы не грузить их повторно. Пока + текстуры нет, `ui_overlay` рисует светлую заглушку: имя файла в две строки + по центру, обрезанное прямоугольником иконки. При повторном открытии меню + кэш переиспользуется. - Спавн из меню: `RunConsoleCommand` складывает вызовы в очередь (`LuaRuntime::take_console_commands`), клиент исполняет `gm_spawn` (и `gm_spawnsent`/`gm_spawnswep` через реестры ENT/SWEP) обычным спавном пропа. - Ограничения реализации: нет HTML-вкладок Dupes/Saves, приблизительные - шрифтовые метрики, текстура скина `gwenskin` заменена палитрой по UV (не - пиксель-в-пиксель с оригиналом), иконки грузятся синхронно по 3 за кадр - (первый показ большой категории может дать просадку FPS). + шрифтовые метрики (bitmap 8×8, не TTF), текстура скина `gwenskin` заменена + палитрой по UV (не пиксель-в-пиксель с оригиналом). Иконки грузятся + синхронно в главном потоке, но порциями по времени; первый показ большой + категории может дать несколько кадров с заглушками. ## 3. Sandbox (защита от крашедла)