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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
|
// 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
rules *rulesView
status *gtk.Label
prefs model.Prefs
}
// 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, prefs: model.LoadPrefs()}
w.win = gtk.NewApplicationWindow(app)
w.win.SetTitle("krino")
w.win.SetDefaultSize(1200, 720)
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"))
w.rules = newRulesView(w)
notebook.AppendPage(w.rules.root, 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()
}
})
// Settings sits at the end of the tab strip - the window's top right -
// with a gear beside the word (his request, 2026-09-17).
settings := gtk.NewButton()
settingsBox := gtk.NewBox(gtk.OrientationHorizontal, 6)
settingsBox.Append(gtk.NewImageFromIconName("emblem-system-symbolic"))
settingsBox.Append(gtk.NewLabel("Settings"))
settings.SetChild(settingsBox)
settings.SetHasFrame(false)
settings.SetTooltipText("krino's defaults, and how this window behaves")
settings.SetMarginEnd(6)
settings.ConnectClicked(func() { w.showSettings() })
notebook.SetActionWidget(settings, gtk.PackEnd)
w.status = gtk.NewLabel("")
w.status.SetXAlign(0)
w.status.SetMarginStart(8)
w.status.SetMarginEnd(8)
w.status.SetMarginTop(4)
w.status.SetMarginBottom(4)
statusRow := gtk.NewBox(gtk.OrientationHorizontal, 6)
w.status.SetHExpand(true)
statusRow.Append(w.status)
box := gtk.NewBox(gtk.OrientationVertical, 0)
notebook.SetVExpand(true)
box.Append(notebook)
box.Append(gtk.NewSeparator(gtk.OrientationHorizontal))
box.Append(statusRow)
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.plan.closePreview()
w.history.closeTab()
return false
})
w.applyPrefs(w.prefs)
return w
}
// Show puts the window on screen.
func (w *Window) Show() { w.win.Show() }
// reloadEngine re-reads the configuration, after the rules editor saves, so
// every tab works from the rules the user just wrote. An open plan came
// from the old ones, so it is closed and its lock released.
func (w *Window) reloadEngine() error {
e, diags := engine.Load(w.engine.MainFile)
if len(diags) > 0 {
return diags[0]
}
e.CacheDir = w.engine.CacheDir
w.plan.closeTab()
w.history.closeTab()
w.engine = e
return nil
}
// applyPrefs takes a change from the settings window: the font of the
// editor, whether the configuration is coloured, and whether the file
// behind a row is shown. What is already on screen changes at once.
func (w *Window) applyPrefs(p model.Prefs) {
w.prefs = p
w.rules.applyPrefs(p)
w.plan.applyPrefs(p)
}
// 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()
}
// escapeText is escape for text shown in a pane rather than on one line:
// line breaks are the layout, so they are kept, and every other control or
// bidirectional character is still written as an escape.
func escapeText(s string) string {
var b strings.Builder
for _, line := range strings.Split(s, "\n") {
b.WriteString(escape(line))
b.WriteString("\n")
}
return strings.TrimSuffix(b.String(), "\n")
}
// 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)
}
|