diff --git a/odin/src/gui/chrome.odin b/odin/src/gui/chrome.odin index a3b7438..9144459 100644 --- a/odin/src/gui/chrome.odin +++ b/odin/src/gui/chrome.odin @@ -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) } + } } } diff --git a/odin/src/gui/panel_editor.odin b/odin/src/gui/panel_editor.odin new file mode 100644 index 0000000..73b401b --- /dev/null +++ b/odin/src/gui/panel_editor.odin @@ -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.. 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.. 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..= 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..= 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}) +} diff --git a/odin/src/gui/runtime.odin b/odin/src/gui/runtime.odin index a53c141..cbe9004 100644 --- a/odin/src/gui/runtime.odin +++ b/odin/src/gui/runtime.odin @@ -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) ────────────── - 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 diff --git a/odin/src/gui/workspaces.odin b/odin/src/gui/workspaces.odin index cd7e740..2d3bbcc 100644 --- a/odin/src/gui/workspaces.odin +++ b/odin/src/gui/workspaces.odin @@ -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") }