1package core
2
3import (
4 "fmt"
5 "github.com/bnema/neferwl/internal/ports"
6 "math/bits"
7 "slices"
8 "strconv"
9 "strings"
10 "time"
11)
12
13type WindowID = ports.WindowID
14
15type Rect = ports.Rect
16
17type Width struct{ Num, Den, Pixels int }
18
19func ParseWidth(s string) (Width, error) {
20 if s == "1" {
21 return Width{Num: 1, Den: 1}, nil
22 }
23 if strings.HasSuffix(s, "px") {
24 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
25 if e == nil && n > 0 {
26 return Width{Pixels: n}, nil
27 }
28 } else if strings.HasSuffix(s, "%") {
29 n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
30 if e == nil && n > 0 && n <= 100 {
31 return Width{Num: n, Den: 100}, nil
32 }
33 } else {
34 p := strings.Split(s, "/")
35 if len(p) == 2 {
36 a, e1 := strconv.Atoi(p[0])
37 b, e2 := strconv.Atoi(p[1])
38 if e1 == nil && e2 == nil && a > 0 && a <= b {
39 return Width{Num: a, Den: b}, nil
40 }
41 }
42 }
43 return Width{}, fmt.Errorf("invalid width %q", s)
44}
45
46// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
47// Widths are clamped to the available area (zero if no space remains).
48func (v Width) Resolve(usableW, gaps int) int {
49 if usableW <= 0 {
50 return 0
51 }
52 gaps = min(max(gaps, 0), usableW/2)
53 limit := max(usableW-2*gaps, 0)
54 if limit == 0 {
55 return 0
56 }
57 if v.Pixels > 0 {
58 return min(v.Pixels, limit)
59 }
60 if v.Den <= 0 || v.Num <= 0 {
61 return 1
62 }
63 // Cap invalid fractions above one before multiplying; the result would
64 // be clamped to the available width anyway.
65 num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
66 avail := uint64(usableW - gaps)
67 q, r := avail/den, avail%den
68 hi, lo := bits.Mul64(r, num)
69 lo, carry := bits.Add64(lo, den/2, 0)
70 hi += carry
71 // hi < den: r and num are both smaller than den.
72 fraction, _ := bits.Div64(hi, lo, den)
73 rounded := q*num + fraction // no larger than avail (plus rounding)
74 if rounded <= uint64(gaps) {
75 return 1
76 }
77 return int(min(rounded-uint64(gaps), uint64(limit)))
78}
79
80// Column.Width is zero (auto) until the user picks a preset: auto columns share
81// the usable width equally, up to MaxColumns visible at once.
82type Column struct {
83 Windows []WindowID
84 Width Width
85 Focus int
86 // Slot is the declared column number (workspace.<name>.column.N) of a
87 // slot window; 0 for normal columns.
88 Slot int
89}
90type Placement struct {
91 ID WindowID
92 Rect Rect
93 Fullscreen, Focused, Hidden bool
94 // Floating windows sit over the columns at their own size.
95 Floating bool
96 // Neighbors are the sides touching another tiled window. Inset are the
97 // sides where the border takes room from the window: all sides of a
98 // float; for tiles, the right and bottom neighbors only, so two
99 // windows share one separator line (tmux style). With gaps, each tile
100 // insets all its neighbor sides.
101 Neighbors, Inset ports.Sides
102}
103
104// Overflow says what happens past MaxColumns columns.
105type Overflow string
106
107const (
108 // OverflowScroll opens further columns to the right; the view scrolls.
109 OverflowScroll Overflow = "scroll"
110 // OverflowFixed keeps every column on screen: past the max, each new
111 // column splits the newest one, alternating top/bottom and left/right
112 // (dwindle spiral).
113 OverflowFixed Overflow = "fixed"
114)
115
116type Workspace struct {
117 // Name is set for workspaces declared in config; empty for dynamic ones.
118 Name string
119 Overflow Overflow
120 Columns []Column
121 Focus int
122 ViewX int
123 Output Rect
124 Usable Rect
125 Gaps int
126 MaxColumns int
127 // border is drawn around floating windows, inside their rect.
128 border int
129 presets []Width
130 fullscreen WindowID
131 // Floats are floating windows, bottom to top; floatFocus is set while
132 // the top one has the focus instead of the columns.
133 Floats []Float
134 floatFocus bool
135 // home is the monitor (key or connector) the workspace belongs to; ""
136 // means the one it is on. On another monitor it is a guest; homePos is
137 // its position there, where it returns.
138 home string
139 homePos int
140 // termAt is when core last spawned a terminal for this workspace.
141 termAt time.Time
142 // origin is set on the workspace a fixed-overflow fullscreen window
143 // moved to; back is where it returns there (Monitor.fullscreenHome).
144 origin *Workspace
145 back origPlace
146}
147
148// origPlace remembers where a window was: its column, its row in a stacked
149// column, the column's width and slot, or its floating size.
150type origPlace struct {
151 // id is the window that went fullscreen; tiled lists the windows that
152 // joined its workspace tiled, floating there until they go home.
153 id WindowID
154 tiled []WindowID
155 col, row, slot int
156 // stacked holds the windows left in the column: the window returns to
157 // its row in the column that still holds one of them.
158 stacked []WindowID
159 width Width
160 float *Float
161}
162
163// Float is a floating window and its client size, logical.
164type Float struct {
165 ID WindowID
166 W, H int
167}
168
169func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 }
170
171// windows lists every window: columns first, then floating ones.
172func (w *Workspace) windows() []WindowID {
173 var ids []WindowID
174 for _, c := range w.Columns {
175 ids = append(ids, c.Windows...)
176 }
177 for _, f := range w.Floats {
178 ids = append(ids, f.ID)
179 }
180 return ids
181}
182
183func (w *Workspace) floatIndex(id WindowID) int {
184 return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id })
185}
186
187// AddFloating shows a window over the columns and focuses it.
188func (w *Workspace) AddFloating(id WindowID, width, height int) {
189 if id == 0 || w.has(id) {
190 return
191 }
192 w.Floats = append(w.Floats, Float{ID: id, W: width, H: height})
193 w.floatFocus = true
194}
195
196// ResizeFloating records the size a floating window draws.
197func (w *Workspace) ResizeFloating(id WindowID, width, height int) {
198 if i := w.floatIndex(id); i >= 0 {
199 w.Floats[i].W, w.Floats[i].H = width, height
200 }
201}
202
203// has reports whether the workspace holds the window.
204func (w *Workspace) has(id WindowID) bool {
205 if w.floatIndex(id) >= 0 {
206 return true
207 }
208 for _, c := range w.Columns {
209 for _, v := range c.Windows {
210 if v == id {
211 return true
212 }
213 }
214 }
215 return false
216}
217
218func (w *Workspace) Focused() (WindowID, bool) {
219 if w.floatFocus && len(w.Floats) > 0 {
220 return w.Floats[len(w.Floats)-1].ID, true
221 }
222 if w.Focus < 0 || w.Focus >= len(w.Columns) {
223 return 0, false
224 }
225 c := w.Columns[w.Focus]
226 if c.Focus < 0 || c.Focus >= len(c.Windows) {
227 return 0, false
228 }
229 return c.Windows[c.Focus], true
230}
231func (w *Workspace) AddWindow(id WindowID) {
232 if id == 0 || w.has(id) {
233 return
234 }
235 w.addColumn(Column{Windows: []WindowID{id}})
236}
237
238// addColumn places a new column like a new window and focuses it.
239func (w *Workspace) addColumn(col Column) {
240 at := 0
241 if w.Overflow == OverflowFixed {
242 // The spiral depends only on window order: new windows go last.
243 at = len(w.Columns)
244 } else if len(w.Columns) > 0 {
245 at = w.Focus + 1
246 }
247 w.Columns = append(w.Columns, Column{})
248 copy(w.Columns[at+1:], w.Columns[at:])
249 w.Columns[at] = col
250 w.Focus = at
251 w.scroll()
252}
253
254// AddSlotWindow places a slot window in its own column, ordered by slot
255// number before the other columns. Focus stays on the focused window: slot
256// windows are automatic events (ADR 011 golden rule).
257func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
258 if id == 0 || w.has(id) {
259 return
260 }
261 // Right after the last slot column with a lower number, else first.
262 at := 0
263 for i, c := range w.Columns {
264 if c.Slot > 0 && c.Slot < slot {
265 at = i + 1
266 }
267 }
268 w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
269 if len(w.Columns) > 1 && at <= w.Focus {
270 w.Focus++
271 }
272 w.scroll()
273}
274
275// setSlotWidth updates the width of slot column n, if present.
276func (w *Workspace) setSlotWidth(n int, width Width) {
277 for i := range w.Columns {
278 if w.Columns[i].Slot == n {
279 w.Columns[i].Width = width
280 }
281 }
282 w.scroll()
283}
284
285// inSlot reports whether window id is in slot column n.
286func (w *Workspace) inSlot(id WindowID, n int) bool {
287 for _, c := range w.Columns {
288 if c.Slot == n && slices.Contains(c.Windows, id) {
289 return true
290 }
291 }
292 return false
293}
294
295// unslot turns slot column n into a normal column.
296func (w *Workspace) unslot(n int) {
297 for i := range w.Columns {
298 if w.Columns[i].Slot == n {
299 w.Columns[i].Slot = 0
300 }
301 }
302}
303
304func (w *Workspace) RemoveWindow(id WindowID) {
305 if i := w.floatIndex(id); i >= 0 {
306 w.Floats = slices.Delete(w.Floats, i, i+1)
307 if w.fullscreen == id {
308 w.fullscreen = 0
309 }
310 if len(w.Floats) == 0 {
311 w.floatFocus = false
312 }
313 return
314 }
315 for i := range w.Columns {
316 for j, v := range w.Columns[i].Windows {
317 if v != id {
318 continue
319 }
320 c := &w.Columns[i]
321 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
322 if w.fullscreen == id {
323 w.fullscreen = 0
324 }
325 if len(c.Windows) == 0 {
326 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
327 if len(w.Columns) == 0 {
328 w.Focus = 0
329 w.ViewX = 0
330 return
331 }
332 if i < w.Focus || (i == w.Focus && i > 0) {
333 w.Focus--
334 }
335 } else {
336 if j < c.Focus {
337 c.Focus--
338 } else if j == c.Focus && c.Focus > 0 {
339 c.Focus--
340 }
341 }
342 w.scroll()
343 return
344 }
345 }
346}
347
348// FocusID selects a window and scrolls its column into view; a floating
349// window is raised.
350func (w *Workspace) FocusID(id WindowID) bool {
351 if i := w.floatIndex(id); i >= 0 {
352 f := w.Floats[i]
353 w.Floats = append(slices.Delete(w.Floats, i, i+1), f)
354 w.floatFocus = true
355 return true
356 }
357 w.floatFocus = false
358 for i := range w.Columns {
359 for j, v := range w.Columns[i].Windows {
360 if v == id {
361 w.Focus = i
362 w.Columns[i].Focus = j
363 w.scroll()
364 return true
365 }
366 }
367 }
368 return false
369}
370func (w *Workspace) FocusColumn(dir int) {
371 if w.floatFocus {
372 // The first move leaves the floating window for the columns.
373 w.floatFocus = false
374 return
375 }
376 if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
377 w.Focus += dir
378 w.scroll()
379 }
380}
381
382// FocusWindow moves focus inside the column; false means it was already at the edge.
383func (w *Workspace) FocusWindow(dir int) bool {
384 if w.floatFocus {
385 w.floatFocus = false
386 return true
387 }
388 if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
389 return false
390 }
391 c := &w.Columns[w.Focus]
392 if c.Focus+dir >= 0 && c.Focus+dir < len(c.Windows) {
393 c.Focus += dir
394 w.scroll()
395 return true
396 }
397 return false
398}
399func (w *Workspace) MoveColumn(dir int) {
400 if w.floatFocus {
401 return
402 }
403 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
404 i := w.Focus
405 w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
406 w.Focus += dir
407 w.scroll()
408 }
409}
410
411// takeColumn removes the focused column and returns it, as a normal column.
412func (w *Workspace) takeColumn() (Column, bool) {
413 if len(w.Columns) == 0 || w.floatFocus {
414 return Column{}, false
415 }
416 col := w.Columns[w.Focus]
417 col.Slot = 0
418 if slices.Contains(col.Windows, w.fullscreen) {
419 w.fullscreen = 0
420 }
421 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
422 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
423 if len(w.Columns) == 0 {
424 w.ViewX = 0
425 } else {
426 w.scroll()
427 }
428 return col, true
429}
430
431// insertColumn adds a column at index at (clamped) and focuses it.
432func (w *Workspace) insertColumn(at int, col Column) {
433 at = min(max(at, 0), len(w.Columns))
434 w.Columns = slices.Insert(w.Columns, at, col)
435 w.Focus = at
436 w.scroll()
437}
438
439func (w *Workspace) CycleWidth() {
440 if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus {
441 return
442 }
443 // auto → presets in order → auto.
444 c := &w.Columns[w.Focus]
445 next := Width{}
446 if c.Width == next {
447 next = w.presets[0]
448 }
449 for i, v := range w.presets {
450 if v == c.Width && i+1 < len(w.presets) {
451 next = w.presets[i+1]
452 break
453 }
454 }
455 c.Width = next
456 w.scroll()
457}
458func (w *Workspace) ToggleFullscreen() {
459 id, ok := w.Focused()
460 if !ok {
461 return
462 }
463 if w.fullscreen == id {
464 w.fullscreen = 0
465 } else {
466 w.fullscreen = id
467 }
468 w.scroll()
469}
470
471// Activate focuses a window and makes it visible: a tiled window leaves
472// another window's fullscreen, which would hide it.
473func (w *Workspace) Activate(id WindowID) {
474 if w.fullscreen != 0 && w.fullscreen != id && w.floatIndex(id) < 0 {
475 w.fullscreen = 0
476 }
477 w.FocusID(id)
478}
479
480// SetFullscreen applies a client request. It never moves focus: client
481// requests are automatic events (ADR 011 golden rule).
482func (w *Workspace) SetFullscreen(id WindowID, on bool) {
483 if w.floatIndex(id) >= 0 {
484 if on {
485 w.fullscreen = id
486 } else if w.fullscreen == id {
487 w.fullscreen = 0
488 }
489 return
490 }
491 for i := range w.Columns {
492 for _, v := range w.Columns[i].Windows {
493 if v == id {
494 if on {
495 w.fullscreen = id
496 } else if w.fullscreen == id {
497 w.fullscreen = 0
498 }
499 w.scroll()
500 return
501 }
502 }
503 }
504}
505func (w *Workspace) SetOutput(width, height int) {
506 if width < 0 {
507 width = 0
508 }
509 if height < 0 {
510 height = 0
511 }
512 old := w.Output
513 w.Output = Rect{W: width, H: height}
514 if w.Usable == old {
515 w.Usable = w.Output
516 }
517 w.SetUsable(w.Usable)
518}
519
520// SetUsable clamps negative dimensions to zero and clips the usable rectangle
521// to the output. Effective gaps shrink to fit the available area.
522func (w *Workspace) SetUsable(r Rect) {
523 r.X = min(max(r.X, 0), w.Output.W)
524 r.Y = min(max(r.Y, 0), w.Output.H)
525 r.W = min(max(r.W, 0), w.Output.W-r.X)
526 r.H = min(max(r.H, 0), w.Output.H-r.Y)
527 w.Usable = r
528 w.scroll()
529}
530func (w *Workspace) SetGaps(g int) {
531 if g < 0 {
532 g = 0
533 }
534 w.Gaps = g
535 w.scroll()
536}
537func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
538func (w *Workspace) SetMaxColumns(n int) {
539 w.MaxColumns = n
540 w.scroll()
541}
542func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
543func (w *Workspace) fullscreenColumn(i int) bool {
544 for _, id := range w.Columns[i].Windows {
545 if id == w.fullscreen && id != 0 {
546 return true
547 }
548 }
549 return false
550}
551func (w *Workspace) columnWidth(i int) int {
552 if w.fullscreenColumn(i) {
553 return w.Output.W
554 }
555 g := w.gap()
556 // Fixed overflow never scrolls, so presets would push columns off screen.
557 if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed {
558 if len(w.Columns) == 1 {
559 return max(w.Usable.W-2*g, 0)
560 }
561 // Equal shares of the width left after gaps; a remainder under k pixels stays empty.
562 k := min(len(w.Columns), max(w.MaxColumns, 1))
563 return max((w.Usable.W-g*(k+1))/k, 0)
564 }
565 return w.Columns[i].Width.Resolve(w.Usable.W, g)
566}
567func (w *Workspace) columnX(i int) int {
568 x := w.Usable.X + w.gap()
569 for j := 0; j < i; j++ {
570 x += w.columnWidth(j) + w.gap()
571 }
572 return x
573}
574func (w *Workspace) scroll() {
575 if w.Overflow == OverflowFixed {
576 w.ViewX = 0
577 return
578 }
579 if len(w.Columns) == 0 {
580 return
581 }
582 left := w.columnX(w.Focus) - w.ViewX
583 width := w.columnWidth(w.Focus)
584 if w.fullscreenColumn(w.Focus) {
585 w.ViewX += left
586 return
587 }
588 minX := w.Usable.X + w.gap()
589 maxX := w.Usable.X + w.Usable.W - w.gap()
590 if width > maxX-minX || left < minX {
591 w.ViewX += left - minX
592 } else if left+width > maxX {
593 w.ViewX += left + width - maxX
594 }
595}
596
597// columnRects returns each column's area on screen, before stacking windows.
598func (w *Workspace) columnRects() []Rect {
599 g := w.gap()
600 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
601 rects := make([]Rect, len(w.Columns))
602 for i := range w.Columns {
603 rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h}
604 }
605 k := max(w.MaxColumns, 1)
606 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
607 return rects
608 }
609 // The last slot holds the spiral; the columns before it keep their place.
610 area := rects[k-1]
611 for i := k - 1; i < len(w.Columns)-1; i++ {
612 rects[i], area = split(area, g, (i-k+1)%2 == 0)
613 }
614 rects[len(rects)-1] = area
615 return rects
616}
617
618// split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
619func split(r Rect, gap int, vertical bool) (Rect, Rect) {
620 if vertical {
621 h := max((r.H-gap)/2, 0)
622 return Rect{X: r.X, Y: r.Y, W: r.W, H: h}, Rect{X: r.X, Y: r.Y + h + gap, W: r.W, H: max(r.H-gap-h, 0)}
623 }
624 w := max((r.W-gap)/2, 0)
625 return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H}
626}
627
628func (w *Workspace) Layout() []Placement {
629 var result []Placement
630 gap := w.gap()
631 cols := w.columnRects()
632 focusedID, _ := w.Focused()
633 floatFocused := w.floatIndex(focusedID) >= 0
634 for i, c := range w.Columns {
635 col := cols[i]
636 n := len(c.Windows)
637 if n == 0 {
638 continue
639 }
640 fullColumn := w.fullscreenColumn(i)
641 if fullColumn {
642 col.W = w.Output.W
643 }
644 available := max(col.H-(n-1)*gap, 0)
645 height := available / n
646 y := col.Y
647 bottom := col.Y + col.H
648 for j, id := range c.Windows {
649 h := height
650 if j == n-1 {
651 h = available - height*(n-1)
652 }
653 y = min(y, bottom)
654 h = min(h, bottom-y)
655 r := Rect{X: col.X, Y: y, W: col.W, H: h}
656 full := w.fullscreen == id && id != 0
657 // Fixed overflow keeps every column on screen: hide them all
658 // behind a fullscreen window.
659 hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full
660 if full {
661 // Scroll mode aligns the view on the column; fixed never scrolls.
662 r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H}
663 if w.Overflow == OverflowFixed {
664 r.X = 0
665 }
666 }
667 if hidden {
668 r = Rect{}
669 }
670 focused := !floatFocused && i == w.Focus && j == c.Focus
671 result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden})
672 y += h + gap
673 }
674 }
675 setNeighbors(result, gap)
676 // Floating windows go last: they are drawn and hit on top.
677 for _, f := range w.Floats {
678 p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID, Inset: ports.SideAll}
679 // Floats stay above a fullscreen window: a dialog opened from a
680 // fullscreen app must be seen.
681 if w.fullscreen == f.ID {
682 p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true
683 }
684 result = append(result, p)
685 }
686 return result
687}
688
689// setNeighbors marks the sides where tiles touch across the gap, from
690// geometry alone: client sizes do not change as the view scrolls.
691func setNeighbors(tiles []Placement, gap int) {
692 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
693 for i := range tiles {
694 a := &tiles[i]
695 if a.Hidden || a.Fullscreen || a.Rect.W <= 0 || a.Rect.H <= 0 {
696 continue
697 }
698 for j := range tiles {
699 b := tiles[j].Rect
700 if i == j || tiles[j].Hidden || tiles[j].Fullscreen || b.W <= 0 || b.H <= 0 {
701 continue
702 }
703 r := a.Rect
704 switch {
705 case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
706 a.Neighbors |= ports.SideRight
707 case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H):
708 a.Neighbors |= ports.SideLeft
709 case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
710 a.Neighbors |= ports.SideBottom
711 case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W):
712 a.Neighbors |= ports.SideTop
713 }
714 }
715 a.Inset = a.Neighbors
716 if gap == 0 {
717 a.Inset &= ports.SideRight | ports.SideBottom
718 }
719 }
720}
721
722// floatRect centres a floating window in the usable area, its border
723// around its client size, clamped to the area.
724func (w *Workspace) floatRect(f Float) Rect {
725 u := w.Usable
726 fw, fh := f.W, f.H
727 if fw <= 0 || fh <= 0 {
728 fw, fh = u.W/2, u.H/2
729 }
730 fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
731 return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
732}