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:
parent
152ef17610
commit
49b383db2e
@ -257,6 +257,9 @@ 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},
|
||||
}) {
|
||||
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)
|
||||
@ -281,6 +284,7 @@ declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export: bool, next_hint: string, has_fal_key: bool, bp: shared.Breakpoint) {
|
||||
if clay.UI(clay.ID("BottomBar"))({
|
||||
|
||||
735
odin/src/gui/panel_editor.odin
Normal file
735
odin/src/gui/panel_editor.odin
Normal 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})
|
||||
}
|
||||
@ -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, // 0→1 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) ──────────────
|
||||
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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user