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