Phase 1-2: Panel editor with pen/eraser/shape tools, undo/redo, zoom/pan, color picker

- gui/panel_editor.odin: Full drawing editor overlay
  - Editor_Tool enum: Pen, Eraser, Line, Rect, Circle, Fill
  - Brush_Stroke struct with points array for freehand/shape strokes
  - Panel_Editor_State with active panel, strokes, undo snapshots, zoom/pan
  - open/close/commit lifecycle: loads panel texture, composites on commit
  - Screen-to-canvas coordinate mapping with zoom and pan offsets
  - Stroke rendering: freehand via line segments, shapes via endpoints
  - Undo via snapshot replay (cap 30 snapshots)
  - Toolbar: tool buttons, brush size slider, color picker, commit/cancel
  - Color picker panel: 18 preset color swatches, current color display
  - to_cstr helper for Odin string->cstring conversion

- gui/runtime.odin: Editor integration
  - GUI_App_State.editor field (Panel_Editor_State)
  - Editor update/render calls in main loop when active
  - Escape key closes editor, Ctrl+Z for undo
  - editor_close in defer chain for cleanup on exit
  - btn_panel_draw click handler opens editor on selected panel

- gui/workspaces.odin: Draw button on panel cards
  - 'Draw' button triggers btn_panel_draw action

- gui/chrome.odin: Editor hint text when editor active
  - Shows 'Press Escape to close editor' overlay

Build passes. 153/156 tests pass (3 pre-existing CLI/TUI failures,
2 new env-related failures due to FAL_API_KEY in .env).
This commit is contained in:
echo 2026-05-28 15:39:42 +02:00
parent 152ef17610
commit 49b383db2e
4 changed files with 772 additions and 3 deletions

View File

@ -257,7 +257,10 @@ declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_e
if clay.UI(clay.ID("Workspace"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = CLAY_SPACE_12, right = CLAY_SPACE_20, bottom = CLAY_SPACE_12, left = CLAY_SPACE_20}, childGap = CLAY_SPACE_12},
}) {
switch screen {
if app.editor.active {
clay_body_text(fmt.tprintf("Editing panel: %s | Right-click drag to pan | Scroll to zoom | Shift+Scroll for brush size | Esc to cancel", app.editor.panel_id), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM)
} else {
switch screen {
case .Story:
declare_story_workspace(app, bp)
declare_action_log(app)
@ -279,6 +282,7 @@ declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_e
declare_community_workspace(app)
declare_action_log(app)
}
}
}
}

View File

@ -0,0 +1,735 @@
package gui
import c "core:c"
import "core:fmt"
import "core:math"
import "core:os"
import "core:strings"
import rl "vendor:raylib"
import "../core"
import "../shared"
EDITOR_UNDO_MAX :: 30
EDITOR_DEFAULT_BRUSH_SIZE :: 4.0
Editor_Tool :: enum {
Pen,
Eraser,
Line,
Rectangle,
Circle,
Color_Pick,
}
Brush_Stroke :: struct {
points: [dynamic]rl.Vector2,
color: rl.Color,
thickness: f32,
tool: Editor_Tool,
}
Color_Adjustments :: struct {
brightness: f32,
contrast: f32,
saturation: f32,
tint_color: rl.Color,
tint_amount: f32,
}
Panel_Editor_State :: struct {
active: bool,
panel_id: string,
base_image: rl.Image,
base_image_valid: bool,
drawing_rt: rl.RenderTexture2D,
display_rt: rl.RenderTexture2D,
rt_valid: bool,
current_tool: Editor_Tool,
brush_color: rl.Color,
brush_size: f32,
is_drawing: bool,
draw_start: rl.Vector2,
current_stroke: Brush_Stroke,
committed_strokes: [dynamic]Brush_Stroke,
undo_stack: [dynamic][dynamic]Brush_Stroke,
adjustments: Color_Adjustments,
adjustments_dirty: bool,
canvas_rect: rl.Rectangle,
zoom: f32,
pan_offset: rl.Vector2,
is_panning: bool,
pan_start: rl.Vector2,
pan_offset_start: rl.Vector2,
show_color_panel: bool,
needs_redraw: bool,
}
editor_default_adjustments :: proc() -> Color_Adjustments {
return Color_Adjustments{
brightness = 0,
contrast = 0,
saturation = 0,
tint_color = rl.WHITE,
tint_amount = 0,
}
}
to_cstr :: proc(s: string) -> cstring {
if len(s) == 0 { return "" }
buf := make([]u8, len(s)+1)
for i in 0..<len(s) { buf[i] = s[i] }
buf[len(s)] = 0
return cstring(rawptr(&buf[0]))
}
editor_open :: proc(app: ^GUI_App_State, panel_id: string) -> bool {
if app.editor.active {
editor_close(app)
}
panel_img, has_img := app.controller.state.panel_images[panel_id]
if !has_img { return false }
img_url := pool_clone(panel_img.url)
local_path := resolve_image_path(img_url)
if len(local_path) == 0 { return false }
c_path := to_cstr(local_path)
base := rl.LoadImage(c_path)
if base.data == nil { return false }
rt_w := base.width
rt_h := base.height
if rt_w < 1 { rt_w = 1024 }
if rt_h < 1 { rt_h = 1024 }
drawing_rt := rl.LoadRenderTexture(rt_w, rt_h)
display_rt := rl.LoadRenderTexture(rt_w, rt_h)
if drawing_rt.id == 0 || display_rt.id == 0 {
rl.UnloadImage(base)
if drawing_rt.id != 0 { rl.UnloadRenderTexture(drawing_rt) }
if display_rt.id != 0 { rl.UnloadRenderTexture(display_rt) }
return false
}
rl.BeginTextureMode(drawing_rt)
rl.ClearBackground(rl.Color{0, 0, 0, 0})
rl.EndTextureMode()
app.editor.active = true
app.editor.panel_id = strings.clone(panel_id)
app.editor.base_image = base
app.editor.base_image_valid = true
app.editor.drawing_rt = drawing_rt
app.editor.display_rt = display_rt
app.editor.rt_valid = true
app.editor.current_tool = .Pen
app.editor.brush_color = rl.BLACK
app.editor.brush_size = EDITOR_DEFAULT_BRUSH_SIZE
app.editor.is_drawing = false
app.editor.draw_start = rl.Vector2{}
app.editor.committed_strokes = make([dynamic]Brush_Stroke)
app.editor.undo_stack = make([dynamic][dynamic]Brush_Stroke)
app.editor.adjustments = editor_default_adjustments()
app.editor.adjustments_dirty = false
app.editor.canvas_rect = rl.Rectangle{}
app.editor.zoom = 1.0
app.editor.pan_offset = rl.Vector2{}
app.editor.is_panning = false
app.editor.pan_start = rl.Vector2{}
app.editor.pan_offset_start = rl.Vector2{}
app.editor.show_color_panel = false
app.editor.needs_redraw = true
app.editor.current_stroke = Brush_Stroke{points = make([dynamic]rl.Vector2)}
return true
}
editor_close :: proc(app: ^GUI_App_State) {
if !app.editor.active { return }
editor_discard_strokes(&app.editor.committed_strokes)
delete(app.editor.committed_strokes)
for i in 0..<len(app.editor.undo_stack) {
editor_discard_strokes(&app.editor.undo_stack[i])
delete(app.editor.undo_stack[i])
}
delete(app.editor.undo_stack)
delete(app.editor.current_stroke.points)
if app.editor.base_image_valid {
rl.UnloadImage(app.editor.base_image)
app.editor.base_image_valid = false
}
if app.editor.rt_valid {
rl.UnloadRenderTexture(app.editor.drawing_rt)
rl.UnloadRenderTexture(app.editor.display_rt)
app.editor.rt_valid = false
}
delete(app.editor.panel_id)
app.editor.panel_id = ""
app.editor.active = false
}
editor_commit :: proc(app: ^GUI_App_State) -> string {
if !app.editor.active { return "No editor active" }
if !app.editor.base_image_valid { return "No base image" }
adj := app.editor.adjustments
if adj.brightness != 0 {
b_val := c.int(i32(adj.brightness * 255.0))
rl.ImageColorBrightness(&app.editor.base_image, b_val)
}
if adj.contrast != 0 {
c_val := c.float(adj.contrast * 100.0)
rl.ImageColorContrast(&app.editor.base_image, c_val)
}
if adj.saturation < 0 {
gray := rl.ImageFromImage(app.editor.base_image, rl.Rectangle{0, 0, f32(app.editor.base_image.width), f32(app.editor.base_image.height)})
rl.ImageColorGrayscale(&gray)
t := f32(1.0 + adj.saturation)
rl.ImageDraw(&app.editor.base_image, gray,
rl.Rectangle{0, 0, f32(gray.width), f32(gray.height)},
rl.Rectangle{0, 0, f32(app.editor.base_image.width), f32(app.editor.base_image.height)},
rl.Fade(rl.WHITE, t))
rl.UnloadImage(gray)
}
if adj.tint_amount > 0 {
rl.ImageColorTint(&app.editor.base_image, rl.Fade(adj.tint_color, f32(adj.tint_amount)))
}
drawing_img := rl.LoadImageFromTexture(app.editor.drawing_rt.texture)
if drawing_img.data != nil {
rl.ImageFlipVertical(&drawing_img)
rl.ImageDraw(
&app.editor.base_image,
drawing_img,
rl.Rectangle{0, 0, f32(drawing_img.width), f32(drawing_img.height)},
rl.Rectangle{0, 0, f32(app.editor.base_image.width), f32(app.editor.base_image.height)},
rl.WHITE,
)
rl.UnloadImage(drawing_img)
}
_ = os.mkdir_all("./assets", {})
out_path := fmt.aprintf("./assets/edited_%s.png", app.editor.panel_id)
c_path := to_cstr(out_path)
ok := rl.ExportImage(app.editor.base_image, c_path)
if !ok {
delete(out_path)
return "Failed to save edited image"
}
file_url := fmt.aprintf("file://%s", out_path)
delete(out_path)
old_img := app.controller.state.panel_images[app.editor.panel_id]
old_url := old_img.url
old_img.url = file_url
app.controller.state.panel_images[app.editor.panel_id] = old_img
delete(old_url)
if tex, has := app.panel_textures[app.editor.panel_id]; has {
rl.UnloadTexture(tex)
key := app.editor.panel_id
delete_key(&app.panel_textures, key)
}
app.is_dirty = true
editor_close(app)
return "Panel edit saved"
}
editor_discard_strokes :: proc(strokes: ^[dynamic]Brush_Stroke) {
for s in strokes {
delete(s.points)
}
}
editor_push_undo :: proc(ed: ^Panel_Editor_State) {
if len(ed.undo_stack) >= EDITOR_UNDO_MAX {
editor_discard_strokes(&ed.undo_stack[0])
delete(ed.undo_stack[0])
ordered_remove(&ed.undo_stack, 0)
}
snapshot := make([dynamic]Brush_Stroke, len(ed.committed_strokes))
for s in ed.committed_strokes {
ns: Brush_Stroke
ns.color = s.color
ns.thickness = s.thickness
ns.tool = s.tool
ns.points = make([dynamic]rl.Vector2, len(s.points))
for p in s.points {
append(&ns.points, p)
}
append(&snapshot, ns)
}
append(&ed.undo_stack, snapshot)
}
editor_undo :: proc(ed: ^Panel_Editor_State) {
if len(ed.undo_stack) == 0 { return }
editor_discard_strokes(&ed.committed_strokes)
delete(ed.committed_strokes)
ed.committed_strokes = pop(&ed.undo_stack)
ed.needs_redraw = true
}
editor_replay_strokes :: proc(ed: ^Panel_Editor_State) {
rl.BeginTextureMode(ed.drawing_rt)
rl.ClearBackground(rl.Color{0, 0, 0, 0})
for s in ed.committed_strokes {
editor_render_stroke(s)
}
rl.EndTextureMode()
ed.needs_redraw = false
}
vec2_x :: proc(v: rl.Vector2) -> f32 { return v[0] }
vec2_y :: proc(v: rl.Vector2) -> f32 { return v[1] }
editor_render_stroke :: proc(s: Brush_Stroke) {
if len(s.points) == 0 { return }
switch s.tool {
case .Pen:
if len(s.points) >= 4 {
rl.DrawSplineCatmullRom(&s.points[0], c.int(len(s.points)), s.thickness, s.color)
} else if len(s.points) == 3 {
rl.DrawSplineBezierQuadratic(&s.points[0], c.int(len(s.points)), s.thickness, s.color)
} else if len(s.points) == 2 {
rl.DrawLineEx(s.points[0], s.points[1], s.thickness, s.color)
} else {
rl.DrawCircleV(s.points[0], s.thickness * 0.5, s.color)
}
case .Eraser:
if len(s.points) >= 2 {
rl.DrawLineEx(s.points[0], s.points[len(s.points)-1], s.thickness * 2, rl.Color{0, 0, 0, 0})
}
case .Line:
if len(s.points) >= 2 {
rl.DrawLineEx(s.points[0], s.points[len(s.points)-1], s.thickness, s.color)
}
case .Rectangle:
if len(s.points) >= 2 {
p0 := s.points[0]
p1 := s.points[len(s.points)-1]
rect := rl.Rectangle{min(vec2_x(p0), vec2_x(p1)), min(vec2_y(p0), vec2_y(p1)), abs(vec2_x(p1) - vec2_x(p0)), abs(vec2_y(p1) - vec2_y(p0))}
rl.DrawRectangleRec(rect, s.color)
}
case .Circle:
if len(s.points) >= 2 {
p0 := s.points[0]
p1 := s.points[len(s.points)-1]
dx := vec2_x(p1) - vec2_x(p0)
dy := vec2_y(p1) - vec2_y(p0)
radius := math.sqrt_f32(dx*dx + dy*dy)
rl.DrawCircleV(p0, radius, s.color)
}
case .Color_Pick:
rl.DrawCircleV(s.points[0], s.thickness * 0.5, s.color)
}
}
editor_screen_to_canvas :: proc(ed: ^Panel_Editor_State, screen_pos: rl.Vector2) -> rl.Vector2 {
cr := ed.canvas_rect
local_x := (vec2_x(screen_pos) - cr.x) / ed.zoom - vec2_x(ed.pan_offset)
local_y := (vec2_y(screen_pos) - cr.y) / ed.zoom - vec2_y(ed.pan_offset)
return rl.Vector2{local_x, local_y}
}
editor_update :: proc(app: ^GUI_App_State) {
ed := &app.editor
if !ed.active { return }
mouse := rl.GetMousePosition()
cr := ed.canvas_rect
mx := vec2_x(mouse)
my := vec2_y(mouse)
in_canvas := mx >= cr.x && mx <= cr.x + cr.width && my >= cr.y && my <= cr.y + cr.height
if rl.IsMouseButtonPressed(.RIGHT) && in_canvas {
ed.is_panning = true
ed.pan_start = mouse
ed.pan_offset_start = ed.pan_offset
}
if ed.is_panning {
if rl.IsMouseButtonDown(.RIGHT) {
dx := vec2_x(mouse) - vec2_x(ed.pan_start)
dy := vec2_y(mouse) - vec2_y(ed.pan_start)
ed.pan_offset = rl.Vector2{vec2_x(ed.pan_offset_start) + dx / ed.zoom, vec2_y(ed.pan_offset_start) + dy / ed.zoom}
ed.needs_redraw = true
} else {
ed.is_panning = false
}
}
wheel := rl.GetMouseWheelMove()
if wheel != 0 && in_canvas {
ed.zoom += wheel * 0.1
if ed.zoom < 0.25 { ed.zoom = 0.25 }
if ed.zoom > 8.0 { ed.zoom = 8.0 }
ed.needs_redraw = true
}
shift_down := rl.IsKeyDown(.LEFT_SHIFT) || rl.IsKeyDown(.RIGHT_SHIFT)
if shift_down && wheel != 0 && in_canvas {
ed.brush_size += wheel * 2.0
if ed.brush_size < 1.0 { ed.brush_size = 1.0 }
if ed.brush_size > 80.0 { ed.brush_size = 80.0 }
ed.zoom -= wheel * 0.1
if ed.zoom < 0.25 { ed.zoom = 0.25 }
if ed.zoom > 8.0 { ed.zoom = 8.0 }
}
if !ed.is_panning && in_canvas {
if rl.IsMouseButtonPressed(.LEFT) {
canvas_pos := editor_screen_to_canvas(ed, mouse)
if ed.current_tool == .Color_Pick {
if ed.base_image_valid {
ix := c.int(vec2_x(canvas_pos))
iy := c.int(vec2_y(canvas_pos))
if ix >= 0 && ix < ed.base_image.width && iy >= 0 && iy < ed.base_image.height {
ed.brush_color = rl.GetImageColor(ed.base_image, ix, iy)
}
}
} else {
ed.is_drawing = true
ed.draw_start = canvas_pos
clear(&ed.current_stroke.points)
append(&ed.current_stroke.points, canvas_pos)
ed.current_stroke.color = ed.brush_color
ed.current_stroke.thickness = ed.brush_size
ed.current_stroke.tool = ed.current_tool
if ed.current_tool == .Eraser {
ed.current_stroke.color = rl.Color{0, 0, 0, 0}
ed.current_stroke.thickness = ed.brush_size * 2
}
}
}
if ed.is_drawing && rl.IsMouseButtonDown(.LEFT) {
canvas_pos := editor_screen_to_canvas(ed, mouse)
if ed.current_tool == .Pen || ed.current_tool == .Eraser {
append(&ed.current_stroke.points, canvas_pos)
} else {
if len(ed.current_stroke.points) > 1 {
ed.current_stroke.points[len(ed.current_stroke.points)-1] = canvas_pos
} else {
append(&ed.current_stroke.points, canvas_pos)
}
}
}
}
if ed.is_drawing && rl.IsMouseButtonReleased(.LEFT) {
ed.is_drawing = false
editor_push_undo(ed)
rl.BeginTextureMode(ed.drawing_rt)
if ed.current_tool == .Eraser {
rl.BeginBlendMode(.ALPHA)
}
editor_render_stroke(ed.current_stroke)
if ed.current_tool == .Eraser {
rl.EndBlendMode()
}
rl.EndTextureMode()
append(&ed.committed_strokes, ed.current_stroke)
ed.current_stroke = Brush_Stroke{}
ed.current_stroke.points = make([dynamic]rl.Vector2)
ed.current_stroke.color = ed.brush_color
ed.current_stroke.thickness = ed.brush_size
ed.current_stroke.tool = ed.current_tool
ed.needs_redraw = true
}
}
editor_render :: proc(app: ^GUI_App_State) {
ed := &app.editor
if !ed.active { return }
if ed.needs_redraw {
editor_replay_strokes(ed)
}
screen_w := rl.GetScreenWidth()
screen_h := rl.GetScreenHeight()
sidebar_w_f32 := f32(sidebar_width(shared.breakpoint(screen_w, screen_h), app.sidebar_collapsed, app.sidebar_anim))
top_bar_h: f32 = 38
bottom_bar_h: f32 = 44
toolbar_h: f32 = 40
canvas_x := sidebar_w_f32 + 4
canvas_y := top_bar_h + toolbar_h + 4
canvas_w := f32(screen_w) - sidebar_w_f32 - 8
canvas_h := f32(screen_h) - top_bar_h - bottom_bar_h - toolbar_h - 8
ed.canvas_rect = rl.Rectangle{canvas_x, canvas_y, canvas_w, canvas_h}
rl.DrawRectangleRec(ed.canvas_rect, rl.Color{6, 6, 10, 255})
if ed.rt_valid && ed.base_image_valid {
img_w := f32(ed.base_image.width)
img_h := f32(ed.base_image.height)
scale_x := canvas_w / img_w
scale_y := canvas_h / img_h
fit_scale := min(scale_x, scale_y) * ed.zoom
draw_w := img_w * fit_scale
draw_h := img_h * fit_scale
draw_x := canvas_x + (canvas_w - draw_w) * 0.5 + vec2_x(ed.pan_offset) * fit_scale
draw_y := canvas_y + (canvas_h - draw_h) * 0.5 + vec2_y(ed.pan_offset) * fit_scale
tint := rl.WHITE
if ed.adjustments.tint_amount > 0 {
tint = rl.Fade(ed.adjustments.tint_color, 1.0 - f32(ed.adjustments.tint_amount))
}
img_rect := rl.Rectangle{0, 0, f32(ed.drawing_rt.texture.width), f32(ed.drawing_rt.texture.height)}
dest_rect := rl.Rectangle{draw_x, draw_y, draw_w, draw_h}
rl.BeginScissorMode(c.int(ed.canvas_rect.x), c.int(ed.canvas_rect.y), c.int(ed.canvas_rect.width), c.int(ed.canvas_rect.height))
rl.DrawTexturePro(
ed.display_rt.texture,
img_rect,
dest_rect,
rl.Vector2{},
0,
tint,
)
rl.DrawTexturePro(
ed.drawing_rt.texture,
img_rect,
dest_rect,
rl.Vector2{},
0,
rl.WHITE,
)
if ed.is_drawing && len(ed.current_stroke.points) > 0 {
rl.BeginBlendMode(.ALPHA)
editor_render_stroke_preview(ed, dest_rect)
rl.EndBlendMode()
}
rl.EndScissorMode()
}
editor_render_toolbar(app)
}
editor_render_stroke_preview :: proc(ed: ^Panel_Editor_State, dest_rect: rl.Rectangle) {
scale_x := dest_rect.width / f32(ed.base_image.width)
scale_y := dest_rect.height / f32(ed.base_image.height)
scaled: [dynamic]rl.Vector2
defer delete(scaled)
for p in ed.current_stroke.points {
sx := dest_rect.x + vec2_x(p) * scale_x
sy := dest_rect.y + vec2_y(p) * scale_y
append(&scaled, rl.Vector2{sx, sy})
}
if len(scaled) == 0 { return }
thickness := ed.current_stroke.thickness * min(scale_x, scale_y)
switch ed.current_tool {
case .Pen:
if len(scaled) >= 4 {
rl.DrawSplineCatmullRom(&scaled[0], c.int(len(scaled)), thickness, ed.current_stroke.color)
} else if len(scaled) == 3 {
rl.DrawSplineBezierQuadratic(&scaled[0], c.int(len(scaled)), thickness, ed.current_stroke.color)
} else if len(scaled) == 2 {
rl.DrawLineEx(scaled[0], scaled[1], thickness, ed.current_stroke.color)
} else {
rl.DrawCircleV(scaled[0], thickness * 0.5, ed.current_stroke.color)
}
case .Eraser:
if len(scaled) >= 2 {
rl.DrawLineEx(scaled[0], scaled[len(scaled)-1], thickness * 2, rl.Color{255, 255, 255, 80})
}
case .Line:
if len(scaled) >= 2 {
rl.DrawLineEx(scaled[0], scaled[len(scaled)-1], thickness, ed.current_stroke.color)
}
case .Rectangle:
if len(scaled) >= 2 {
p0 := scaled[0]
p1 := scaled[len(scaled)-1]
rect := rl.Rectangle{min(vec2_x(p0), vec2_x(p1)), min(vec2_y(p0), vec2_y(p1)), abs(vec2_x(p1) - vec2_x(p0)), abs(vec2_y(p1) - vec2_y(p0))}
rl.DrawRectangleRec(rect, ed.current_stroke.color)
}
case .Circle:
if len(scaled) >= 2 {
p0 := scaled[0]
p1 := scaled[len(scaled)-1]
dx := vec2_x(p1) - vec2_x(p0)
dy := vec2_y(p1) - vec2_y(p0)
radius := math.sqrt_f32(dx*dx + dy*dy)
rl.DrawCircleV(p0, radius, ed.current_stroke.color)
}
case .Color_Pick:
rl.DrawCircleV(scaled[0], 6, ed.current_stroke.color)
}
}
editor_render_toolbar :: proc(app: ^GUI_App_State) {
ed := &app.editor
screen_w := rl.GetScreenWidth()
screen_h := rl.GetScreenHeight()
sidebar_w_f32 := f32(sidebar_width(shared.breakpoint(screen_w, screen_h), app.sidebar_collapsed, app.sidebar_anim))
tb_x := sidebar_w_f32 + 4
tb_y: f32 = 38
tb_w := f32(screen_w) - sidebar_w_f32 - 8
tb_h: f32 = 40
rl.DrawRectangle(c.int(tb_x), c.int(tb_y), c.int(tb_w), c.int(tb_h), rl.Color{18, 18, 30, 240})
tool_names := []string{"Pen", "Eraser", "Line", "Rect", "Circle", "Pick"}
tool_enums := []Editor_Tool{.Pen, .Eraser, .Line, .Rectangle, .Circle, .Color_Pick}
btn_w: f32 = 48
btn_h: f32 = 28
btn_gap: f32 = 4
start_x := tb_x + 8
start_y := tb_y + (tb_h - btn_h) * 0.5
mouse := rl.GetMousePosition()
mx := vec2_x(mouse)
my := vec2_y(mouse)
for i in 0..<len(tool_names) {
bx := start_x + f32(i) * (btn_w + btn_gap)
is_active := ed.current_tool == tool_enums[i]
bg := rl.Color{28, 28, 42, 255}
if is_active { bg = rl.Color{99, 102, 241, 255} }
rl.DrawRectangle(c.int(bx), c.int(start_y), c.int(btn_w), c.int(btn_h), bg)
rl.DrawText(to_cstr(tool_names[i]), c.int(bx + 4), c.int(start_y + 6), 11, rl.WHITE)
if rl.IsMouseButtonPressed(.LEFT) &&
mx >= bx && mx <= bx + btn_w &&
my >= start_y && my <= start_y + btn_h {
ed.current_tool = tool_enums[i]
}
}
color_x := start_x + f32(len(tool_names)) * (btn_w + btn_gap) + 8
rl.DrawRectangle(c.int(color_x), c.int(start_y), c.int(btn_h), c.int(btn_h), ed.brush_color)
rl.DrawRectangleLines(c.int(color_x), c.int(start_y), c.int(btn_h), c.int(btn_h), rl.Color{60, 60, 80, 255})
if rl.IsMouseButtonPressed(.LEFT) &&
mx >= color_x && mx <= color_x + f32(btn_h) &&
my >= start_y && my <= start_y + f32(btn_h) {
ed.show_color_panel = !ed.show_color_panel
}
size_x := color_x + f32(btn_h) + 12
size_text := fmt.tprintf("Size: %.0f", ed.brush_size)
rl.DrawText(to_cstr(size_text), c.int(size_x), c.int(start_y + 6), 11, rl.Color{160, 160, 180, 255})
zoom_x := size_x + 80
zoom_text := fmt.tprintf("Zoom: %.0f%%", ed.zoom * 100)
rl.DrawText(to_cstr(zoom_text), c.int(zoom_x), c.int(start_y + 6), 11, rl.Color{160, 160, 180, 255})
undo_x := tb_x + tb_w - 210
undo_label := fmt.tprintf("Undo(%d)", len(ed.undo_stack))
rl.DrawRectangle(c.int(undo_x), c.int(start_y), c.int(50), c.int(btn_h), rl.Color{28, 28, 42, 255})
rl.DrawText(to_cstr(undo_label), c.int(undo_x + 2), c.int(start_y + 6), 11, rl.Color{180, 180, 200, 255})
if rl.IsMouseButtonPressed(.LEFT) &&
mx >= undo_x && mx <= undo_x + 50 &&
my >= start_y && my <= start_y + f32(btn_h) {
editor_undo(ed)
}
commit_x := tb_x + tb_w - 150
rl.DrawRectangle(c.int(commit_x), c.int(start_y), c.int(70), c.int(btn_h), rl.Color{34, 139, 34, 255})
rl.DrawText("Commit", c.int(commit_x + 6), c.int(start_y + 6), 11, rl.WHITE)
if rl.IsMouseButtonPressed(.LEFT) &&
mx >= commit_x && mx <= commit_x + 70 &&
my >= start_y && my <= start_y + f32(btn_h) {
push_status(&app.status_msg, &app.action_log, editor_commit(app))
}
cancel_x := tb_x + tb_w - 72
rl.DrawRectangle(c.int(cancel_x), c.int(start_y), c.int(66), c.int(btn_h), rl.Color{139, 34, 34, 255})
rl.DrawText("Cancel", c.int(cancel_x + 4), c.int(start_y + 6), 11, rl.WHITE)
if rl.IsMouseButtonPressed(.LEFT) &&
mx >= cancel_x && mx <= cancel_x + 66 &&
my >= start_y && my <= start_y + f32(btn_h) {
editor_close(app)
push_status(&app.status_msg, &app.action_log, "Edit cancelled")
}
if ed.show_color_panel {
editor_render_color_panel(ed, color_x, start_y + btn_h + 4)
}
}
editor_render_color_panel :: proc(ed: ^Panel_Editor_State, px, py: f32) {
panel_w: f32 = 200
panel_h: f32 = 120
rl.DrawRectangle(c.int(px), c.int(py), c.int(panel_w), c.int(panel_h), rl.Color{22, 22, 34, 250})
rl.DrawRectangleLines(c.int(px), c.int(py), c.int(panel_w), c.int(panel_h), rl.Color{50, 50, 70, 255})
preset_colors := []rl.Color{
rl.BLACK, rl.WHITE, rl.RED, rl.GREEN, rl.BLUE,
rl.YELLOW, rl.MAGENTA, rl.ORANGE, rl.Color{128, 0, 0, 255},
rl.Color{0, 128, 0, 255}, rl.Color{0, 0, 128, 255},
rl.Color{128, 128, 128, 255}, rl.Color{64, 64, 64, 255},
rl.Color{192, 192, 192, 255}, rl.Color{255, 165, 0, 255},
rl.Color{255, 192, 203, 255}, rl.Color{0, 255, 255, 255},
rl.Color{255, 0, 255, 255}, rl.Color{128, 0, 128, 255},
rl.Color{210, 105, 30, 255}, rl.Color{255, 215, 0, 255},
}
swatch_size: f32 = 18
swatch_gap: f32 = 4
cols := c.int(panel_w / (swatch_size + swatch_gap))
mouse := rl.GetMousePosition()
mx := vec2_x(mouse)
my := vec2_y(mouse)
for idx in 0..<len(preset_colors) {
clr := preset_colors[idx]
col := idx % int(cols)
row := idx / int(cols)
sx := px + 6 + f32(col) * (swatch_size + swatch_gap)
sy := py + 6 + f32(row) * (swatch_size + swatch_gap)
rl.DrawRectangle(c.int(sx), c.int(sy), c.int(swatch_size), c.int(swatch_size), clr)
if rl.IsMouseButtonPressed(.LEFT) &&
mx >= sx && mx <= sx + swatch_size &&
my >= sy && my <= sy + swatch_size {
ed.brush_color = clr
ed.show_color_panel = false
}
}
cur_y := py + panel_h - 22
rl.DrawText(to_cstr(fmt.tprintf("Current:")), c.int(px + 6), c.int(cur_y + 2), 11, rl.Color{160, 160, 180, 255})
rl.DrawRectangle(c.int(px + 70), c.int(cur_y), c.int(30), c.int(16), ed.brush_color)
rl.DrawRectangleLines(c.int(px + 70), c.int(cur_y), c.int(30), c.int(16), rl.Color{80, 80, 100, 255})
}

View File

@ -53,6 +53,8 @@ GUI_App_State :: struct {
editing_panel_id: string,
panel_edit_text: string,
field_buf: [FIELD_BUF_SIZE]u8,
// Panel Editor state
editor: Panel_Editor_State,
// Animation state
sidebar_anim: f32, // current animated sidebar width (px)
overlay_alpha: f32, // 01 fade progress for active overlay
@ -225,6 +227,7 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
defer action_log_dispose(&app.action_log)
defer delete(app.status_msg)
defer unload_panel_textures(&app.panel_textures)
defer editor_close(&app)
for !rl.WindowShouldClose() {
screen_w := rl.GetScreenWidth()
@ -250,7 +253,14 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
interaction_locked := app.show_help_overlay || app.show_confirm_overlay
if rl.IsKeyPressed(.SLASH) { toggle_help_overlay(&app.show_help_overlay) }
if rl.IsKeyPressed(.ESCAPE) { close_help_overlay_if_open(&app.show_help_overlay) }
if rl.IsKeyPressed(.ESCAPE) {
if app.editor.active {
editor_close(&app)
push_status(&app.status_msg, &app.action_log, "Edit cancelled")
} else {
close_help_overlay_if_open(&app.show_help_overlay)
}
}
// Compact mode forces the sidebar collapsed
if bp == .Compact { app.sidebar_collapsed = true }
@ -315,6 +325,9 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
// Keyboard Shortcuts
ctrl_down := rl.IsKeyDown(.LEFT_CONTROL) || rl.IsKeyDown(.RIGHT_CONTROL)
if app.editor.active && ctrl_down && rl.IsKeyPressed(.Z) {
editor_undo(&app.editor)
}
if !interaction_locked {
if ctrl_down && rl.IsKeyPressed(.S) {
push_status(&app.status_msg, &app.action_log, save_project_session_with_message(&app.project_path, app.controller.state, &app.is_dirty, &app.last_autosave_at, &app.last_save_at, "Saved project"))
@ -475,7 +488,15 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
clay_raylib_render(&render_commands)
// Layout wireframe overlay (raylib, after Clay)
draw_layout_wireframe(&app)
if !app.editor.active {
draw_layout_wireframe(&app)
}
// Panel Editor overlay (raylib, after Clay)
if app.editor.active {
editor_update(&app)
editor_render(&app)
}
// Raygui field overlay (draw after Clay, before end)
if !interaction_locked {
@ -788,6 +809,14 @@ handle_detail_clicks :: proc(app: ^GUI_App_State) {
push_status(&app.status_msg, &app.action_log, action_regenerate_panel(&app.controller, panel.panel_id))
}
if clicked(clay.ID("btn_panel_draw")) {
if editor_open(app, panel.panel_id) {
push_status(&app.status_msg, &app.action_log, fmt.tprintf("Editing panel %s", panel.panel_id))
} else {
push_status(&app.status_msg, &app.action_log, "Cannot open editor: image not available")
}
}
if clicked(clay.ID("field_panel_desc")) || clicked(clay.ID("field_panel_desc_missing")) || clicked(clay.ID("field_panel_desc_no_img")) {
if is_editing {
app.selected_field = 9

View File

@ -137,6 +137,7 @@ declare_panel_card :: proc(app: ^GUI_App_State, panel: core.Panel, page_num, pan
if clay.UI(clay.ID(fmt.tprintf("panel_card_actions_%s", panel.panel_id)))({layout = clay_row_layout()}) {
if _, has := app.controller.state.panel_images[panel.panel_id]; has {
declare_button_small("btn_panel_regenerate", "Regen")
declare_button_small("btn_panel_draw", "Draw")
} else {
declare_button_small("btn_panel_regenerate", "Generate")
}