v0.1.0 47aaa0f

main.go RawHistoryFolder

1//go:build linux
2
3package main
4
5import (
6	"context"
7	_ "embed"
8	"encoding/json"
9	"errors"
10	"flag"
11	"fmt"
12	"math"
13	"os"
14	"os/signal"
15	"path/filepath"
16	"strconv"
17	"time"
18
19	"github.com/bnema/nefergui"
20)
21
22//go:embed app.css
23var stylesheet []byte
24
25type Model struct {
26	Page, Name, Status string
27	Notes              string
28	NewPending         bool
29	DarkMode           bool
30	Density            string
31	Volume             float64
32}
33
34func navButton(parent nefergui.Node, m *Model, label, page string) {
35	opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")}
36	if m.Page == page {
37		opts = append(opts, nefergui.Class("active"))
38	}
39	if parent.Button(label, opts...).Activated() {
40		m.Page = page
41	}
42}
43func home(parent nefergui.Node, m *Model) {
44	workspace := parent.Row(nefergui.Class("workspace"))
45	editor := workspace.Column(nefergui.Class("editor-pane"))
46	tab := editor.Row(nefergui.Class("pane-bar"))
47	tab.Text("Untitled.txt", nefergui.Class("document-title"))
48	tab.Spacer(nefergui.Class("stretch"))
49	tab.Text("Plain text", nefergui.Class("hint"))
50	if editor.Textarea("Document", &m.Notes, nefergui.Key("document"), nefergui.Class("document-editor"), nefergui.Placeholder("Start writing…")).Changed() {
51		m.NewPending = false
52		m.Status = "Unsaved changes · session only"
53	}
54	inspector := workspace.Aside(nefergui.Class("inspector"))
55	inspector.Text("PROPERTIES", nefergui.Class("eyebrow"))
56	inspector.Text("Author", nefergui.Class("field-label"))
57	inspector.Input("Author", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Your name"))
58	if inspector.Button("Apply", nefergui.Key("continue"), nefergui.Disabled(m.Name == "")).Activated() {
59		m.Status = "Author set to " + m.Name + " · session only"
60	}
61	inspector.Separator()
62	inspector.Text("Interface density", nefergui.Class("field-label"))
63	for _, d := range []struct{ label, value string }{{"Comfortable", "comfortable"}, {"Compact", "compact"}} {
64		inspector.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value))
65	}
66	inspector.Separator()
67	inspector.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume"))
68	inspector.Spacer(nefergui.Class("stretch"))
69	inspector.Text("Changes stay in this session.", nefergui.Class("hint"))
70}
71func settings(parent nefergui.Node, m *Model) {
72	bar := parent.Row(nefergui.Class("pane-bar"))
73	bar.Text("Preferences", nefergui.Class("document-title"))
74	pane := parent.Column(nefergui.Class("preferences-pane"))
75	pane.Text("Appearance", nefergui.Class("field-label"))
76	pane.Checkbox("Dark mode", &m.DarkMode, nefergui.Key("appearance"))
77	pane.Text("Rendering", nefergui.Class("field-label"))
78	pane.Text("Wayland · Vulkan · UTF-8", nefergui.Class("muted"))
79	pane.Text("Document properties are available in the editor's right pane.", nefergui.Class("hint"))
80}
81
82// requestNew arms confirmation without changing the document. A second request
83// discards it; editing or Cancel disarms the pending request.
84func (m *Model) requestNew() {
85	if m.Notes != "" && !m.NewPending {
86		m.NewPending = true
87		m.Status = "Click Discard & new to replace the current document"
88		return
89	}
90	m.Notes = ""
91	m.NewPending = false
92	m.Page = "home"
93	m.Status = "New document · session only"
94}
95
96func app(f *nefergui.Frame, m *Model) {
97	theme := "light"
98	if m.DarkMode {
99		theme = "dark"
100	}
101	root := f.Root(nefergui.Class("app"), nefergui.Class(theme), nefergui.Class(m.Density))
102	header := root.Header(nefergui.Class("header"))
103	header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo"))
104	newLabel := "New"
105	if m.NewPending {
106		newLabel = "Discard & new"
107	}
108	if header.Button(newLabel, nefergui.Key("new-document")).Activated() {
109		m.requestNew()
110	}
111	if m.NewPending && header.Button("Cancel", nefergui.Key("cancel-new")).Activated() {
112		m.NewPending = false
113		m.Status = "Ready"
114	}
115	header.Text("Untitled.txt", nefergui.Class("muted"))
116	header.Spacer(nefergui.Class("stretch"))
117	label := "Dark mode"
118	if m.DarkMode {
119		label = "Light mode"
120	}
121	if header.Button(label, nefergui.Key("dark-mode"), nefergui.Class("theme-toggle")).Activated() {
122		m.DarkMode = !m.DarkMode
123	}
124	body := root.Row(nefergui.Class("body"))
125	sidebar := body.Aside(nefergui.Class("sidebar"))
126	sidebar.Text("DOCUMENTS", nefergui.Class("eyebrow"))
127	menu := sidebar.Nav(nefergui.Class("menu"))
128	navButton(menu, m, "Untitled.txt", "home")
129	navButton(menu, m, "Preferences", "settings")
130	sidebar.Spacer(nefergui.Class("stretch"))
131	sidebar.Text("Local workspace", nefergui.Class("sidebar-note"))
132	sidebar.Text("1 document · in memory", nefergui.Class("hint"))
133	content := body.Main(nefergui.Class("content"))
134	switch m.Page {
135	case "home":
136		home(content, m)
137	case "settings":
138		settings(content, m)
139	}
140	footer := root.Footer(nefergui.Class("status-bar"))
141	footer.Text(m.Status)
142	footer.Spacer(nefergui.Class("stretch"))
143	footer.Text("UTF-8   •   Plain text", nefergui.Class("hint"))
144}
145func main() {
146	static := flag.Bool("static", false, "render a fixed number of frames and exit")
147	frames := flag.Int("frames", 120, "static frame count")
148	minDuration := flag.Duration("min-duration", 0, "static: keep presenting until at least this long after start (for scripted input)")
149	captureFrame := flag.Int("capture-frame", 0, "commit number for harness capture request")
150	flag.Parse()
151	if *static && (*frames < 2 || *captureFrame < 0 || *captureFrame >= *frames) {
152		fmt.Fprintln(os.Stderr, "invalid frame count or capture frame")
153		os.Exit(2)
154	}
155	if *captureFrame == 0 {
156		*captureFrame = *frames - 8
157		if *captureFrame < 1 {
158			*captureFrame = 1
159		}
160	}
161	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
162	defer stop()
163	if *static {
164		// Scripted runs must end even if the frame budget is never reached;
165		// the interactive demo runs until closed or interrupted.
166		var cancel context.CancelFunc
167		ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
168		defer cancel()
169	}
170	dir := os.Getenv("NEFERGUI_DEBUG_DIR")
171	var path string
172	if dir != "" {
173		path = filepath.Join(dir, "app.css")
174		if err := os.MkdirAll(dir, 0700); err != nil {
175			fail(err)
176		}
177		if err := os.WriteFile(path, stylesheet, 0600); err != nil {
178			fail(err)
179		}
180	} else {
181		file, err := os.CreateTemp("", "nefergui-demo-*.css")
182		if err != nil {
183			fail(err)
184		}
185		path = file.Name()
186		defer os.Remove(path)
187		if _, err = file.Write(stylesheet); err != nil {
188			file.Close()
189			fail(err)
190		}
191		if err = file.Close(); err != nil {
192			fail(err)
193		}
194	}
195	model := Model{Page: "home", Status: "Ready", Density: "comfortable", Volume: 40, Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session."}
196	start := time.Now()
197	var requested time.Time
198	commit := func(frame uint64) error {
199		if dir != "" {
200			data, err := json.Marshal(model)
201			if err != nil {
202				return err
203			}
204			if err = os.WriteFile(filepath.Join(dir, "demo-state.json"), data, 0600); err != nil {
205				return err
206			}
207		}
208		if *static && int(frame) == *captureFrame && dir != "" {
209			p := filepath.Join(dir, "capture-request")
210			tmp := p + ".tmp"
211			if err := os.WriteFile(tmp, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)+"\n"), 0600); err != nil {
212				return err
213			}
214			if err := os.Rename(tmp, p); err != nil {
215				return err
216			}
217			requested = time.Now()
218		}
219		if *static && int(frame) >= *frames && !requested.IsZero() {
220			time.Sleep(time.Until(requested.Add(250 * time.Millisecond)))
221		}
222		// Frame count alone depends on render speed: keep presenting (and
223		// handling input) until the minimum duration has also elapsed.
224		if *static && int(frame) >= *frames && time.Since(start) >= *minDuration {
225			return errDone
226		}
227		return nil
228	}
229	opts := []nefergui.WindowOption{nefergui.Title("NeferGUI — Untitled.txt"), nefergui.Size(960, 640), nefergui.Styles(path)}
230	var err error
231	if *static {
232		// The commit callback ends the run with errDone; the frame limit is a backstop.
233		err = nefergui.RunFrames(ctx, math.MaxInt32, &model, app, commit, opts...)
234	} else {
235		err = nefergui.Run(ctx, &model, app, opts...)
236	}
237	if err = unexpected(err); err != nil {
238		fail(err)
239	}
240}
241
242var errDone = errors.New("demo: static run complete")
243
244// unexpected drops the normal stop reasons (errDone, cancellation) from a
245// possibly joined error while keeping everything else, such as close errors.
246func unexpected(err error) error {
247	if joined, ok := err.(interface{ Unwrap() []error }); ok {
248		var rest []error
249		for _, e := range joined.Unwrap() {
250			if e = unexpected(e); e != nil {
251				rest = append(rest, e)
252			}
253		}
254		return errors.Join(rest...)
255	}
256	if errors.Is(err, errDone) || errors.Is(err, context.Canceled) {
257		return nil
258	}
259	return err
260}
261
262func fail(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close