#import "modules/std.sx"; #import "modules/math"; #import "ui/types.sx"; #import "ui/render.sx"; #import "ui/events.sx"; #import "ui/view.sx"; #import "ui/animation.sx"; #import "ui/font.sx"; // ============================================================================= // DockZone — where a panel can be dropped // ============================================================================= DockZone :: enum { floating; fill; center; top; bottom; left; right; top_left; top_right; bottom_left; bottom_right; } dock_zone_get_hint_frame :: (zone: DockZone, bounds: Frame, hint_size: f32) -> Frame { pad :f32: 8.0; cx := bounds.origin.x + (bounds.size.width - hint_size) * 0.5; cy := bounds.origin.y + (bounds.size.height - hint_size) * 0.5; if zone == { case .floating: Frame.zero(); case .fill: Frame.make(cx, cy, hint_size * 1.2, hint_size * 1.2); case .center: Frame.make(cx, cy, hint_size, hint_size); case .top: Frame.make(cx, bounds.origin.y + pad, hint_size, hint_size); case .bottom: Frame.make(cx, bounds.max_y() - hint_size - pad, hint_size, hint_size); case .left: Frame.make(bounds.origin.x + pad, cy, hint_size, hint_size); case .right: Frame.make(bounds.max_x() - hint_size - pad, cy, hint_size, hint_size); case .top_left: Frame.make(bounds.origin.x + pad, bounds.origin.y + pad, hint_size, hint_size); case .top_right: Frame.make(bounds.max_x() - hint_size - pad, bounds.origin.y + pad, hint_size, hint_size); case .bottom_left: Frame.make(bounds.origin.x + pad, bounds.max_y() - hint_size - pad, hint_size, hint_size); case .bottom_right:Frame.make(bounds.max_x() - hint_size - pad, bounds.max_y() - hint_size - pad, hint_size, hint_size); } } dock_zone_get_preview_frame :: (zone: DockZone, bounds: Frame) -> Frame { hw := bounds.size.width * 0.5; hh := bounds.size.height * 0.5; if zone == { case .floating: Frame.zero(); case .fill: bounds; case .center: Frame.make(bounds.origin.x + bounds.size.width * 0.25, bounds.origin.y + bounds.size.height * 0.25, bounds.size.width * 0.5, bounds.size.height * 0.5); case .top: Frame.make(bounds.origin.x, bounds.origin.y, bounds.size.width, hh); case .bottom: Frame.make(bounds.origin.x, bounds.origin.y + hh, bounds.size.width, hh); case .left: Frame.make(bounds.origin.x, bounds.origin.y, hw, bounds.size.height); case .right: Frame.make(bounds.origin.x + hw, bounds.origin.y, hw, bounds.size.height); case .top_left: Frame.make(bounds.origin.x, bounds.origin.y, hw, hh); case .top_right: Frame.make(bounds.origin.x + hw, bounds.origin.y, hw, hh); case .bottom_left: Frame.make(bounds.origin.x, bounds.origin.y + hh, hw, hh); case .bottom_right:Frame.make(bounds.origin.x + hw, bounds.origin.y + hh, hw, hh); } } dock_zone_to_alignment :: (zone: DockZone) -> ?Alignment { if zone == { case .floating: null; case .fill: ALIGN_CENTER; case .center: ALIGN_CENTER; case .top: ALIGN_TOP; case .bottom: ALIGN_BOTTOM; case .left: ALIGN_LEADING; case .right: ALIGN_TRAILING; case .top_left: ALIGN_TOP_LEADING; case .top_right: ALIGN_TOP_TRAILING; case .bottom_left: ALIGN_BOTTOM_LEADING; case .bottom_right: ALIGN_BOTTOM_TRAILING; } } dock_zone_should_fill :: (zone: DockZone) -> bool { zone == .fill; } // ============================================================================= // DockInteraction — persistent drag state for the dock // ============================================================================= DOCK_ANIM_DURATION :f32: 0.19; // 190ms DockInteraction :: struct { // Drag state dragging_child: s64; // -1 = none drag_start_pos: Point; drag_offset: Point; click_fraction_x: f32; click_fraction_y: f32; hovered_zone: s64; // -1 = none, else DockZone ordinal // Per-child state natural_sizes: List(Size); alignment_overrides: List(Alignment); has_alignment_override: List(bool); is_floating: List(bool); is_fill: List(bool); floating_positions: List(Point); child_bounds: List(Frame); anim_sizes: List(Animated(Size)); header_pressed: List(bool); child_count: s64; parent_allocator: Allocator; // GPA — used for persistent list growth init :: (self: *DockInteraction) { self.dragging_child = -1; self.drag_start_pos = Point.zero(); self.drag_offset = Point.zero(); self.click_fraction_x = 0.0; self.click_fraction_y = 0.0; self.hovered_zone = -1; self.child_count = 0; self.parent_allocator = context.allocator; // capture GPA at init time self.natural_sizes = List(Size).{}; self.alignment_overrides = List(Alignment).{}; self.has_alignment_override = List(bool).{}; self.is_floating = List(bool).{}; self.is_fill = List(bool).{}; self.floating_positions = List(Point).{}; self.child_bounds = List(Frame).{}; self.anim_sizes = List(Animated(Size)).{}; self.header_pressed = List(bool).{}; } // BLOCKED on issue-0009: should use push instead of manual save/restore ensure_capacity :: (self: *DockInteraction, count: s64) { if self.child_count >= count { return; } push Context.{ allocator = self.parent_allocator, data = context.data } { while self.child_count < count { self.natural_sizes.append(Size.zero()); self.alignment_overrides.append(ALIGN_CENTER); self.has_alignment_override.append(false); self.is_floating.append(false); self.is_fill.append(false); self.floating_positions.append(Point.zero()); self.child_bounds.append(Frame.zero()); self.anim_sizes.append(Animated(Size).make(Size.zero())); self.header_pressed.append(false); self.child_count += 1; } } } set_target_size :: (self: *DockInteraction, index: s64, target: Size) { if index >= self.child_count { return; } anim := @self.anim_sizes.items[index]; cur := anim.to; // First time (target is ~0): jump immediately, don't animate from zero if cur.width < 1.0 and cur.height < 1.0 { anim.set_immediate(target); return; } // Only animate if target changed significantly if abs(cur.width - target.width) > 0.5 or abs(cur.height - target.height) > 0.5 { anim.animate_to(target, DOCK_ANIM_DURATION); } } get_animated_size :: (self: *DockInteraction, index: s64) -> Size { if index >= self.child_count { return Size.zero(); } (@self.anim_sizes.items[index]).current; } tick_animations :: (self: *DockInteraction, dt: f32) { i := 0; while i < self.child_count { anim := @self.anim_sizes.items[i]; anim.tick(dt); i += 1; } } get_hovered_dock_zone :: (self: *DockInteraction) -> ?DockZone { if self.hovered_zone < 0 { return null; } // Map ordinal back to DockZone cast(DockZone) self.hovered_zone; } set_hovered_dock_zone :: (self: *DockInteraction, zone: ?DockZone) { if z := zone { self.hovered_zone = xx z; } else { self.hovered_zone = -1; } } } start_dragging :: (interaction: *DockInteraction, child_index: s64, pos: Point, panel_frame: Frame) { interaction.dragging_child = child_index; interaction.drag_start_pos = pos; interaction.drag_offset = Point.zero(); if panel_frame.size.width > 0.0 { interaction.click_fraction_x = (pos.x - panel_frame.origin.x) / panel_frame.size.width; } else { interaction.click_fraction_x = 0.0; } if panel_frame.size.height > 0.0 { interaction.click_fraction_y = (pos.y - panel_frame.origin.y) / panel_frame.size.height; } else { interaction.click_fraction_y = 0.0; } } // ============================================================================= // Helper functions // ============================================================================= zone_by_index :: (i: s64) -> DockZone { if i == { case 0: .fill; case 1: .center; case 2: .top; case 3: .bottom; case 4: .left; case 5: .right; case 6: .top_left; case 7: .top_right; case 8: .bottom_left; case 9: .bottom_right; } } find_hovered_zone :: (bounds: Frame, pos: Point, hint_size: f32, enable_corners: bool) -> ?DockZone { count : s64 = if enable_corners then 10 else 6; i : s64 = 0; while i < count { zone := zone_by_index(i); hint := dock_zone_get_hint_frame(zone, bounds, hint_size); expanded := hint.expand(8.0); if expanded.contains(pos) { return zone; } i += 1; } null; } calculate_origin :: (bounds: Frame, child_size: Size, alignment: Alignment) -> Point { x : f32 = bounds.origin.x; if alignment.h == .center { x = bounds.origin.x + (bounds.size.width - child_size.width) * 0.5; } if alignment.h == .trailing { x = bounds.origin.x + bounds.size.width - child_size.width; } y : f32 = bounds.origin.y; if alignment.v == .center { y = bounds.origin.y + (bounds.size.height - child_size.height) * 0.5; } if alignment.v == .bottom { y = bounds.origin.y + bounds.size.height - child_size.height; } Point.{ x = x, y = y }; } get_size_proposal_for_alignment :: (alignment: Alignment, bounds_size: Size, is_fill: bool) -> ProposedSize { if is_fill { return ProposedSize.fixed(bounds_size.width, bounds_size.height); } // Edge docking: constrain one axis, leave other natural if alignment.h == .leading or alignment.h == .trailing { if alignment.v == .center { return ProposedSize.{ width = null, height = bounds_size.height }; } } if alignment.v == .top or alignment.v == .bottom { if alignment.h == .center { return ProposedSize.{ width = bounds_size.width, height = null }; } } // Center or corners: natural size ProposedSize.flexible(); } get_final_size_for_alignment :: (alignment: Alignment, child_size: Size, bounds_size: Size, is_fill: bool) -> Size { if is_fill { return bounds_size; } // Left/Right edges: fill height if alignment.h == .leading or alignment.h == .trailing { if alignment.v == .center { return Size.{ width = child_size.width, height = bounds_size.height }; } } // Top/Bottom edges: fill width if alignment.v == .top or alignment.v == .bottom { if alignment.h == .center { return Size.{ width = bounds_size.width, height = child_size.height }; } } // Center or corners: natural size child_size; } draw_zone_indicator :: (ctx: *RenderContext, frame: Frame, zone: DockZone, color: Color) { indicator_size := frame.size.width * 0.4; cx := frame.mid_x(); cy := frame.mid_y(); offset := frame.size.width * 0.15; if zone == { case .floating: {} case .fill: { s := indicator_size * 0.8; ctx.add_rect(Frame.make(cx - s * 0.5, cy - s * 0.5, s, s), color); } case .center: { s := indicator_size * 0.5; ctx.add_rect(Frame.make(cx - s * 0.5, cy - s * 0.5, s, s), color); } case .top: { ctx.add_rect(Frame.make(cx - indicator_size * 0.5, cy - offset - indicator_size * 0.25, indicator_size, indicator_size * 0.5), color); } case .bottom: { ctx.add_rect(Frame.make(cx - indicator_size * 0.5, cy + offset - indicator_size * 0.25, indicator_size, indicator_size * 0.5), color); } case .left: { ctx.add_rect(Frame.make(cx - offset - indicator_size * 0.25, cy - indicator_size * 0.5, indicator_size * 0.5, indicator_size), color); } case .right: { ctx.add_rect(Frame.make(cx + offset - indicator_size * 0.25, cy - indicator_size * 0.5, indicator_size * 0.5, indicator_size), color); } case .top_left: { s := indicator_size * 0.5; ctx.add_rect(Frame.make(cx - offset, cy - offset, s, s), color); } case .top_right: { s := indicator_size * 0.5; ctx.add_rect(Frame.make(cx + offset - s, cy - offset, s, s), color); } case .bottom_left: { s := indicator_size * 0.5; ctx.add_rect(Frame.make(cx - offset, cy + offset - s, s, s), color); } case .bottom_right: { s := indicator_size * 0.5; ctx.add_rect(Frame.make(cx + offset - s, cy + offset - s, s, s), color); } } } // ============================================================================= // DockPanel — a draggable panel within a Dock // ============================================================================= DockPanel :: struct { child: ViewChild; title: string; dock: Alignment; fill: bool; background: Color; header_background: Color; header_text_color: Color; corner_radius: f32; header_height: f32; dock_interaction: *DockInteraction; panel_index: s64; DEFAULT_BG :Color: Color.rgba(26, 26, 31, 242); DEFAULT_HEADER_BG :Color: Color.rgba(38, 38, 46, 255); DEFAULT_HEADER_TEXT:Color: COLOR_WHITE; DEFAULT_RADIUS :f32: 8.0; DEFAULT_HEADER_H :f32: 28.0; make :: (title: string, dock: Alignment, content: View) -> DockPanel { DockPanel.{ child = ViewChild.{ view = content }, title = title, dock = dock, fill = false, background = DockPanel.DEFAULT_BG, header_background = DockPanel.DEFAULT_HEADER_BG, header_text_color = DockPanel.DEFAULT_HEADER_TEXT, corner_radius = DockPanel.DEFAULT_RADIUS, header_height = DockPanel.DEFAULT_HEADER_H, dock_interaction = xx 0, // set by Dock.add_panel panel_index = 0 }; } } impl View for DockPanel { size_that_fits :: (self: *DockPanel, proposal: ProposedSize) -> Size { content_size := self.child.view.size_that_fits(ProposedSize.{ width = proposal.width, height = null }); w := if pw := proposal.width { min(content_size.width, pw); } else { content_size.width; }; Size.{ width = w, height = content_size.height + self.header_height }; } layout :: (self: *DockPanel, bounds: Frame) { content_frame := Frame.make( bounds.origin.x, bounds.origin.y + self.header_height, bounds.size.width, bounds.size.height - self.header_height ); self.child.computed_frame = content_frame; self.child.view.layout(content_frame); } render :: (self: *DockPanel, ctx: *RenderContext, frame: Frame) { // Panel background ctx.add_rounded_rect(frame, self.background, self.corner_radius); // Header background header_frame := Frame.make(frame.origin.x, frame.origin.y, frame.size.width, self.header_height); ctx.add_rounded_rect(header_frame, self.header_background, self.corner_radius); // Title text title_size := measure_text(self.title, 12.0); title_x := header_frame.origin.x + (header_frame.size.width - title_size.width) * 0.5; title_y := header_frame.origin.y + (header_frame.size.height - title_size.height) * 0.5; title_frame := Frame.make(title_x, title_y, title_size.width, title_size.height); ctx.add_text(title_frame, self.title, 12.0, self.header_text_color); // Child content self.child.view.render(ctx, self.child.computed_frame); } handle_event :: (self: *DockPanel, event: *Event, frame: Frame) -> bool { header_frame := Frame.make(frame.origin.x, frame.origin.y, frame.size.width, self.header_height); idx := self.panel_index; if event.* == { case .mouse_down: (d) { if header_frame.contains(d.position) { self.dock_interaction.header_pressed.items[idx] = true; start_dragging(self.dock_interaction, idx, d.position, frame); return true; } } case .mouse_up: (d) { if self.dock_interaction.header_pressed.items[idx] { self.dock_interaction.header_pressed.items[idx] = false; } } } // Forward to child content self.child.view.handle_event(event, self.child.computed_frame); } } // ============================================================================= // Dock — dockable container with drag-and-drop zones // ============================================================================= // Global delta_time pointer — set by main.sx g_dock_delta_time : *f32 = xx 0; Dock :: struct { children: List(ViewChild); alignments: List(Alignment); interaction: *DockInteraction; // heap-allocated, shared with DockPanels // Config background: ?Color; corner_radius: f32; hint_size: f32; hint_color: Color; hint_active_color: Color; preview_color: Color; enable_corners: bool; on_dock: ?Closure(s64, DockZone); make :: (interaction: *DockInteraction) -> Dock { d : Dock = ---; d.children = List(ViewChild).{}; d.alignments = List(Alignment).{}; d.interaction = interaction; d.background = null; d.corner_radius = 0.0; d.hint_size = 40.0; d.hint_color = Color.rgba(77, 153, 255, 153); d.hint_active_color = Color.rgba(77, 153, 255, 230); d.preview_color = Color.rgba(77, 153, 255, 64); d.enable_corners = true; d.on_dock = null; d; } add_panel :: (self: *Dock, panel: DockPanel) { idx := self.children.len; p := panel; p.dock_interaction = self.interaction; // share heap pointer p.panel_index = idx; self.alignments.append(panel.dock); self.children.append(ViewChild.{ view = p }); // Apply initial fill flag if panel.fill { self.interaction.ensure_capacity(idx + 1); self.interaction.is_fill.items[idx] = true; } } } impl View for Dock { size_that_fits :: (self: *Dock, proposal: ProposedSize) -> Size { Size.{ width = proposal.width ?? 800.0, height = proposal.height ?? 600.0 }; } layout :: (self: *Dock, bounds: Frame) { interaction := self.interaction; interaction.ensure_capacity(self.children.len); // Tick animations (g_dock_delta_time is always set before main loop) dt : f32 = g_dock_delta_time.*; interaction.tick_animations(dt); i : s64 = 0; while i < self.children.len { child := @self.children.items[i]; natural_size := child.view.size_that_fits(ProposedSize.flexible()); interaction.natural_sizes.items[i] = natural_size; is_being_dragged := interaction.dragging_child == i; fl_val : bool = interaction.is_floating.items[i]; if fl_val { // Floating: use natural size, position from stored floating pos child_size := natural_size; origin := interaction.floating_positions.items[i]; origin.x += bounds.origin.x; origin.y += bounds.origin.y; // Store bounds for hit testing (before drag offset) interaction.child_bounds.items[i] = Frame.make(origin.x, origin.y, child_size.width, child_size.height); // Apply drag offset if this is the dragged child if is_being_dragged { origin.x += interaction.drag_offset.x; origin.y += interaction.drag_offset.y; } child.computed_frame = Frame.make(origin.x, origin.y, child_size.width, child_size.height); child.view.layout(child.computed_frame); } else { // Docked: use alignment-based sizing has_ovr : bool = interaction.has_alignment_override.items[i]; alignment := if has_ovr then interaction.alignment_overrides.items[i] else self.alignments.items[i]; is_fill := interaction.is_fill.items[i] and !is_being_dragged; size_proposal := if is_being_dragged then ProposedSize.flexible() else get_size_proposal_for_alignment(alignment, bounds.size, is_fill); child_size := child.view.size_that_fits(size_proposal); target_size := if is_being_dragged then child_size else get_final_size_for_alignment(alignment, child_size, bounds.size, is_fill); // Animate size transitions interaction.set_target_size(i, target_size); final_size := interaction.get_animated_size(i); // Position origin : Point = ---; if is_being_dragged { current_touch := interaction.drag_start_pos.add(interaction.drag_offset); origin.x = current_touch.x - interaction.click_fraction_x * final_size.width; origin.y = current_touch.y - interaction.click_fraction_y * final_size.height; } else { origin = calculate_origin(bounds, final_size, alignment); } if !is_being_dragged { interaction.child_bounds.items[i] = Frame.make(origin.x, origin.y, final_size.width, final_size.height); } child.computed_frame = Frame.make(origin.x, origin.y, final_size.width, final_size.height); child.view.layout(child.computed_frame); } i += 1; } } render :: (self: *Dock, ctx: *RenderContext, frame: Frame) { // Background if bg := self.background { if self.corner_radius > 0.0 { ctx.add_rounded_rect(frame, bg, self.corner_radius); } else { ctx.add_rect(frame, bg); } } // Draw children i : s64 = 0; while i < self.children.len { child := @self.children.items[i]; child.view.render(ctx, child.computed_frame); i += 1; } // Draw drag overlay when dragging if self.interaction.dragging_child >= 0 { // Preview overlay for hovered zone if zone := self.interaction.get_hovered_dock_zone() { preview_frame := dock_zone_get_preview_frame(zone, frame); ctx.add_rounded_rect(preview_frame, self.preview_color, 8.0); } // Zone hint indicators count : s64 = if self.enable_corners then 10 else 6; j : s64 = 0; while j < count { zone := zone_by_index(j); hint_frame := dock_zone_get_hint_frame(zone, frame, self.hint_size); is_hovered := self.interaction.hovered_zone == xx zone; color := if is_hovered then self.hint_active_color else self.hint_color; ctx.add_rounded_rect(hint_frame, color, self.hint_size * 0.25); draw_zone_indicator(ctx, hint_frame, zone, COLOR_WHITE); j += 1; } } } handle_event :: (self: *Dock, event: *Event, frame: Frame) -> bool { interaction := self.interaction; // Pre-handle: intercept drag events when actively dragging if interaction.dragging_child >= 0 { if event.* == { case .mouse_moved: (d) { interaction.drag_offset = d.position.sub(interaction.drag_start_pos); // Update hovered zone zone := find_hovered_zone(frame, d.position, self.hint_size, self.enable_corners); interaction.set_hovered_dock_zone(zone); return true; } case .mouse_up: (d) { child_idx := interaction.dragging_child; if child_idx >= 0 and child_idx < self.children.len { if zone := interaction.get_hovered_dock_zone() { // Dock to zone if alignment := dock_zone_to_alignment(zone) { interaction.alignment_overrides.items[child_idx] = alignment; interaction.has_alignment_override.items[child_idx] = true; } interaction.is_floating.items[child_idx] = false; interaction.is_fill.items[child_idx] = dock_zone_should_fill(zone); } else { // Float: compute floating position from current cursor natural_size := interaction.natural_sizes.items[child_idx]; fp_x := d.position.x - interaction.click_fraction_x * natural_size.width - frame.origin.x; fp_y := d.position.y - interaction.click_fraction_y * natural_size.height - frame.origin.y; interaction.floating_positions.items[child_idx] = Point.{ x = fp_x, y = fp_y }; interaction.is_floating.items[child_idx] = true; interaction.is_fill.items[child_idx] = false; } } // Reset drag state interaction.dragging_child = -1; interaction.drag_offset = Point.zero(); interaction.click_fraction_x = 0.0; interaction.click_fraction_y = 0.0; interaction.hovered_zone = -1; return true; } } } // Forward to children (reverse order: last drawn = top = first to handle) i := self.children.len - 1; while i >= 0 { child := @self.children.items[i]; if child.view.handle_event(event, child.computed_frame) { return true; } i -= 1; } false; } }