gui/pkg/window/smart_layout.go
Snider 2c59364250
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Implement chat, preload shims, and smart layouts
2026-04-15 13:39:13 +01:00

514 lines
13 KiB
Go

package window
import (
"context"
"sort"
"strings"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/gui/pkg/screen"
)
type schemeResponse struct {
ContentType string
Body string
}
func (s *Service) buildWindowSpec(t TaskOpenWindow) (*Window, error) {
if t.Window != nil {
spec := *t.Window
return &spec, nil
}
return ApplyOptions(t.Options...)
}
func (s *Service) prepareWindowSpec(spec *Window) error {
if spec == nil {
return coreerr.E("window.prepareWindowSpec", "window spec is nil", nil)
}
rawURL := spec.URL
preload := s.buildPreload(rawURL)
if preload != "" {
if spec.JS != "" {
spec.JS = preload + "\n" + spec.JS
} else {
spec.JS = preload
}
}
if !strings.HasPrefix(rawURL, "core://") {
return nil
}
resolved, ok, err := s.resolveCoreScheme(rawURL)
if err != nil {
return err
}
if !ok {
return coreerr.E("window.prepareWindowSpec", "core scheme handler unavailable for "+rawURL, nil)
}
spec.HTML = resolved.Body
spec.URL = "about:blank"
return nil
}
func (s *Service) buildPreload(rawURL string) string {
if rawURL == "" {
rawURL = "/"
}
result := s.Core().Action("display.buildPreload").Run(context.Background(), core.NewOptions(
core.Option{Key: "url", Value: rawURL},
))
if !result.OK {
return ""
}
script, _ := result.Value.(string)
return script
}
func (s *Service) resolveCoreScheme(rawURL string) (schemeResponse, bool, error) {
result := s.Core().Action("display.resolveScheme").Run(context.Background(), core.NewOptions(
core.Option{Key: "url", Value: rawURL},
))
if !result.OK {
if err, ok := result.Value.(error); ok {
return schemeResponse{}, false, err
}
return schemeResponse{}, false, nil
}
switch value := result.Value.(type) {
case map[string]any:
body, _ := value["body"].(string)
contentType, _ := value["content_type"].(string)
return schemeResponse{ContentType: contentType, Body: body}, true, nil
default:
return schemeResponse{}, false, nil
}
}
func (s *Service) applyWindowBounds(name string, bounds WindowBounds) error {
pw, ok := s.manager.Get(name)
if !ok {
return coreerr.E("window.applyWindowBounds", "window not found: "+name, nil)
}
pw.SetBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height)
s.manager.State().UpdatePosition(name, bounds.X, bounds.Y)
s.manager.State().UpdateSize(name, bounds.Width, bounds.Height)
return nil
}
func (s *Service) defaultScreen() *screen.Screen {
result := s.Core().QUERY(screen.QueryCurrent{})
if result.OK {
if value, ok := result.Value.(*screen.Screen); ok && value != nil {
return value
}
}
result = s.Core().QUERY(screen.QueryPrimary{})
if result.OK {
if value, ok := result.Value.(*screen.Screen); ok && value != nil {
return value
}
}
return nil
}
func (s *Service) screenByID(id string) *screen.Screen {
if id == "" {
return s.defaultScreen()
}
result := s.Core().QUERY(screen.QueryByID{ID: id})
if !result.OK {
return s.defaultScreen()
}
value, _ := result.Value.(*screen.Screen)
if value == nil {
return s.defaultScreen()
}
return value
}
func (s *Service) screenForWindow(name string) *screen.Screen {
info := s.queryWindowByName(name)
if info == nil {
return s.defaultScreen()
}
result := s.Core().QUERY(screen.QueryAtPoint{
X: info.X + max(info.Width/2, 1),
Y: info.Y + max(info.Height/2, 1),
})
if !result.OK {
return s.defaultScreen()
}
value, _ := result.Value.(*screen.Screen)
if value == nil {
return s.defaultScreen()
}
return value
}
func screenWorkArea(scr *screen.Screen) screen.Rect {
if scr == nil {
return screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080}
}
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
return scr.WorkArea
}
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
return scr.Bounds
}
return screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080}
}
func screenID(scr *screen.Screen) string {
if scr == nil {
return ""
}
return scr.ID
}
func normalizeSide(side string) string {
switch strings.ToLower(strings.TrimSpace(side)) {
case "left":
return "left"
case "right":
return "right"
default:
return "auto"
}
}
func preferredEditorWindow(windows []WindowInfo, target string, explicit string) *WindowInfo {
if explicit != "" {
for i := range windows {
if windows[i].Name == explicit {
return &windows[i]
}
}
}
editorHints := []string{"code", "cursor", "zed", "xcode", "idea", "goland", "webstorm", "clion", "fleet", "nvim", "vim"}
for i := range windows {
if windows[i].Name == target {
continue
}
haystack := strings.ToLower(windows[i].Name + " " + windows[i].Title)
for _, hint := range editorHints {
if strings.Contains(haystack, hint) {
return &windows[i]
}
}
}
for i := range windows {
if windows[i].Name == target {
continue
}
if windows[i].Focused {
return &windows[i]
}
}
for i := range windows {
if windows[i].Name != target {
return &windows[i]
}
}
return nil
}
func (s *Service) taskLayoutBesideEditor(task TaskLayoutBesideEditor) (LayoutBesideEditorResult, error) {
target := s.queryWindowByName(task.Name)
if target == nil {
return LayoutBesideEditorResult{}, coreerr.E("window.taskLayoutBesideEditor", "window not found: "+task.Name, nil)
}
editor := preferredEditorWindow(s.queryWindowList(), task.Name, task.Editor)
if editor == nil {
return LayoutBesideEditorResult{}, coreerr.E("window.taskLayoutBesideEditor", "no editor window detected", nil)
}
scr := s.screenForWindow(editor.Name)
area := screenWorkArea(scr)
side := normalizeSide(task.Side)
if side == "auto" {
leftFree := editor.X - area.X
rightFree := (area.X + area.Width) - (editor.X + editor.Width)
if rightFree >= leftFree {
side = "right"
} else {
side = "left"
}
}
targetWidth := target.Width
if targetWidth <= 0 {
targetWidth = area.Width / 3
}
targetWidth = max(area.Width/4, min(targetWidth, area.Width/2))
editorBounds := WindowBounds{X: editor.X, Y: editor.Y, Width: editor.Width, Height: editor.Height}
windowBounds := WindowBounds{Width: targetWidth, Height: area.Height}
leftFree := max(editor.X-area.X, 0)
rightFree := max((area.X+area.Width)-(editor.X+editor.Width), 0)
freeSpace := rightFree
if side == "left" {
freeSpace = leftFree
}
if freeSpace >= targetWidth {
if side == "left" {
windowBounds.X = area.X
} else {
windowBounds.X = area.X + area.Width - targetWidth
}
windowBounds.Y = area.Y
windowBounds.Width = targetWidth
windowBounds.Height = area.Height
} else {
ratio := task.Ratio
if ratio <= 0 || ratio >= 1 {
ratio = 0.62
}
editorWidth := int(float64(area.Width) * ratio)
if editorWidth <= 0 || editorWidth >= area.Width {
editorWidth = area.Width * 62 / 100
}
targetWidth = area.Width - editorWidth
if side == "left" {
windowBounds = WindowBounds{X: area.X, Y: area.Y, Width: targetWidth, Height: area.Height}
editorBounds = WindowBounds{X: area.X + targetWidth, Y: area.Y, Width: editorWidth, Height: area.Height}
} else {
editorBounds = WindowBounds{X: area.X, Y: area.Y, Width: editorWidth, Height: area.Height}
windowBounds = WindowBounds{X: area.X + editorWidth, Y: area.Y, Width: targetWidth, Height: area.Height}
}
if err := s.applyWindowBounds(editor.Name, editorBounds); err != nil {
return LayoutBesideEditorResult{}, err
}
}
if err := s.applyWindowBounds(task.Name, windowBounds); err != nil {
return LayoutBesideEditorResult{}, err
}
return LayoutBesideEditorResult{
Editor: editor.Name,
EditorBounds: editorBounds,
WindowBounds: windowBounds,
Side: side,
ScreenID: screenID(scr),
}, nil
}
func (s *Service) taskLayoutSuggest(task TaskLayoutSuggest) LayoutSuggestion {
scr := s.screenByID(task.ScreenID)
area := screenWorkArea(scr)
windowCount := task.WindowCount
if windowCount <= 0 {
windowCount = len(s.manager.List())
}
mode := "presenting"
reason := "single window fits the full work area"
aspect := float64(area.Width) / float64(max(area.Height, 1))
switch {
case windowCount >= 4:
mode = "grid"
reason = "four or more windows benefit from equal cells"
case windowCount == 3:
if aspect >= 1.35 {
mode = "coding"
reason = "wide screen leaves room for a dominant editor plus side tools"
} else {
mode = "grid"
reason = "balanced thirds fit better on a narrower screen"
}
case windowCount == 2:
if aspect >= 1.2 {
mode = "left-right"
reason = "landscape screen favors a side-by-side split"
} else {
mode = "stack"
reason = "portrait-like screen favors a vertical cascade"
}
}
return LayoutSuggestion{
Mode: mode,
Reason: reason,
ScreenID: screenID(scr),
Width: area.Width,
Height: area.Height,
}
}
func expandRect(rect screen.Rect, padding int) screen.Rect {
if padding <= 0 {
return rect
}
return screen.Rect{
X: rect.X - padding,
Y: rect.Y - padding,
Width: rect.Width + padding*2,
Height: rect.Height + padding*2,
}
}
func rectContains(parent, child screen.Rect) bool {
if child.IsEmpty() || parent.IsEmpty() {
return false
}
return child.X >= parent.X &&
child.Y >= parent.Y &&
child.X+child.Width <= parent.X+parent.Width &&
child.Y+child.Height <= parent.Y+parent.Height
}
func uniqueSorted(values []int) []int {
sort.Ints(values)
if len(values) == 0 {
return values
}
out := values[:1]
for _, value := range values[1:] {
if value != out[len(out)-1] {
out = append(out, value)
}
}
return out
}
func intersectsAny(candidate screen.Rect, occupied []screen.Rect) bool {
for _, rect := range occupied {
if !candidate.Intersect(rect).IsEmpty() {
return true
}
}
return false
}
func (s *Service) taskScreenFindSpace(task TaskScreenFindSpace) ScreenSpace {
scr := s.screenByID(task.ScreenID)
area := screenWorkArea(scr)
padding := task.Padding
if padding < 0 {
padding = 0
}
reqWidth := task.Width
if reqWidth <= 0 {
reqWidth = min(640, area.Width)
}
reqHeight := task.Height
if reqHeight <= 0 {
reqHeight = min(480, area.Height)
}
windows := s.queryWindowList()
occupied := make([]screen.Rect, 0, len(windows))
xEdges := []int{area.X, area.X + area.Width}
yEdges := []int{area.Y, area.Y + area.Height}
for _, win := range windows {
rect := screen.Rect{X: win.X, Y: win.Y, Width: win.Width, Height: win.Height}
rect = rect.Intersect(area)
if rect.IsEmpty() {
continue
}
rect = expandRect(rect, padding)
occupied = append(occupied, rect)
xEdges = append(xEdges, rect.X, rect.X+rect.Width)
yEdges = append(yEdges, rect.Y, rect.Y+rect.Height)
}
xEdges = uniqueSorted(xEdges)
yEdges = uniqueSorted(yEdges)
best := ScreenSpace{
ScreenID: screenID(scr),
X: area.X,
Y: area.Y,
Width: min(reqWidth, area.Width),
Height: min(reqHeight, area.Height),
}
bestArea := -1
for i := 0; i < len(xEdges); i++ {
for j := i + 1; j < len(xEdges); j++ {
for k := 0; k < len(yEdges); k++ {
for l := k + 1; l < len(yEdges); l++ {
candidate := screen.Rect{
X: xEdges[i],
Y: yEdges[k],
Width: xEdges[j] - xEdges[i],
Height: yEdges[l] - yEdges[k],
}
if candidate.Width < reqWidth || candidate.Height < reqHeight {
continue
}
if !rectContains(area, candidate) || intersectsAny(candidate, occupied) {
continue
}
areaScore := candidate.Width * candidate.Height
if areaScore > bestArea {
bestArea = areaScore
best = ScreenSpace{
ScreenID: screenID(scr),
X: candidate.X,
Y: candidate.Y,
Width: candidate.Width,
Height: candidate.Height,
}
}
}
}
}
}
return best
}
func (s *Service) taskWindowArrangePair(task TaskWindowArrangePair) (PairArrangement, error) {
if task.Primary == "" || task.Secondary == "" {
return PairArrangement{}, coreerr.E("window.taskWindowArrangePair", "primary and secondary windows are required", nil)
}
if _, ok := s.manager.Get(task.Primary); !ok {
return PairArrangement{}, coreerr.E("window.taskWindowArrangePair", "window not found: "+task.Primary, nil)
}
if _, ok := s.manager.Get(task.Secondary); !ok {
return PairArrangement{}, coreerr.E("window.taskWindowArrangePair", "window not found: "+task.Secondary, nil)
}
scr := s.screenByID(task.ScreenID)
if task.ScreenID == "" {
scr = s.screenForWindow(task.Primary)
}
area := screenWorkArea(scr)
ratio := task.Ratio
if ratio <= 0 || ratio >= 1 {
ratio = 0.55
}
arrangement := PairArrangement{ScreenID: screenID(scr)}
if area.Width >= area.Height {
leftWidth := int(float64(area.Width) * ratio)
arrangement.Orientation = "horizontal"
arrangement.Primary = WindowBounds{X: area.X, Y: area.Y, Width: leftWidth, Height: area.Height}
arrangement.Secondary = WindowBounds{X: area.X + leftWidth, Y: area.Y, Width: area.Width - leftWidth, Height: area.Height}
} else {
topHeight := int(float64(area.Height) * ratio)
arrangement.Orientation = "vertical"
arrangement.Primary = WindowBounds{X: area.X, Y: area.Y, Width: area.Width, Height: topHeight}
arrangement.Secondary = WindowBounds{X: area.X, Y: area.Y + topHeight, Width: area.Width, Height: area.Height - topHeight}
}
if err := s.applyWindowBounds(task.Primary, arrangement.Primary); err != nil {
return PairArrangement{}, err
}
if err := s.applyWindowBounds(task.Secondary, arrangement.Secondary); err != nil {
return PairArrangement{}, err
}
return arrangement, nil
}