aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui/window.go
blob: c42a7e89e1fcaad047b9d55df076d77adc4f8c5f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// SPDX-License-Identifier: GPL-3.0-or-later

// Package ui is krino-gui's GTK4 layer: it renders what package model
// holds and forwards what the user does back to it. It makes no decisions
// of its own (GUI design §1.2).
package ui

import (
	"context"
	"fmt"
	"os"
	"strings"

	"github.com/diamondburned/gotk4/pkg/gtk/v4"

	"krino/gui/internal/model"
	"krino/internal/engine"
	"krino/internal/xdg"
)

// Window is krino-gui's one window: three tabs, and a status bar.
type Window struct {
	app *gtk.Application
	win *gtk.ApplicationWindow

	engine *engine.Engine

	plan    *planView
	history *historyView
	status  *gtk.Label
}

// NewWindow builds the window for e. Each plan and each undo is its own
// run of krino, with its own run id in the log, so the window itself holds
// no session.
func NewWindow(app *gtk.Application, e *engine.Engine) *Window {
	w := &Window{app: app, engine: e}
	w.win = gtk.NewApplicationWindow(app)
	w.win.SetTitle("krino")
	w.win.SetDefaultSize(1000, 640)

	notebook := gtk.NewNotebook()
	w.plan = newPlanView(w)
	notebook.AppendPage(w.plan.root, gtk.NewLabel("Plan"))
	w.history = newHistoryView(w)
	notebook.AppendPage(w.history.root, gtk.NewLabel("History & undo"))
	notebook.AppendPage(placeholder("The rules editor arrives with a later milestone."), gtk.NewLabel("Rules"))
	// The log is read when the tab is first opened, not at start-up: a
	// window that only sorts never reads it.
	loaded := false
	notebook.ConnectSwitchPage(func(_ gtk.Widgetter, page uint) {
		if page == 1 && !loaded {
			loaded = true
			w.history.loadRuns()
		}
	})

	w.status = gtk.NewLabel("")
	w.status.SetXAlign(0)
	w.status.SetMarginStart(8)
	w.status.SetMarginEnd(8)
	w.status.SetMarginTop(4)
	w.status.SetMarginBottom(4)

	box := gtk.NewBox(gtk.OrientationVertical, 0)
	notebook.SetVExpand(true)
	box.Append(notebook)
	box.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
	box.Append(w.status)
	w.win.SetChild(box)
	// Closing the window releases whatever directory lock the open plan
	// holds, rather than leaving a lock file for the next run to find.
	w.win.ConnectCloseRequest(func() bool {
		w.plan.closeTab()
		w.history.closeTab()
		return false
	})
	return w
}

// Show puts the window on screen.
func (w *Window) Show() { w.win.Show() }

// dirRoot is the path of the configured directory called name, or "" if
// the configuration no longer has one - a log entry can outlive its
// directory.
func (w *Window) dirRoot(name string) string {
	for _, d := range w.engine.Dirs {
		if d.Name == name {
			return d.Root
		}
	}
	return ""
}

// setStatus writes the line at the bottom of the window.
func (w *Window) setStatus(format string, args ...any) {
	w.status.SetText(fmt.Sprintf(format, args...))
}

// placeholder is the page of a tab that is not built yet.
func placeholder(text string) gtk.Widgetter {
	l := gtk.NewLabel(text)
	l.SetVExpand(true)
	return l
}

// escape returns text as it is safe to show: control, bidirectional and
// separator characters are written as escapes, the way the terminal
// front end does it (spec §15.1), so a hostile file name cannot reorder or
// restyle the window. GTK labels are set as plain text, never as markup.
func escape(s string) string {
	var b strings.Builder
	for _, r := range s {
		switch {
		case r == '\t':
			b.WriteString("\\t")
		case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f,
			r == 0x061c, r >= 0x200e && r <= 0x200f, r >= 0x202a && r <= 0x202e,
			r >= 0x2066 && r <= 0x2069, r == 0x2028, r == 0x2029:
			fmt.Fprintf(&b, "\\u%04x", r)
		default:
			b.WriteRune(r)
		}
	}
	return b.String()
}

// runInBackground runs work off the main loop and hands its result back to
// the GTK thread with done. GTK may only be touched from the main loop.
func runInBackground(work func(context.Context) error, done func(error)) context.CancelFunc {
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		err := work(ctx)
		idleAdd(func() { done(err) })
	}()
	return cancel
}

// rowLabel is the middle cell of a row: what would happen to the file and
// where it would land, or - when nothing would - why not. Destinations
// inside root are shown relative to it, as the plan's own output does.
func rowLabel(r model.Row, root string) string {
	if len(r.Steps) == 0 {
		return strings.Join(r.Warnings, "; ")
	}
	var parts []string
	for _, s := range r.Steps {
		switch {
		case s.Skip != "":
			parts = append(parts, s.Kind.String()+" skipped: "+s.Skip)
		case s.Dst == "":
			parts = append(parts, s.Kind.String())
		default:
			parts = append(parts, s.Kind.String()+" "+shorten(s.Dst, root))
		}
	}
	return strings.Join(parts, ", ")
}

// shorten writes a destination inside root relative to it, and any other
// with ~ for the home directory.
func shorten(dst, root string) string {
	if root != "" && strings.HasPrefix(dst, root+string(os.PathSeparator)) {
		return dst[len(root)+1:]
	}
	return xdg.Abbrev(dst)
}