514 lines
13 KiB
Go
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
|
|
}
|