1 package etk
2
3 import (
4 "fmt"
5 "image"
6 "image/color"
7 "io"
8 "log"
9 "os"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "codeberg.org/tslocum/etk/messeji"
16 "github.com/hajimehoshi/ebiten/v2"
17 "github.com/hajimehoshi/ebiten/v2/inpututil"
18 "github.com/hajimehoshi/ebiten/v2/text/v2"
19 "golang.org/x/image/font"
20 "golang.org/x/image/math/fixed"
21 )
22
23
24 type Alignment int
25
26 const (
27
28 AlignStart Alignment = 0
29
30
31 AlignCenter Alignment = 1
32
33
34 AlignEnd Alignment = 2
35 )
36
37
38
39 var ResizeDebounce = 250 * time.Millisecond
40
41 var root Widget
42
43 var drawDebug bool
44
45 var (
46 lastWidth, lastHeight int
47
48 lastX, lastY int
49 lastCursorX, lastCursorY int
50
51 touchIDs []ebiten.TouchID
52 activeTouchID = ebiten.TouchID(-1)
53
54 focusedWidget Widget
55
56 pressedWidget Widget
57
58 cursorShape ebiten.CursorShapeType
59
60 foundFocused bool
61
62 lastResize time.Time
63
64 lastBackspaceRepeat time.Time
65
66 keyBuffer []ebiten.Key
67 runeBuffer []rune
68
69 fontMutex = &sync.Mutex{}
70 )
71
72 const maxScroll = 3
73
74 var debugColor = color.RGBA{0, 0, 255, 255}
75
76 const (
77 backspaceRepeatWait = 500 * time.Millisecond
78 backspaceRepeatTime = 75 * time.Millisecond
79 )
80
81 func init() {
82 envDebug := os.Getenv("ETK_DEBUG")
83 if strings.TrimSpace(envDebug) == "1" {
84 SetDebug(true)
85 }
86 }
87
88 var deviceScale float64
89
90
91
92
93
94 func ScaleFactor() float64 {
95 if deviceScale == 0 {
96 envScale := os.Getenv("ETK_SCALE")
97 if envScale != "" {
98 envFloat, err := strconv.ParseFloat(strings.TrimSpace(envScale), 64)
99 if err != nil || envFloat < 0 {
100 log.Fatal("Error: ETK_SCALE is set to an invalid value. Set ETK_SCALE to a positive number to override the scale factor, or 0 to use the device's default scale factor.")
101 }
102 deviceScale = envFloat
103 }
104 if deviceScale == 0 {
105 monitor := ebiten.Monitor()
106 if monitor != nil {
107 deviceScale = monitor.DeviceScaleFactor()
108 }
109 if deviceScale <= 0 {
110 deviceScale = ebiten.DeviceScaleFactor()
111 }
112 }
113 }
114 return deviceScale
115 }
116
117
118
119
120
121 func Scale(v int) int {
122 if deviceScale == 0 {
123 deviceScale = ScaleFactor()
124 }
125 return int(float64(v) * deviceScale)
126 }
127
128 var (
129 fontCache = make(map[string]font.Face)
130 fontCacheLock sync.Mutex
131 )
132
133
134 func FontFace(source *text.GoTextFaceSource, size int) *text.GoTextFace {
135 return &text.GoTextFace{
136 Source: source,
137 Size: float64(size),
138 }
139 }
140
141
142
143 func Root() Widget {
144 return root
145 }
146
147
148
149 func SetRoot(w Widget) {
150 root = w
151 if root != nil && (lastWidth != 0 || lastHeight != 0) {
152 root.SetRect(image.Rect(0, 0, lastWidth, lastHeight))
153 }
154 SetFocus(root)
155 }
156
157
158 func SetFocus(w Widget) {
159 lastFocused := focusedWidget
160 if w != nil && !w.SetFocus(true) {
161 return
162 }
163 if lastFocused != nil && lastFocused != w {
164 lastFocused.SetFocus(false)
165 }
166 focusedWidget = w
167 }
168
169
170 func Focused() Widget {
171 return focusedWidget
172 }
173
174 func int26ToRect(r fixed.Rectangle26_6) image.Rectangle {
175 x, y := r.Min.X, r.Min.Y
176 w, h := r.Max.X-r.Min.X, r.Max.Y-r.Min.Y
177 return image.Rect(x.Round(), y.Round(), (x + w).Round(), (y + h).Round())
178 }
179
180
181 func BoundString(f *text.GoTextFace, s string) image.Rectangle {
182 fontMutex.Lock()
183 defer fontMutex.Unlock()
184
185 w, h := text.Measure(s, f, 0)
186 return image.Rect(0, 0, int(w), int(h))
187 }
188
189
190
191 func SetDebug(debug bool) {
192 drawDebug = debug
193 }
194
195
196 func ScreenSize() (width int, height int) {
197 return lastWidth, lastHeight
198 }
199
200
201
202 func Layout(outsideWidth int, outsideHeight int) (scaledWidth int, scaledHeight int) {
203 if !lastResize.IsZero() && time.Since(lastResize) < ResizeDebounce && outsideWidth != 0 && outsideHeight != 0 {
204 return lastWidth, lastHeight
205 }
206
207 outsideWidth, outsideHeight = Scale(outsideWidth), Scale(outsideHeight)
208 if outsideWidth != lastWidth || outsideHeight != lastHeight {
209 lastWidth, lastHeight = outsideWidth, outsideHeight
210 }
211
212 if root == nil {
213 return outsideWidth, outsideHeight
214 }
215 root.SetRect(image.Rect(0, 0, outsideWidth, outsideHeight))
216 return outsideWidth, outsideHeight
217 }
218
219
220 func Update() error {
221 if root == nil {
222 return nil
223 }
224
225 cursor := image.Point{lastX, lastY}
226
227
228
229 var pressed bool
230 var clicked bool
231 var touchInput bool
232
233 if activeTouchID == -1 {
234 touchIDs = inpututil.AppendJustPressedTouchIDs(touchIDs[:0])
235 for _, id := range touchIDs {
236 x, y := ebiten.TouchPosition(id)
237 if x > 0 || y > 0 {
238 activeTouchID = id
239
240 clicked = true
241 touchInput = true
242 break
243 }
244 }
245 }
246
247 if activeTouchID != -1 {
248 x, y := ebiten.TouchPosition(activeTouchID)
249 if x > 0 || y > 0 {
250 cursor.X, cursor.Y = x, y
251 lastX, lastY = x, y
252 pressed = true
253 touchInput = true
254 } else {
255 activeTouchID = -1
256 }
257 }
258
259
260
261 if !touchInput {
262 x, y := ebiten.CursorPosition()
263
264 if (x > 0 || y > 0) && (x != lastCursorX || y != lastCursorY) {
265 cursor.X, cursor.Y = x, y
266 lastX, lastY = x, y
267 lastCursorX, lastCursorY = x, y
268 }
269
270 for _, binding := range Bindings.ConfirmMouse {
271 pressed = ebiten.IsMouseButtonPressed(binding)
272 if pressed {
273 break
274 }
275 }
276
277 for _, binding := range Bindings.ConfirmMouse {
278 clicked = inpututil.IsMouseButtonJustPressed(binding)
279 if clicked {
280 break
281 }
282 }
283 }
284
285 if pressedWidget != nil {
286 c := cursor
287 if c.X <= 0 && c.Y <= 0 {
288 c.X, c.Y = lastX, lastY
289 }
290 _, err := pressedWidget.HandleMouse(c, pressed, clicked)
291 if err != nil {
292 return err
293 }
294 if !pressed && !clicked {
295 pressedWidget = nil
296 }
297 }
298
299 mouseHandled, err := update(root, cursor, pressed, clicked, false)
300 if err != nil {
301 return fmt.Errorf("failed to handle widget mouse input: %s", err)
302 } else if !mouseHandled && cursorShape != ebiten.CursorShapeDefault {
303 ebiten.SetCursorShape(ebiten.CursorShapeDefault)
304 cursorShape = ebiten.CursorShapeDefault
305 }
306
307
308
309 if focusedWidget == nil {
310 return nil
311 } else if ebiten.IsKeyPressed(ebiten.KeyBackspace) {
312 if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
313 lastBackspaceRepeat = time.Now().Add(backspaceRepeatWait)
314 } else if time.Since(lastBackspaceRepeat) >= backspaceRepeatTime {
315 lastBackspaceRepeat = time.Now()
316
317 _, err := focusedWidget.HandleKeyboard(ebiten.KeyBackspace, 0)
318 if err != nil {
319 return err
320 }
321 }
322 }
323
324
325 if inpututil.IsKeyJustPressed(ebiten.KeyV) && ebiten.IsKeyPressed(ebiten.KeyControl) {
326 focused := Focused()
327 if focused != nil {
328 writer, ok := focused.(io.Writer)
329 if ok {
330 _, err := writer.Write(clipboardBuffer())
331 return err
332 }
333 }
334 }
335
336 keyBuffer = inpututil.AppendJustPressedKeys(keyBuffer[:0])
337 for _, key := range keyBuffer {
338 _, err := focusedWidget.HandleKeyboard(key, 0)
339 if err != nil {
340 return fmt.Errorf("failed to handle widget keyboard input: %s", err)
341 }
342 }
343
344 runeBuffer = ebiten.AppendInputChars(runeBuffer[:0])
345 INPUTCHARS:
346 for i, r := range runeBuffer {
347 if i > 0 {
348 for j, r2 := range runeBuffer {
349 if j == i {
350 break
351 } else if r2 == r {
352 continue INPUTCHARS
353 }
354 }
355 }
356 var err error
357 switch r {
358 case Bindings.ConfirmRune:
359 _, err = focusedWidget.HandleKeyboard(ebiten.KeyEnter, 0)
360 case Bindings.BackRune:
361 _, err = focusedWidget.HandleKeyboard(ebiten.KeyBackspace, 0)
362 default:
363 _, err = focusedWidget.HandleKeyboard(-1, r)
364 }
365 if err != nil {
366 return fmt.Errorf("failed to handle widget keyboard input: %s", err)
367 }
368 }
369 return nil
370 }
371
372 func at(w Widget, p image.Point) Widget {
373 if w == nil || !w.Visible() {
374 return nil
375 }
376
377 for _, child := range w.Children() {
378 result := at(child, p)
379 if result != nil {
380 return result
381 }
382 }
383
384 if p.In(w.Rect()) {
385 return w
386 }
387
388 return nil
389 }
390
391
392 func At(p image.Point) Widget {
393 return at(root, p)
394 }
395
396 func update(w Widget, cursor image.Point, pressed bool, clicked bool, mouseHandled bool) (bool, error) {
397 if w == nil {
398 return false, nil
399 } else if !w.Visible() {
400 return mouseHandled, nil
401 }
402
403 var err error
404 children := w.Children()
405 for i := len(children) - 1; i >= 0; i-- {
406 mouseHandled, err = update(children[i], cursor, pressed, clicked, mouseHandled)
407 if err != nil {
408 return false, err
409 } else if mouseHandled {
410 return true, nil
411 }
412 }
413 if !mouseHandled && cursor.In(w.Rect()) {
414 if pressed && !clicked && w != pressedWidget {
415 return mouseHandled, nil
416 }
417 originalFocus := focusedWidget
418 mouseHandled, err = w.HandleMouse(cursor, pressed, clicked)
419 if err != nil {
420 return false, fmt.Errorf("failed to handle widget mouse input: %s", err)
421 } else if mouseHandled {
422 if clicked {
423 if focusedWidget == originalFocus {
424 SetFocus(w)
425 }
426 pressedWidget = w
427 } else if pressedWidget != nil && (!pressed || pressedWidget != w) {
428 pressedWidget = nil
429 }
430 shape := w.Cursor()
431 if shape == -1 {
432 shape = ebiten.CursorShapeDefault
433 }
434 if shape != cursorShape {
435 ebiten.SetCursorShape(shape)
436 cursorShape = shape
437 }
438 }
439 }
440 return mouseHandled, nil
441 }
442
443
444 func Draw(screen *ebiten.Image) error {
445 foundFocused = false
446 err := draw(root, screen)
447 if err != nil {
448 return err
449 } else if focusedWidget != nil && !foundFocused {
450 SetFocus(nil)
451 }
452 return nil
453 }
454
455 func draw(w Widget, screen *ebiten.Image) error {
456 if w == nil || !w.Visible() {
457 return nil
458 }
459
460 r := w.Rect()
461 subScreen := screen
462 if w.Clip() {
463 subScreen = screen.SubImage(r).(*ebiten.Image)
464 }
465
466 background := w.Background()
467 if background.A > 0 {
468 if subScreen == screen {
469 screen.SubImage(r).(*ebiten.Image).Fill(background)
470 } else {
471 subScreen.Fill(background)
472 }
473 }
474
475 err := w.Draw(subScreen)
476 if err != nil {
477 return fmt.Errorf("failed to draw widget: %s", err)
478 }
479
480 if drawDebug && !r.Empty() {
481 x, y := r.Min.X, r.Min.Y
482 w, h := r.Dx(), r.Dy()
483 screen.SubImage(image.Rect(x, y, x+w, y+1)).(*ebiten.Image).Fill(debugColor)
484 screen.SubImage(image.Rect(x, y+h-1, x+w, y+h)).(*ebiten.Image).Fill(debugColor)
485 screen.SubImage(image.Rect(x, y, x+1, y+h)).(*ebiten.Image).Fill(debugColor)
486 screen.SubImage(image.Rect(x+w-1, y, x+w, y+h)).(*ebiten.Image).Fill(debugColor)
487 }
488
489 children := w.Children()
490 for _, child := range children {
491 err = draw(child, subScreen)
492 if err != nil {
493 return fmt.Errorf("failed to draw widget: %s", err)
494 }
495 }
496
497 if w == focusedWidget {
498 foundFocused = true
499 }
500 return nil
501 }
502
503 func newText() *messeji.TextField {
504 f := messeji.NewTextField(Style.TextFont, Scale(Style.TextSize), fontMutex)
505 f.SetForegroundColor(Style.TextColorLight)
506 f.SetBackgroundColor(transparent)
507 f.SetScrollBarColors(Style.ScrollAreaColor, Style.ScrollHandleColor)
508 f.SetScrollBorderSize(Scale(Style.ScrollBorderSize))
509 f.SetScrollBorderColors(Style.ScrollBorderTop, Style.ScrollBorderRight, Style.ScrollBorderBottom, Style.ScrollBorderLeft)
510 return f
511 }
512
513 func rectAtOrigin(r image.Rectangle) image.Rectangle {
514 r.Max.X, r.Max.Y = r.Dx(), r.Dy()
515 r.Min.X, r.Min.Y = 0, 0
516 return r
517 }
518
519 func _clamp(x int, y int, boundary image.Rectangle) (int, int) {
520 if x < boundary.Min.X {
521 x = boundary.Min.X
522 } else if x > boundary.Max.X {
523 x = boundary.Max.X
524 }
525 if y < boundary.Min.Y {
526 y = boundary.Min.Y
527 } else if y > boundary.Max.Y {
528 y = boundary.Max.Y
529 }
530 return x, y
531 }
532
533 func clampRect(r image.Rectangle, boundary image.Rectangle) image.Rectangle {
534 r.Min.X, r.Min.Y = _clamp(r.Min.X, r.Min.Y, boundary)
535 r.Max.X, r.Max.Y = _clamp(r.Max.X, r.Max.Y, boundary)
536 if r.Min.X == r.Max.X || r.Min.Y == r.Max.Y {
537 return image.Rectangle{}
538 }
539 return r
540 }
541
View as plain text