0xc3 2 月之前
父節點
當前提交
ef225b84d1
共有 3 個文件被更改,包括 805 次插入1 次删除
  1. 3 1
      src/cmd/sandbox/main.odin
  2. 121 0
      src/cmd/ui/main.odin
  3. 681 0
      src/core/ui/ui.odin

+ 3 - 1
src/cmd/sandbox/main.odin

@@ -180,7 +180,9 @@ frame :: proc "c" () {
 
 	if mu.begin_window(&state.mu_ctx, "Interactive Window", {40, 40, 300, 250}) {
 		mu.label(&state.mu_ctx, "Try interacting!")
-		mu.button(&state.mu_ctx, "Button")
+		if .SUBMIT in mu.button(&state.mu_ctx, "Click Me") {
+			fmt.println("Clicked!")
+		}
 		mu.end_window(&state.mu_ctx)
 	}
 	mu.end(&state.mu_ctx)

+ 121 - 0
src/cmd/ui/main.odin

@@ -0,0 +1,121 @@
+package main
+
+import "core:fmt"
+import "core:math"
+import "core:strings"
+import "huginn:core/ui"
+import rl "vendor:raylib"
+
+main :: proc() {
+	rl.InitWindow(1024, 768, "Odin UI v2")
+	rl.SetTargetFPS(60)
+	rl.SetWindowState({.WINDOW_RESIZABLE})
+
+	ctx: ui.Context
+	ui.init(&ctx)
+
+	bg_color := rl.Color{30, 30, 30, 255}
+
+	// Демо-данные
+	checked := false
+	volume: f32 = 50
+
+	for !rl.WindowShouldClose() {
+		// --- 1. Input ---
+		input: ui.Input
+		input.mouse_pos = {f32(rl.GetMouseX()), f32(rl.GetMouseY())}
+		// Важно: передаем дельту мыши для драга и ресайза
+		input.mouse_delta = {rl.GetMouseDelta().x, rl.GetMouseDelta().y}
+		input.mouse_down = rl.IsMouseButtonDown(.LEFT)
+		input.mouse_pressed = rl.IsMouseButtonPressed(.LEFT)
+
+		input.measure_text = proc(text: string) -> f32 {
+			return f32(rl.MeasureText(strings.clone_to_cstring(text, context.temp_allocator), 20))
+		}
+
+		// --- 2. UI Logic ---
+		ui.begin(&ctx, input)
+
+		// Окно 1
+		if ui.window(&ctx, "Demo Window", {50, 50, 300, 400}) {
+			ui.label(&ctx, "Try dragging this window!")
+
+			// Длинный текст для теста обрезки
+			ui.label(
+				&ctx,
+				"This is a very long text label that should be truncated if the window is too small.",
+			)
+
+			ui.row(&ctx, {100, -1})
+			if ui.button(&ctx, "Button A") {fmt.println("A")}
+			if ui.button(&ctx, "Button B") {fmt.println("B")}
+
+			ui.row(&ctx, {-1})
+			if ui.button(&ctx, "Wide Button") {fmt.println("Wide")}
+
+			ui.label(&ctx, "Adaptive Flow:")
+
+			// ИСПОЛЬЗУЕМ FLOW ВМЕСТО ROW
+			// Кнопки будут идти в ряд, пока влезают, потом перенесутся
+			ui.flow(&ctx)
+			if ui.button(&ctx, "Gray Color") {bg_color = {30, 30, 30, 255}}
+			if ui.button(&ctx, "Red Color") {bg_color = {60, 20, 20, 255}}
+			if ui.button(&ctx, "Blue Color") {bg_color = {20, 20, 60, 255}}
+			if ui.button(&ctx, "Green") {bg_color = {20, 60, 20, 255}}
+			if ui.button(&ctx, "Yellow") {bg_color = {60, 60, 20, 255}}
+
+			ui.window_end(&ctx)
+		}
+
+		// Окно 2
+		@(static) is_open: bool = true
+		if ui.window(&ctx, "Another One", {400, 100, 200, 150}) {
+			ui.label(&ctx, "I am independent.")
+			if ui.button(&ctx, "Close Me") {
+				is_open = false
+				fmt.println("Close clicked")
+			}
+			ui.window_end(&ctx)
+		}
+
+		ui.end(&ctx)
+
+		// --- 3. Render ---
+		rl.BeginDrawing()
+		rl.ClearBackground(bg_color)
+
+		for cmd in ctx.commands {
+			switch c in cmd {
+			case ui.Cmd_Rect:
+				rl.DrawRectangleRec(
+					{c.rect.x, c.rect.y, c.rect.w, c.rect.h},
+					transmute(rl.Color)c.color,
+				)
+			case ui.Cmd_Text:
+				rl.DrawText(
+					strings.clone_to_cstring(c.text, context.temp_allocator),
+					i32(c.pos.x),
+					i32(c.pos.y),
+					20,
+					transmute(rl.Color)c.color,
+				)
+			case ui.Cmd_Clip:
+				if c.is_actived {
+					// Raylib ScissorMode работает в экранных координатах,
+					// а наши Rect тоже экранные, так что конвертация простая.
+					// Важно: Scissor не может иметь отрицательные размеры.
+					w := i32(math.max(0, c.rect.w))
+					h := i32(math.max(0, c.rect.h))
+					rl.BeginScissorMode(i32(c.rect.x), i32(c.rect.y), w, h)
+				} else {
+					rl.EndScissorMode()
+				}
+			}
+		}
+
+		rl.EndDrawing()
+		free_all(context.temp_allocator)
+	}
+
+	rl.CloseWindow()
+}

+ 681 - 0
src/core/ui/ui.odin

@@ -0,0 +1,681 @@
+package ui
+
+import "core:fmt"
+import "core:hash"
+import "core:math"
+
+// --- 1. Типы данных ---
+
+Vec2 :: [2]f32
+Rect :: struct {
+	x, y, w, h: f32,
+}
+Color :: [4]u8
+
+Id :: distinct u32
+
+Input :: struct {
+	mouse_pos:     Vec2,
+	mouse_delta:   Vec2,
+	mouse_down:    bool,
+	mouse_pressed: bool,
+	measure_text:  proc(text: string) -> f32,
+}
+
+// --- 2. Команды ---
+
+Command :: union {
+	Cmd_Rect,
+	Cmd_Text,
+	Cmd_Clip,
+}
+
+Cmd_Rect :: struct {
+	rect:  Rect,
+	color: Color,
+}
+Cmd_Text :: struct {
+	pos:   Vec2,
+	color: Color,
+	text:  string,
+}
+Cmd_Clip :: struct {
+	rect:       Rect,
+	is_actived: bool,
+}
+
+// --- 3. Контекст ---
+
+Context :: struct {
+	state:             map[Id]State_Variant,
+	commands:          [dynamic]Command,
+	layout_stack:      [dynamic]Layout,
+	id_stack:          [dynamic]Id,
+	input, last_input: Input,
+	hover_id:          Id,
+	hot_id:            Id,
+	drag_start_pos:    Vec2,
+	drag_start_rect:   Rect,
+	current_window_id: Id,
+	windows_order:     [dynamic]Id,
+	top_window_id:     Id,
+}
+
+State_Variant :: union {
+	State_Window,
+}
+
+State_Window :: struct {
+	rect:     Rect,
+	is_open:  bool,
+	min_size: Vec2,
+	commands: [dynamic]Command,
+}
+
+// --- 4. Лейаут ---
+
+Layout_Mode :: enum {
+	Vertical,
+	Horizontal,
+	Flow,
+}
+
+Layout :: struct {
+	rect:         Rect,
+	cursor:       Vec2,
+	row_h:        f32,
+	spacing:      f32,
+	widths:       [16]f32,
+	widths_count: int,
+	width_idx:    int,
+	max_pos:      Vec2,
+	min_w:        f32,
+	mode:         Layout_Mode,
+}
+
+// --- 5. API: Core ---
+
+init :: proc(ctx: ^Context) {
+	ctx.state = make(map[Id]State_Variant)
+	ctx.commands = make([dynamic]Command, 0, 1024)
+	ctx.layout_stack = make([dynamic]Layout, 0, 16)
+	ctx.id_stack = make([dynamic]Id, 0, 16)
+	ctx.windows_order = make([dynamic]Id, 0, 16)
+}
+
+begin :: proc(ctx: ^Context, input: Input) {
+	ctx.last_input = ctx.input
+	ctx.input = input
+
+	clear(&ctx.commands)
+	clear(&ctx.layout_stack)
+	clear(&ctx.id_stack)
+
+	ctx.hover_id = 0
+	ctx.top_window_id = 0
+
+	// Проходим по списку окон В ОБРАТНОМ ПОРЯДКЕ (от верхнего к нижнему)
+	#reverse for id in ctx.windows_order {
+		if id in ctx.state {
+			// Получаем состояние окна (нам нужен только rect)
+			// Важно: берем копию или ссылку, здесь не важно, мы только читаем
+			state := ctx.state[id].(State_Window)
+
+			// Если окно открыто и мышь внутри него
+			if state.is_open && point_in_rect(ctx.input.mouse_pos, state.rect) {
+				ctx.top_window_id = id
+				break // Нашли самое верхнее, остальные перекрыты
+			}
+		}
+	}
+
+	// Seed ID
+	append(&ctx.id_stack, 2166136261)
+	push_layout(ctx, {0, 0, 10000, 10000})
+}
+
+end :: proc(ctx: ^Context) {
+	// Проходим по списку порядка окон
+	for id in ctx.windows_order {
+		if id in ctx.state {
+			state := ctx.state[id].(State_Window)
+
+			// Если окно открыто, копируем его команды в главный список
+			if state.is_open {
+				for cmd in state.commands {
+					append(&ctx.commands, cmd)
+				}
+			}
+		}
+	}
+
+	// Сбрасываем текущее окно, на всякий случай
+	ctx.current_window_id = 0
+}
+
+// --- 6. API: Виджеты ---
+
+window :: proc(ctx: ^Context, title: string, rect: Rect) -> bool {
+	// ЗАПРЕТ ВЛОЖЕННЫХ ОКОН
+	// Это предотвратит создание вложенного окна и выполнение его кода.
+	assert(ctx.current_window_id == 0)
+
+	id := get_id(ctx, title)
+	ctx.current_window_id = id
+
+	// 1. Инициализация
+	if id not_in ctx.state {
+		ctx.state[id] = State_Window {
+			rect     = rect,
+			is_open  = true,
+			min_size = {64, 64},
+			commands = make([dynamic]Command),
+		}
+		// Добавляем новое окно в список порядка (сверху)
+		append(&ctx.windows_order, id)
+	}
+
+	// Берем указатель на состояние
+	variant_ptr := &ctx.state[id]
+	state := &variant_ptr.(State_Window)
+
+	// ОЧИЩАЕМ буфер команд окна в начале его отрисовки
+	clear(&state.commands)
+
+	// --- ОБНОВЛЕННАЯ ЛОГИКА ФОКУСА ---
+	m := ctx.input.mouse_pos
+	hovered := point_in_rect(m, state.rect)
+
+	// Мы можем взаимодействовать с окном (двигать, поднимать), ТОЛЬКО если:
+	// 1. Мышь над ним
+	// 2. И это окно является верхним под мышью (никто его не перекрывает)
+	if hovered && ctx.top_window_id == id {
+		if ctx.input.mouse_pressed {
+			bring_to_front(ctx, id)
+		}
+	}
+
+	// --- 2. Логика заголовка (Перемещение) ---
+	header_h: f32 = 25
+	header_rect := Rect{state.rect.x, state.rect.y, state.rect.w, header_h}
+	header_id := get_id_from_id(id, 1)
+
+	update_control(ctx, header_id, header_rect)
+
+	if ctx.hot_id == header_id {
+		if ctx.input.mouse_pressed {
+			ctx.drag_start_pos = ctx.input.mouse_pos
+			ctx.drag_start_rect = state.rect
+		}
+
+		// Проверяем, держит ли пользователь мышь
+		if ctx.input.mouse_down {
+			delta := ctx.input.mouse_pos - ctx.drag_start_pos
+			state.rect.x = ctx.drag_start_rect.x + delta.x
+			state.rect.y = ctx.drag_start_rect.y + delta.y
+		} else {
+			// Мышь отпустили — прекращаем драг
+			ctx.hot_id = 0
+		}
+	}
+
+	// --- 3. Логика ресайза (Правый нижний угол) ---
+	resize_handle_size: f32 = 15
+	resize_rect := Rect {
+		state.rect.x + state.rect.w - resize_handle_size,
+		state.rect.y + state.rect.h - resize_handle_size,
+		resize_handle_size,
+		resize_handle_size,
+	}
+
+	resize_id := get_id_from_id(id, 2)
+	update_control(ctx, resize_id, resize_rect)
+
+	if ctx.hot_id == resize_id {
+		if ctx.input.mouse_pressed {
+			ctx.drag_start_pos = ctx.input.mouse_pos
+			ctx.drag_start_rect = state.rect
+		}
+
+		if ctx.input.mouse_down {
+			delta := ctx.input.mouse_pos - ctx.drag_start_pos
+			limit_w := math.max(64, state.min_size.x)
+			limit_h := math.max(64, state.min_size.y)
+			new_w := ctx.drag_start_rect.w + delta.x
+			new_h := ctx.drag_start_rect.h + delta.y
+			state.rect.w = math.clamp(new_w, limit_w, 10000)
+			state.rect.h = math.clamp(new_h, limit_h, 10000)
+		} else {
+			// Мышь отпустили — прекращаем ресайз
+			ctx.hot_id = 0
+		}
+	}
+
+
+	push_cmd(
+		ctx,
+		Cmd_Rect{{state.rect.x + 4, state.rect.y + 4, state.rect.w, state.rect.h}, {0, 0, 0, 40}},
+	)
+	push_cmd(ctx, Cmd_Rect{state.rect, {45, 45, 45, 255}})
+
+	header_col: Color
+	switch {
+	case ctx.hot_id == header_id:
+		header_col = {90, 90, 90, 255}
+	case:
+		header_col = {60, 60, 60, 255}
+	}
+	push_cmd(ctx, Cmd_Rect{{state.rect.x, state.rect.y, state.rect.w, header_h}, header_col})
+	// Вычисляем область для текста (ширина окна минус место под треугольник ресайза и отступы)
+	title_rect := Rect {
+		state.rect.x + 8,
+		state.rect.y,
+		state.rect.w - 20, // Оставляем место справа
+		header_h,
+	}
+	draw_text_left_truncated(ctx, title, title_rect, {220, 220, 220, 255})
+
+
+	// Рисуем индикатор ресайза в актуальной позиции (после обновления state.rect)
+	// Пересчитываем позицию, так как state.rect мог измениться выше
+	current_resize_rect := Rect {
+		state.rect.x + state.rect.w - resize_handle_size,
+		state.rect.y + state.rect.h - resize_handle_size,
+		resize_handle_size,
+		resize_handle_size,
+	}
+
+	// Цвет индикатора меняем для наглядности
+	resize_col := ctx.hot_id == resize_id ? Color{120, 120, 120, 255} : Color{80, 80, 80, 255}
+	push_cmd(ctx, Cmd_Rect{{current_resize_rect.x, current_resize_rect.y, 15, 15}, resize_col})
+
+	// --- 5. Лейаут контента ---
+	content_rect := state.rect
+	content_rect.y += header_h
+	content_rect.h -= header_h
+
+	push_cmd(ctx, Cmd_Clip{content_rect, true})
+
+	push_layout(ctx, content_rect)
+
+	return true
+}
+
+window_end :: proc(ctx: ^Context) {
+	l := current_layout(ctx)
+
+	id := ctx.current_window_id
+	if id in ctx.state {
+		variant_ptr := &ctx.state[id]
+		state := &variant_ptr.(State_Window)
+
+		min_w := l.min_w + 16.0 // + padding
+		min_h := (l.max_pos.y - state.rect.y) + 8.0
+
+		state.min_size = {min_w, min_h}
+	}
+
+	pop_layout(ctx)
+
+	push_cmd(ctx, Cmd_Clip{{}, false})
+
+	// Сбрасываем текущее окно
+	ctx.current_window_id = 0
+}
+
+button :: proc(ctx: ^Context, text: string) -> bool {
+	id := get_id(ctx, text)
+
+	w: f32 = 0
+	l := current_layout(ctx)
+
+	// В Flow режиме запрашиваем полную ширину текста + отступы
+	if l.mode == .Flow {
+		padding: f32 = 20
+		w = measure_text(ctx, text) + padding
+	}
+
+	// layout_next теперь вернет w, возможно сжатое (clamped), если места мало
+	rect := layout_next(ctx, w)
+
+	update_control(ctx, id, rect)
+
+	clicked := false
+	bg_color: Color
+	switch {
+	case ctx.hot_id == id:
+		bg_color = {50, 50, 50, 255}
+		if ctx.last_input.mouse_down && !ctx.input.mouse_down {
+			ctx.hot_id = 0
+			clicked = true
+		}
+	case ctx.hover_id == id:
+		bg_color = {90, 90, 90, 255}
+	case:
+		bg_color = {70, 70, 70, 255}
+	}
+
+	push_cmd(ctx, Cmd_Rect{rect, bg_color})
+
+	draw_text_centered_truncated(ctx, text, rect, {255, 255, 255, 255})
+
+	return clicked
+}
+
+label :: proc(ctx: ^Context, text: string) {
+	rect := layout_next(ctx)
+	draw_text_left_truncated(ctx, text, rect, {200, 200, 200, 255})
+}
+
+row :: proc(ctx: ^Context, widths: []f32, height: f32 = 0) {
+	l := current_layout(ctx)
+
+	l.width_idx = 0
+	l.widths_count = min(len(widths), 16)
+	l.row_h = height > 0 ? height : 28
+
+	total_width := l.rect.w
+	total_spacing := f32(l.widths_count - 1) * l.spacing
+	available_width := total_width - total_spacing
+
+	fixed_width: f32 = 0
+	dynamic_count := 0
+	current_row_min_w: f32 = 0
+
+	for w in widths {
+		if w == -1 {
+			dynamic_count += 1
+			current_row_min_w += 20 // Минимальная ширина для динамической кнопки
+		} else if w > 0 && w <= 1.0 {
+			fixed_width += total_width * w
+			current_row_min_w += 20 // Проценты тоже могут сжиматься, ставим безопасный минимум
+		} else {
+			fixed_width += w
+			current_row_min_w += w // Фиксированные пиксели — это жесткое ограничение
+		}
+	}
+
+	// Добавляем отступы к минимальной ширине
+	current_row_min_w += total_spacing
+
+	// Обновляем глобальный минимум лейаута
+	l.min_w = math.max(l.min_w, current_row_min_w)
+	// -------------------------------------
+
+	remaining_width := math.max(0, available_width - fixed_width)
+	auto_width := dynamic_count > 0 ? remaining_width / f32(dynamic_count) : 0
+
+	for i in 0 ..< l.widths_count {
+		w := widths[i]
+		if w == -1 {
+			l.widths[i] = auto_width
+		} else if w > 0 && w <= 1.0 {
+			l.widths[i] = total_width * w
+		} else {
+			l.widths[i] = w
+		}
+	}
+}
+
+flow :: proc(ctx: ^Context, height: f32 = 0) {
+	l := current_layout(ctx)
+	l.mode = .Flow
+	l.row_h = height > 0 ? height : 28
+	l.width_idx = 0 // Сбрасываем индексы, они не нужны в Flow
+}
+
+// --- Внутренние функции ---
+
+@(private)
+get_id :: proc(ctx: ^Context, data: string) -> Id {
+	seed := ctx.id_stack[len(ctx.id_stack) - 1]
+	h := hash.fnv32a(transmute([]u8)data, u32(seed))
+	return Id(h)
+}
+
+@(private)
+get_id_from_id :: proc(id: Id, salt: u32) -> Id {
+	// Простое комбинирование хешей
+	h := hash.fnv32a(transmute([]u8)string("salt"), u32(id) + salt)
+	return Id(h)
+}
+
+@(private)
+push_cmd :: proc(ctx: ^Context, cmd: Command) {
+	assert(ctx.current_window_id != 0)
+
+	// Если мы сейчас внутри окна
+	// Получаем состояние окна
+	variant_ptr := &ctx.state[ctx.current_window_id]
+	// Важно: используем указатель, чтобы append сработал
+	state := &variant_ptr.(State_Window)
+	append(&state.commands, cmd)
+}
+
+@(private)
+bring_to_front :: proc(ctx: ^Context, id: Id) {
+	// 1. Ищем индекс этого окна в списке
+	idx := -1
+	for win_id, i in ctx.windows_order {
+		if win_id == id {
+			idx = i
+			break
+		}
+	}
+
+	// 2. Если нашли и оно не последнее — перемещаем
+	if idx != -1 && idx < len(ctx.windows_order) - 1 {
+		ordered_remove(&ctx.windows_order, idx)
+		append(&ctx.windows_order, id)
+	}
+}
+
+@(private)
+update_control :: proc(ctx: ^Context, id: Id, rect: Rect) {
+	m := ctx.input.mouse_pos
+
+	// Геометрическая проверка
+	hovered := point_in_rect(m, rect)
+
+	// --- НОВАЯ ПРОВЕРКА: ПЕРЕКРЫТИЕ ---
+	// Если мышь геометрически над виджетом, НО:
+	// 1. Есть какое-то окно под мышью (top_window_id != 0)
+	// 2. И это окно - НЕ то, в котором находится наш виджет
+	// ТОГДА: Мышь перекрыта другим окном.
+	if hovered && ctx.top_window_id != 0 && ctx.top_window_id != ctx.current_window_id {
+		hovered = false
+	}
+
+	// Если мышь нажата где-то в другом месте
+	if ctx.hot_id != 0 && ctx.hot_id != id {
+		return
+	}
+
+	if hovered {
+		ctx.hover_id = id
+		if ctx.input.mouse_pressed {
+			ctx.hot_id = id
+		}
+	}
+}
+
+@(private)
+push_layout :: proc(ctx: ^Context, rect: Rect) {
+	l: Layout
+	l.rect = rect
+
+	padding: f32 = 8
+	l.cursor = {rect.x + padding, rect.y + padding}
+	l.max_pos = l.cursor
+
+	l.rect.w -= padding * 2
+	l.spacing = 4
+	l.row_h = 28
+	append(&ctx.layout_stack, l)
+}
+
+@(private)
+pop_layout :: proc(ctx: ^Context) {
+	pop(&ctx.layout_stack)
+}
+
+@(private)
+current_layout :: proc(ctx: ^Context) -> ^Layout {
+	return &ctx.layout_stack[len(ctx.layout_stack) - 1]
+}
+
+@(private)
+layout_next :: proc(ctx: ^Context, desired_w: f32 = 0) -> Rect {
+	l := current_layout(ctx)
+	res: Rect
+
+	switch l.mode {
+	case .Horizontal:
+		if l.width_idx < l.widths_count {
+			w := l.widths[l.width_idx]
+			res = {l.cursor.x, l.cursor.y, w, l.row_h}
+			l.cursor.x += w + l.spacing
+			l.width_idx += 1
+			if l.width_idx == l.widths_count {
+				l.cursor.x = l.rect.x + 8
+				l.cursor.y += l.row_h + l.spacing
+			}
+		}
+
+	case .Vertical:
+		res = {l.cursor.x, l.cursor.y, l.rect.w, l.row_h}
+		l.cursor.y += l.row_h + l.spacing
+
+	case .Flow:
+		// 1. Сколько места осталось на текущей строке?
+		// l.rect.x + l.rect.w = правая граница контейнера
+		// l.cursor.x = текущая позиция
+		right_boundary := l.rect.x + l.rect.w
+		available_w := right_boundary - l.cursor.x
+
+		// 2. Минимальная ширина, при которой есть смысл рисовать кнопку (например, "A...")
+		min_useful_w: f32 = 40.0
+
+		w := desired_w
+
+		// Влезает ли элемент целиком?
+		if w <= available_w {
+			// Влезает, оставляем как есть
+		} else {
+			// Не влезает.
+			// Проверяем: мы в начале строки?
+			at_start_of_line := (l.cursor.x <= l.rect.x + 8.0) // +8 это padding
+
+			if !at_start_of_line && available_w < min_useful_w {
+				// Мы НЕ в начале строки, и места осталось мало (< 40px).
+				// Сжимать нет смысла, переносим на новую строку.
+				l.cursor.x = l.rect.x + 8
+				l.cursor.y += l.row_h + l.spacing
+
+				// На новой строке доступна вся ширина
+				available_w = l.rect.w
+			}
+
+			// Теперь (возможно, уже на новой строке) проверяем, влезает ли он.
+			// Если все еще не влезает (окно очень узкое), СЖИМАЕМ его до ширины окна.
+			w = math.min(w, available_w)
+		}
+
+		res = {l.cursor.x, l.cursor.y, w, l.row_h}
+		l.cursor.x += w + l.spacing
+	}
+
+	l.max_pos.x = math.max(l.max_pos.x, res.x + res.w)
+	l.max_pos.y = math.max(l.max_pos.y, res.y + res.h)
+
+	return res
+}
+
+// Функция для обрезки текста с добавлением "..."
+@(private)
+truncate_text :: proc(ctx: ^Context, text: string, max_w: f32) -> string {
+	if measure_text(ctx, text) <= max_w {
+		return text
+	}
+
+	// Это упрощенная версия. В идеале нужно использовать string builder и аллокатор
+	// Но для IMGUI часто просто режут строку.
+
+	ellipsis := "..."
+	ellipsis_w := measure_text(ctx, ellipsis)
+
+	// Если даже точки не влезают
+	if ellipsis_w >= max_w {
+		return "."
+	}
+
+	// Пытаемся найти длину подстроки, которая влезет вместе с точками
+	// Это линейный поиск, можно оптимизировать бинарным, но для UI сойдет
+	for i := len(text) - 1; i > 0; i -= 1 {
+		sub := text[:i]
+		w := measure_text(ctx, sub)
+		if w + ellipsis_w <= max_w {
+			// Нашли! Собираем новую строку.
+			// Используем temp_allocator, так как строка живет 1 кадр
+			return fmt.tprintf("%s%s", sub, ellipsis)
+		}
+	}
+
+	return ellipsis
+}
+
+// Рисует текст, центрируя его в rect. Если не влезает — обрезает и ставит "..."
+@(private)
+draw_text_centered_truncated :: proc(ctx: ^Context, text: string, rect: Rect, color: Color) {
+	// Отступы по бокам, чтобы текст не прилипал к краям
+	padding: f32 = 4
+	max_w := math.max(0, rect.w - padding * 2)
+
+	display_text := text
+	text_w := measure_text(ctx, text)
+
+	// Если текст шире доступного места — обрезаем
+	if text_w > max_w {
+		display_text = truncate_text(ctx, text, max_w)
+		text_w = measure_text(ctx, display_text)
+	}
+
+	tx := rect.x + (rect.w - text_w) * 0.5
+	ty := rect.y + (rect.h - 14) * 0.5 // 14 - примерная высота шрифта по умолчанию
+
+	push_cmd(ctx, Cmd_Text{{tx, ty}, color, display_text})
+}
+
+// Для заголовков и лейблов (выравнивание слева)
+@(private)
+draw_text_left_truncated :: proc(ctx: ^Context, text: string, rect: Rect, color: Color) {
+	max_w := math.max(0, rect.w)
+
+	display_text := text
+	if measure_text(ctx, text) > max_w {
+		display_text = truncate_text(ctx, text, max_w)
+	}
+
+	// Центрируем только по вертикали
+	ty := rect.y + (rect.h - 14) * 0.5
+	push_cmd(ctx, Cmd_Text{{rect.x, ty}, color, display_text})
+}
+
+@(private)
+measure_text :: proc(ctx: ^Context, text: string) -> f32 {
+	if ctx.input.measure_text != nil {
+		return ctx.input.measure_text(text)
+	}
+	return f32(len(text) * 8)
+}
+
+@(private)
+shrink_rect :: proc(r: Rect, amount: f32) -> Rect {
+	return Rect{r.x + amount, r.y + amount, r.w - amount * 2, r.h - amount * 2}
+}
+
+// Хелпер для проверки попадания точки в прямоугольник
+@(private)
+point_in_rect :: proc(p: Vec2, r: Rect) -> bool {
+	return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h
+}