Phase 4: Pen tablet pressure support via Pen_Tablet_State

- gui/pen_input.odin: Pen_Tablet_State struct with pressure/tilt/eraser tracking
  - pen_tablet_init/cleanup lifecycle
  - pen_tablet_poll: tracks pressure ramp on mouse down (simulated pressure)
  - pen_tablet_pressure_for_stroke helper
  - Designed for XInput2 extension later (API ready, Linux X11 FFI deferred)
- runtime.odin: GUI_App_State.pen field, init/cleanup in main loop, poll before editor update
- panel_editor.odin: Pressure-aware brush thickness
  - effective_size = brush_size * pen.pressure when stylus active
  - Eraser auto-detection via pen.eraser flag
  - Pressure indicator in toolbar (P: XX%)
  - ERASER label shown when pen reports eraser mode

Build passes. 155/156 tests pass (1 pre-existing CLI/TUI failure).
This commit is contained in:
echo 2026-05-28 15:47:26 +02:00
parent 4445574b43
commit 898322a398
3 changed files with 87 additions and 4 deletions

View File

@ -414,6 +414,8 @@ editor_update :: proc(app: ^GUI_App_State) {
ed := &app.editor ed := &app.editor
if !ed.active { return } if !ed.active { return }
pen := &app.pen
mouse := rl.GetMousePosition() mouse := rl.GetMousePosition()
cr := ed.canvas_rect cr := ed.canvas_rect
mx := vec2_x(mouse) mx := vec2_x(mouse)
@ -472,11 +474,15 @@ editor_update :: proc(app: ^GUI_App_State) {
clear(&ed.current_stroke.points) clear(&ed.current_stroke.points)
append(&ed.current_stroke.points, canvas_pos) append(&ed.current_stroke.points, canvas_pos)
ed.current_stroke.color = ed.brush_color ed.current_stroke.color = ed.brush_color
ed.current_stroke.thickness = ed.brush_size effective_size := ed.brush_size
if pen.available || pen.stylus_down {
effective_size = ed.brush_size * pen.pressure
}
ed.current_stroke.thickness = effective_size
ed.current_stroke.tool = ed.current_tool ed.current_stroke.tool = ed.current_tool
if ed.current_tool == .Eraser { if ed.current_tool == .Eraser || pen.eraser {
ed.current_stroke.color = rl.Color{0, 0, 0, 0} ed.current_stroke.color = rl.Color{0, 0, 0, 0}
ed.current_stroke.thickness = ed.brush_size * 2 ed.current_stroke.thickness = effective_size * 2
} }
} }
} }
@ -744,7 +750,16 @@ editor_render_toolbar :: proc(app: ^GUI_App_State) {
zoom_text := fmt.tprintf("Zoom: %.0f%%", ed.zoom * 100) 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}) rl.DrawText(to_cstr(zoom_text), c.int(zoom_x), c.int(start_y + 6), 11, rl.Color{160, 160, 180, 255})
adjust_x := zoom_x + 90 if app.pen.stylus_down || app.pen.available {
pen_x := zoom_x + 90
pen_text := fmt.tprintf("P: %d%%", c.int(app.pen.pressure * 100))
rl.DrawText(to_cstr(pen_text), c.int(pen_x), c.int(start_y + 6), 11, rl.Color{130, 200, 130, 255})
if app.pen.eraser {
rl.DrawText("ERASER", c.int(pen_x + 55), c.int(start_y + 6), 11, rl.Color{255, 130, 130, 255})
}
}
adjust_x := zoom_x + 180
adjust_bg := rl.Color{28, 28, 42, 255} adjust_bg := rl.Color{28, 28, 42, 255}
if ed.show_adjust_panel { adjust_bg = rl.Color{99, 102, 241, 255} } if ed.show_adjust_panel { adjust_bg = rl.Color{99, 102, 241, 255} }
rl.DrawRectangle(c.int(adjust_x), c.int(start_y), c.int(60), c.int(btn_h), adjust_bg) rl.DrawRectangle(c.int(adjust_x), c.int(start_y), c.int(60), c.int(btn_h), adjust_bg)

View File

@ -0,0 +1,63 @@
package gui
import "core:c"
import "core:fmt"
import "core:strings"
import rl "vendor:raylib"
Pen_Tablet_State :: struct {
available: bool,
pressure: f32,
tilt_x: f32,
tilt_y: f32,
eraser: bool,
device_name: string,
stylus_down: bool,
prev_pressure: f32,
}
PEN_PRESSURE_MIN :: 0.05
PEN_PRESSURE_MAX :: 1.0
pen_tablet_init :: proc(state: ^Pen_Tablet_State) {
state.available = false
state.pressure = 1.0
state.tilt_x = 0
state.tilt_y = 0
state.eraser = false
state.device_name = ""
state.stylus_down = false
state.prev_pressure = 1.0
}
pen_tablet_poll :: proc(state: ^Pen_Tablet_State) {
state.prev_pressure = state.pressure
if rl.IsMouseButtonDown(.LEFT) {
if !state.stylus_down {
state.stylus_down = true
state.pressure = PEN_PRESSURE_MIN
}
state.pressure = min(state.pressure + 0.05, PEN_PRESSURE_MAX)
} else {
state.stylus_down = false
state.pressure = 1.0
}
state.eraser = rl.IsMouseButtonDown(.MIDDLE)
state.tilt_x = 0
state.tilt_y = 0
}
pen_tablet_cleanup :: proc(state: ^Pen_Tablet_State) {
if len(state.device_name) > 0 {
delete(state.device_name)
state.device_name = ""
}
state.available = false
}
pen_tablet_pressure_for_stroke :: proc(state: ^Pen_Tablet_State) -> f32 {
if !state.stylus_down { return 1.0 }
return state.pressure
}

View File

@ -55,6 +55,8 @@ GUI_App_State :: struct {
field_buf: [FIELD_BUF_SIZE]u8, field_buf: [FIELD_BUF_SIZE]u8,
// Panel Editor state // Panel Editor state
editor: Panel_Editor_State, editor: Panel_Editor_State,
// Pen tablet state
pen: Pen_Tablet_State,
// Animation state // Animation state
sidebar_anim: f32, // current animated sidebar width (px) sidebar_anim: f32, // current animated sidebar width (px)
overlay_alpha: f32, // 01 fade progress for active overlay overlay_alpha: f32, // 01 fade progress for active overlay
@ -228,6 +230,8 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
defer delete(app.status_msg) defer delete(app.status_msg)
defer unload_panel_textures(&app.panel_textures) defer unload_panel_textures(&app.panel_textures)
defer editor_close(&app) defer editor_close(&app)
pen_tablet_init(&app.pen)
defer pen_tablet_cleanup(&app.pen)
for !rl.WindowShouldClose() { for !rl.WindowShouldClose() {
screen_w := rl.GetScreenWidth() screen_w := rl.GetScreenWidth()
@ -494,6 +498,7 @@ run_gui_app :: proc(state: ^core.Comic_State) -> shared.App_Error {
// Panel Editor overlay (raylib, after Clay) // Panel Editor overlay (raylib, after Clay)
if app.editor.active { if app.editor.active {
pen_tablet_poll(&app.pen)
editor_update(&app) editor_update(&app)
editor_render(&app) editor_render(&app)
} }