aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/ui/window.go
blob: 5132c12976dfbc2a3184427003740bbd330a0fef (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
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
// 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/glib/v2"
	"github.com/diamondburned/gotk4/pkg/gtk/v4"

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

// Version is what About shows. main stamps it at build time; "dev" is
// what a plain `go build` leaves.
var Version = "dev"

// 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
	leaving   bool
	saveTimer glib.SourceHandle
}

// 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 {
		// A plan is only a plan until Apply: leaving with one open throws
		// it away, which is worth saying out loud (his report, 2026-09-17).
		if w.plan.hasUnapplied() && !w.leaving {
			w.confirmLeaving()
			return true
		}
		w.plan.closeTab()
		w.plan.closePreview()
		w.history.closeTab()
		return false
	})
	themeColours(w.win)
	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
}

// confirmLeaving asks before a window with an unapplied plan closes.
func (w *Window) confirmLeaving() {
	n := w.plan.tab.SelectedCount()
	d := gtk.NewMessageDialog(&w.win.Window, gtk.DialogModal|gtk.DialogDestroyWithParent,
		gtk.MessageQuestion, gtk.ButtonsNone)
	d.SetObjectProperty("text", "Close without applying?")
	d.SetObjectProperty("secondary-text", fmt.Sprintf(
		"%d file(s) are checked but nothing has been moved: a plan lives in this window until Apply, and closing throws it away.", n))
	d.AddButton("Stay", int(gtk.ResponseCancel))
	d.AddButton("Close without applying", int(gtk.ResponseAccept))
	d.ConnectResponse(func(response int) {
		d.Destroy()
		if response == int(gtk.ResponseAccept) {
			w.leaving = true
			w.win.Close()
		}
	})
	d.Show()
}

// addDirectory asks for a name and a path and makes krino sort that
// directory too - the window had no way to do it (his report, 2026-09-17).
func (w *Window) addDirectory() {
	d := gtk.NewWindow()
	d.SetTitle("Add a directory")
	d.SetTransientFor(&w.win.Window)
	d.SetModal(true)
	d.SetDefaultSize(520, 200)

	name := gtk.NewEntry()
	name.SetPlaceholderText("papers")
	name.SetTooltipText("the name krino knows it by: letters, digits, '.', '_' and '-'; its rules go in dirs/NAME.conf")
	path := gtk.NewEntry()
	path.SetPlaceholderText("~/papers")
	path.SetHExpand(true)
	path.SetTooltipText("the directory to sort")

	note := gtk.NewLabel("")
	note.SetXAlign(0)
	note.SetWrap(true)

	box := gtk.NewBox(gtk.OrientationVertical, 8)
	box.SetMarginStart(12)
	box.SetMarginEnd(12)
	box.SetMarginTop(12)
	box.SetMarginBottom(12)
	box.Append(field("name", name))
	box.Append(field("path", path))
	box.Append(note)

	create := gtk.NewButtonWithLabel("Add")
	create.AddCSSClass("suggested-action")
	cancel := gtk.NewButtonWithLabel("Cancel")
	cancel.ConnectClicked(func() { d.Close() })
	buttons := gtk.NewBox(gtk.OrientationHorizontal, 6)
	buttons.SetHAlign(gtk.AlignEnd)
	buttons.Append(cancel)
	buttons.Append(create)
	box.Append(buttons)

	create.ConnectClicked(func() {
		file, err := model.AddDirectory(w.engine, strings.TrimSpace(name.Text()), strings.TrimSpace(path.Text()))
		if err != nil {
			note.SetText(escape(err.Error()))
			note.AddCSSClass("error")
			return
		}
		added := strings.TrimSpace(name.Text())
		if err := w.reloadEngine(); err != nil {
			note.SetText(escape(err.Error()))
			note.AddCSSClass("error")
			return
		}
		w.plan.refreshDirs(w.plan.currentName())
		w.rules.refreshDirs(added)
		w.setStatus("added %s; its rules are in %s", escape(added), escape(xdg.Abbrev(file)))
		d.Close()
	})
	d.SetChild(box)
	d.Show()
}

// savePrefsSoon writes the preferences a moment after the last change, so
// dragging a divider does not write the file on every pixel.
func (w *Window) savePrefsSoon() {
	if w.saveTimer != 0 {
		glib.SourceRemove(w.saveTimer)
	}
	w.saveTimer = glib.TimeoutAdd(500, func() bool {
		w.saveTimer = 0
		if err := w.prefs.Save(); err != nil {
			w.setStatus("settings: %v", err)
		}
		return false
	})
}

// 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
}

// actionColours are what each action is painted in, so the eye finds the
// deletions without reading: they are the ones that cannot be undone from
// the window. They are the fallbacks; themeColours replaces them with the
// running theme's own, so the window looks like the rest of the desktop
// rather than like GNOME's palette (his request, 2026-09-17).
var actionColours = map[plan.Kind]string{
	plan.Copy:            "#2a9d8f",
	plan.Move:            "#3584e4",
	plan.Rename:          "#9141ac",
	plan.Trash:           "#c06014",
	plan.DeletePermanent: "#c01c28",
}

// actionClasses name the CSS class each action's cell carries. The colour
// is applied by a style sheet rather than by painting the text, so that a
// selected row - which draws its own background - can take the colour back
// and stay readable: his green accent on his green selection was not (his
// report, 2026-09-17).
var actionClasses = map[plan.Kind]string{
	plan.Copy:            "krino-copy",
	plan.Move:            "krino-move",
	plan.Rename:          "krino-rename",
	plan.Trash:           "krino-trash",
	plan.DeletePermanent: "krino-delete",
}

// themeColours takes what it can from the GTK theme - the accent for a
// move, the selection blue for a rename, the theme's own success, warning
// and error for the rest - and installs the style sheet that paints the
// action cells. A theme that names none of them leaves the fallbacks above.
func themeColours(w gtk.Widgetter) {
	widget := gtk.BaseWidget(w)
	ctx := widget.StyleContext()
	pick := func(names ...string) string {
		for _, name := range names {
			if rgba, ok := ctx.LookupColor(name); ok {
				return fmt.Sprintf("#%02x%02x%02x",
					int(rgba.Red()*255), int(rgba.Green()*255), int(rgba.Blue()*255))
			}
		}
		return ""
	}
	set := func(kind plan.Kind, colour string) {
		if colour != "" {
			actionColours[kind] = colour
		}
	}
	set(plan.Move, pick("accent_color", "theme_selected_bg_color", "accent_bg_color"))
	set(plan.Copy, pick("success_color", "success_bg_color"))
	set(plan.Rename, pick("theme_selected_bg_color", "accent_bg_color"))
	set(plan.Trash, pick("warning_color", "warning_bg_color"))
	set(plan.DeletePermanent, pick("error_color", "destructive_color", "error_bg_color"))

	var css strings.Builder
	css.WriteString(".krino-action { font-weight: bold; }\n")
	for kind, class := range actionClasses {
		fmt.Fprintf(&css, ".%s { color: %s; }\n", class, actionColours[kind])
	}
	// A selected row paints its own background; the action takes that row's
	// foreground so it never sits on a colour of its own.
	fmt.Fprintf(&css, ".krino-warn { color: %s; }\n", actionColours[plan.Trash])
	css.WriteString(".krino-title { font-weight: bold; font-size: 115%; }\n")
	css.WriteString("row:selected .krino-action { color: @theme_selected_fg_color; }\n")
	css.WriteString("row:selected .krino-action { color: @accent_fg_color; }\n")
	provider := gtk.NewCSSProvider()
	provider.LoadFromData(css.String())
	if display := widget.Display(); display != nil {
		gtk.StyleContextAddProviderForDisplay(display, provider, 700)
	}
}

// actionRank decides which action gives a row its colour when a file gets
// several: the one that matters most to the reader.
var actionRank = map[plan.Kind]int{
	plan.Rename: 1, plan.Copy: 2, plan.Move: 3, plan.Trash: 4, plan.DeletePermanent: 5,
}

// rowAction is a row's actions in capitals - "MOVE", "RENAME+MOVE" - and
// the CSS class they are painted with. A row that would do nothing has
// neither.
func rowAction(r model.Row) (text, class string) {
	if len(r.Steps) == 0 {
		return "", ""
	}
	var parts []string
	var worst plan.Kind
	rank := -1
	skipped := 0
	for _, s := range r.Steps {
		if s.Skip != "" {
			skipped++
			continue
		}
		parts = append(parts, strings.ToUpper(s.Kind.String()))
		if actionRank[s.Kind] > rank {
			rank, worst = actionRank[s.Kind], s.Kind
		}
	}
	if len(parts) == 0 {
		return "SKIPPED", ""
	}
	return strings.Join(parts, "+"), actionClasses[worst]
}

// rowWhere is where a row's file would end up - the last place its steps
// put it - or, when nothing would happen, why not. Destinations inside root
// are shown relative to it, as the plan's own output does.
func rowWhere(r model.Row, root string) string {
	if len(r.Steps) == 0 {
		return strings.Join(r.Warnings, "; ")
	}
	where := ""
	var notes []string
	for _, s := range r.Steps {
		switch {
		case s.Skip != "":
			notes = append(notes, strings.ToLower(s.Kind.String())+" skipped: "+s.Skip)
		case s.Kind == plan.Trash:
			where = "the Trash"
		case s.Kind == plan.DeletePermanent:
			where = "gone for good"
		case s.Dst != "":
			where = shorten(s.Dst, root)
		}
	}
	if where == "" {
		return strings.Join(notes, "; ")
	}
	if len(notes) > 0 {
		return where + "  (" + strings.Join(notes, "; ") + ")"
	}
	return where
}

// dimColour is for text that is not an action: a skip, a warning.
const dimColour = "#8b8b8b"

// 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)
}