detached a510003

feat(demo): showcase a native editor workspace with light and dark themes

commit
a510003bfd42d94746d82da8d37f09e268ecde80
author
brice · · 2026-09-30 05:29 UTC
parent
c77cf3a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +370 -88
M examples/demo/app.css +71 -18
@@ -1,19 +1,72 @@
1-app.app { background-color: #f5f7fa; color: #18253b; padding: 20px; }
2-header.header { background-color: #dae5f5; padding: 14px; height: 130px; }
3-heading.logo { color: #203d7a; font-size: 32px; }
4-nav.menu { display: flex; flex-direction: row; column-gap: 8px; padding: 8px; }
5-button.menu-item { background-color: #aabbd8; padding: 6px 12px; }
6-button.menu-item:hover { background-color: #bccae2; }
7-button.menu-item.active { background-color: #8fa6cf; }
8-main.content { padding: 24px; }
9-section.welcome { background-color: #ffffff; padding: 20px; }
10-heading { color: #203d7a; font-size: 28px; }
11-input { background-color: #f1f5fc; border: 2px solid #5675a4; padding: 8px; width: 300px; height: 42px; }
12-row.actions { padding: 12px; }
13-button.primary { background-color: #2357bb; color: #ffffff; padding: 13px 10px; width: 160px; }
14-button.primary:hover { background-color: #1b4699; }
15-button.primary:disabled { background-color: #7594c9; }
16-input:hover { border-color: #203d7a; }
1+/* Neutral desktop surfaces; tokens keep both themes on the same hierarchy. */
2+app.app {
3+ --canvas: #fafafa; --surface: #ffffff; --sidebar: #f4f4f5;
4+ --ink: #18181b; --secondary: #52525b; --quiet: #71717a;
5+ --line: #e4e4e7; --hover: #ededf0; --selected: #e4e4e7;
6+ --control: #fafafa; --action: #27272a; --action-ink: #ffffff;
7+ --disabled: #d4d4d8; --disabled-ink: #52525b;
8+ display: flex; flex-direction: column; width: 100%; height: 100%;
9+ box-sizing: border-box;
10+ background: var(--canvas); color: var(--ink); font-size: 14px; line-height: 1.4;
11+ accent-color: #52525b;
12+}
13+app.app.dark {
14+ --canvas: #18181b; --surface: #202023; --sidebar: #1c1c1f;
15+ --ink: #f4f4f5; --secondary: #d4d4d8; --quiet: #a1a1aa;
16+ --line: #36363c; --hover: #303035; --selected: #36363c;
17+ --control: #18181b; --action: #e4e4e7; --action-ink: #18181b;
18+ --disabled: #36363c; --disabled-ink: #a1a1aa;
19+ accent-color: #a1a1aa;
20+}
21+app, header, footer, aside, main, section, row, nav, button, input { box-sizing: border-box; }
22+header.header {
23+ display: flex; flex-direction: row; align-items: center; column-gap: 20px;
24+ height: 48px; flex-shrink: 0; padding: 6px 12px;
25+ background: var(--surface); border-bottom: 1px solid var(--line);
26+}
27+heading { font-size: 24px; font-weight: 600; margin: 0; color: var(--ink); }
28+heading.logo { font-size: 18px; font-weight: 700; }
29+section heading { font-size: 16px; }
30+text.muted { color: var(--secondary); }
31+text.hint { color: var(--quiet); font-size: 12px; }
32+text.eyebrow { color: var(--quiet); font-size: 11px; font-weight: 600; letter-spacing: 1px; }
33+spacer.stretch { flex-grow: 1; min-height: 0; min-width: 0; }
34+row.body { flex-grow: 1; min-height: 0; height: 100%; align-items: stretch; }
35+aside.sidebar {
36+ display: flex; flex-direction: column; width: 200px; flex-shrink: 0;
37+ padding: 24px 12px 20px; background: var(--sidebar); border-right: 1px solid var(--line);
38+}
39+aside.sidebar > text { margin-left: 12px; }
40+nav.menu { display: flex; flex-direction: column; row-gap: 4px; margin-top: 16px; flex-shrink: 0; height: 84px; }
41+button { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; }
42+button:hover { background: var(--hover); }
43+button:active { background: var(--selected); }
44+button:focus-visible, input:focus-visible, radio:focus-visible, slider:focus-visible {
45+ outline: 2px solid var(--quiet); outline-offset: 2px;
46+}
47+button.menu-item { height: 40px; flex-shrink: 0; width: 176px; text-align: left; border: 1px solid transparent; padding: 10px 12px; background: transparent; }
48+button.menu-item:hover { background: var(--hover); }
49+button.menu-item.active { background: var(--selected); font-weight: 600; }
50+button.theme-toggle { width: 104px; font-size: 13px; }
51+main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }
52+row.workspace { height: 100%; align-items: stretch; }
53+column.editor-pane { flex-grow: 1; min-width: 0; background: var(--surface); }
54+row.pane-bar { height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
55+text.document-title { font-size: 13px; font-weight: 600; }
56+textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }
57+aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }
58+aside.inspector input, aside.inspector slider { width: 208px; }
59+aside.inspector button { margin-top: 8px; height: 32px; }
60+text.field-label { margin-top: 16px; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
61+separator { background: var(--line); margin-top: 16px; margin-bottom: 4px; flex-shrink: 0; }
62+button:disabled { background: var(--disabled); color: var(--disabled-ink); }
63+column.preferences-pane { padding: 16px 24px; row-gap: 8px; }
64+input { background: var(--control); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; width: 320px; height: 40px; }
65+input:hover { border-color: var(--quiet); }
66+text.sidebar-note { color: var(--secondary); font-size: 12px; margin-bottom: 4px; }
1767 radio { padding-top: 6px; padding-bottom: 6px; }
18-slider { width: 300px; margin-top: 12px; }
19-footer.status-bar { background-color: #dae5f5; padding: 12px; height: 42px; }
68+slider { width: 320px; margin-top: 8px; }
69+footer.status-bar { display: flex; flex-direction: row; align-items: center; height: 28px; flex-shrink: 0; padding: 4px 12px; background: var(--surface); border-top: 1px solid var(--line); color: var(--secondary); font-size: 12px; }
70+app.app.compact aside.inspector { padding-top: 8px; }
71+app.app.compact textarea.document-editor { padding: 12px; }
72+app.app.compact button.menu-item { padding-top: 6px; padding-bottom: 6px; }
@@ -1,19 +1,72 @@
1-app.app { background-color: #f5f7fa; color: #18253b; padding: 20px; }1+/* Neutral desktop surfaces; tokens keep both themes on the same hierarchy. */
2-header.header { background-color: #dae5f5; padding: 14px; height: 130px; }2+app.app {
3-heading.logo { color: #203d7a; font-size: 32px; }3+ --canvas: #fafafa; --surface: #ffffff; --sidebar: #f4f4f5;
4-nav.menu { display: flex; flex-direction: row; column-gap: 8px; padding: 8px; }4+ --ink: #18181b; --secondary: #52525b; --quiet: #71717a;
5-button.menu-item { background-color: #aabbd8; padding: 6px 12px; }5+ --line: #e4e4e7; --hover: #ededf0; --selected: #e4e4e7;
6-button.menu-item:hover { background-color: #bccae2; }6+ --control: #fafafa; --action: #27272a; --action-ink: #ffffff;
7-button.menu-item.active { background-color: #8fa6cf; }7+ --disabled: #d4d4d8; --disabled-ink: #52525b;
8-main.content { padding: 24px; }8+ display: flex; flex-direction: column; width: 100%; height: 100%;
9-section.welcome { background-color: #ffffff; padding: 20px; }9+ box-sizing: border-box;
10-heading { color: #203d7a; font-size: 28px; }10+ background: var(--canvas); color: var(--ink); font-size: 14px; line-height: 1.4;
11-input { background-color: #f1f5fc; border: 2px solid #5675a4; padding: 8px; width: 300px; height: 42px; }11+ accent-color: #52525b;
12-row.actions { padding: 12px; }12+}
13-button.primary { background-color: #2357bb; color: #ffffff; padding: 13px 10px; width: 160px; }13+app.app.dark {
14-button.primary:hover { background-color: #1b4699; }14+ --canvas: #18181b; --surface: #202023; --sidebar: #1c1c1f;
15-button.primary:disabled { background-color: #7594c9; }15+ --ink: #f4f4f5; --secondary: #d4d4d8; --quiet: #a1a1aa;
16-input:hover { border-color: #203d7a; }16+ --line: #36363c; --hover: #303035; --selected: #36363c;
17+ --control: #18181b; --action: #e4e4e7; --action-ink: #18181b;
18+ --disabled: #36363c; --disabled-ink: #a1a1aa;
19+ accent-color: #a1a1aa;
20+}
21+app, header, footer, aside, main, section, row, nav, button, input { box-sizing: border-box; }
22+header.header {
23+ display: flex; flex-direction: row; align-items: center; column-gap: 20px;
24+ height: 48px; flex-shrink: 0; padding: 6px 12px;
25+ background: var(--surface); border-bottom: 1px solid var(--line);
26+}
27+heading { font-size: 24px; font-weight: 600; margin: 0; color: var(--ink); }
28+heading.logo { font-size: 18px; font-weight: 700; }
29+section heading { font-size: 16px; }
30+text.muted { color: var(--secondary); }
31+text.hint { color: var(--quiet); font-size: 12px; }
32+text.eyebrow { color: var(--quiet); font-size: 11px; font-weight: 600; letter-spacing: 1px; }
33+spacer.stretch { flex-grow: 1; min-height: 0; min-width: 0; }
34+row.body { flex-grow: 1; min-height: 0; height: 100%; align-items: stretch; }
35+aside.sidebar {
36+ display: flex; flex-direction: column; width: 200px; flex-shrink: 0;
37+ padding: 24px 12px 20px; background: var(--sidebar); border-right: 1px solid var(--line);
38+}
39+aside.sidebar > text { margin-left: 12px; }
40+nav.menu { display: flex; flex-direction: column; row-gap: 4px; margin-top: 16px; flex-shrink: 0; height: 84px; }
41+button { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; }
42+button:hover { background: var(--hover); }
43+button:active { background: var(--selected); }
44+button:focus-visible, input:focus-visible, radio:focus-visible, slider:focus-visible {
45+ outline: 2px solid var(--quiet); outline-offset: 2px;
46+}
47+button.menu-item { height: 40px; flex-shrink: 0; width: 176px; text-align: left; border: 1px solid transparent; padding: 10px 12px; background: transparent; }
48+button.menu-item:hover { background: var(--hover); }
49+button.menu-item.active { background: var(--selected); font-weight: 600; }
50+button.theme-toggle { width: 104px; font-size: 13px; }
51+main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }
52+row.workspace { height: 100%; align-items: stretch; }
53+column.editor-pane { flex-grow: 1; min-width: 0; background: var(--surface); }
54+row.pane-bar { height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
55+text.document-title { font-size: 13px; font-weight: 600; }
56+textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }
57+aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }
58+aside.inspector input, aside.inspector slider { width: 208px; }
59+aside.inspector button { margin-top: 8px; height: 32px; }
60+text.field-label { margin-top: 16px; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
61+separator { background: var(--line); margin-top: 16px; margin-bottom: 4px; flex-shrink: 0; }
62+button:disabled { background: var(--disabled); color: var(--disabled-ink); }
63+column.preferences-pane { padding: 16px 24px; row-gap: 8px; }
64+input { background: var(--control); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; width: 320px; height: 40px; }
65+input:hover { border-color: var(--quiet); }
66+text.sidebar-note { color: var(--secondary); font-size: 12px; margin-bottom: 4px; }
17 radio { padding-top: 6px; padding-bottom: 6px; }67 radio { padding-top: 6px; padding-bottom: 6px; }
18-slider { width: 300px; margin-top: 12px; }68+slider { width: 320px; margin-top: 8px; }
19-footer.status-bar { background-color: #dae5f5; padding: 12px; height: 42px; }69+footer.status-bar { display: flex; flex-direction: row; align-items: center; height: 28px; flex-shrink: 0; padding: 4px 12px; background: var(--surface); border-top: 1px solid var(--line); color: var(--secondary); font-size: 12px; }
70+app.app.compact aside.inspector { padding-top: 8px; }
71+app.app.compact textarea.document-editor { padding: 12px; }
72+app.app.compact button.menu-item { padding-top: 6px; padding-bottom: 6px; }
M examples/demo/main.go +92 -27
@@ -24,6 +24,8 @@ var stylesheet []byte
2424
2525 type Model struct {
2626 Page, Name, Status string
27+ Notes string
28+ NewPending bool
2729 DarkMode bool
2830 Density string
2931 Volume float64
@@ -39,39 +41,106 @@ func navButton(parent nefergui.Node, m *Model, label, page string) {
3941 }
4042 }
4143 func home(parent nefergui.Node, m *Model) {
42- section := parent.Section(nefergui.Class("welcome"))
43- section.Heading("Bienvenue", nefergui.Level(2))
44- section.Text("Une interface native écrite entièrement en Go.")
45- section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada"))
46- actions := section.Row(nefergui.Class("actions"))
47- if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() {
48- m.Status = "Bonjour " + m.Name
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"
4953 }
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"))
5070 }
5171 func settings(parent nefergui.Node, m *Model) {
52- section := parent.Section(nefergui.Class("welcome"))
53- section.Heading("Paramètres", nefergui.Level(2))
54- for _, d := range []struct{ label, value string }{{"Confortable", "comfortable"}, {"Compacte", "compact"}} {
55- section.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value))
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.
84+func (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
5689 }
57- section.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume"))
90+ m.Notes = ""
91+ m.NewPending = false
92+ m.Page = "home"
93+ m.Status = "New document · session only"
5894 }
95+
5996 func app(f *nefergui.Frame, m *Model) {
60- root := f.Root(nefergui.Class("app"))
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))
61102 header := root.Header(nefergui.Class("header"))
62- header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo"))
63- menu := header.Nav(nefergui.Class("menu"))
64- navButton(menu, m, "Accueil", "home")
65- navButton(menu, m, "Paramètres", "settings")
66- header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode"))
67- content := root.Main(nefergui.Class("content"))
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"))
68134 switch m.Page {
69135 case "home":
70136 home(content, m)
71137 case "settings":
72138 settings(content, m)
73139 }
74- root.Footer(nefergui.Class("status-bar")).Text(m.Status)
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"))
75144 }
76145 func main() {
77146 static := flag.Bool("static", false, "render a fixed number of frames and exit")
@@ -123,16 +192,12 @@ func main() {
123192 fail(err)
124193 }
125194 }
126- model := Model{Page: "home", Status: "Prêt", Density: "comfortable", Volume: 40}
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."}
127196 start := time.Now()
128197 var requested time.Time
129198 commit := func(frame uint64) error {
130199 if dir != "" {
131- data, err := json.Marshal(struct {
132- Page, Name, Status, Density string
133- DarkMode bool
134- Volume float64
135- }{model.Page, model.Name, model.Status, model.Density, model.DarkMode, model.Volume})
200+ data, err := json.Marshal(model)
136201 if err != nil {
137202 return err
138203 }
@@ -161,7 +226,7 @@ func main() {
161226 }
162227 return nil
163228 }
164- opts := []nefergui.WindowOption{nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles(path)}
229+ opts := []nefergui.WindowOption{nefergui.Title("NeferGUI — Untitled.txt"), nefergui.Size(960, 640), nefergui.Styles(path)}
165230 var err error
166231 if *static {
167232 // The commit callback ends the run with errDone; the frame limit is a backstop.
@@ -24,6 +24,8 @@ var stylesheet []byte
24 24
25 type Model struct {25 type Model struct {
26 Page, Name, Status string26 Page, Name, Status string
27+ Notes string
28+ NewPending bool
27 DarkMode bool29 DarkMode bool
28 Density string30 Density string
29 Volume float6431 Volume float64
@@ -39,39 +41,106 @@ func navButton(parent nefergui.Node, m *Model, label, page string) {
39 }41 }
40 }42 }
41 func home(parent nefergui.Node, m *Model) {43 func home(parent nefergui.Node, m *Model) {
42- section := parent.Section(nefergui.Class("welcome"))44+ workspace := parent.Row(nefergui.Class("workspace"))
43- section.Heading("Bienvenue", nefergui.Level(2))45+ editor := workspace.Column(nefergui.Class("editor-pane"))
44- section.Text("Une interface native écrite entièrement en Go.")46+ tab := editor.Row(nefergui.Class("pane-bar"))
45- section.Input("Votre nom", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Ada"))47+ tab.Text("Untitled.txt", nefergui.Class("document-title"))
46- actions := section.Row(nefergui.Class("actions"))48+ tab.Spacer(nefergui.Class("stretch"))
47- if actions.Button("Continuer", nefergui.Key("continue"), nefergui.Class("primary"), nefergui.Disabled(m.Name == "")).Activated() {49+ tab.Text("Plain text", nefergui.Class("hint"))
48- m.Status = "Bonjour " + m.Name50+ 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"
49 }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"))
50 }70 }
51 func settings(parent nefergui.Node, m *Model) {71 func settings(parent nefergui.Node, m *Model) {
52- section := parent.Section(nefergui.Class("welcome"))72+ bar := parent.Row(nefergui.Class("pane-bar"))
53- section.Heading("Paramètres", nefergui.Level(2))73+ bar.Text("Preferences", nefergui.Class("document-title"))
54- for _, d := range []struct{ label, value string }{{"Confortable", "comfortable"}, {"Compacte", "compact"}} {74+ pane := parent.Column(nefergui.Class("preferences-pane"))
55- section.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value))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.
84+func (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
56 }89 }
57- section.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume"))90+ m.Notes = ""
91+ m.NewPending = false
92+ m.Page = "home"
93+ m.Status = "New document · session only"
58 }94 }
95+
59 func app(f *nefergui.Frame, m *Model) {96 func app(f *nefergui.Frame, m *Model) {
60- root := f.Root(nefergui.Class("app"))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))
61 header := root.Header(nefergui.Class("header"))102 header := root.Header(nefergui.Class("header"))
62- header.Heading("Demo", nefergui.Level(1), nefergui.Class("logo"))103+ header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo"))
63- menu := header.Nav(nefergui.Class("menu"))104+ newLabel := "New"
64- navButton(menu, m, "Accueil", "home")105+ if m.NewPending {
65- navButton(menu, m, "Paramètres", "settings")106+ newLabel = "Discard & new"
66- header.Checkbox("Thème sombre", &m.DarkMode, nefergui.Key("dark-mode"))107+ }
67- content := root.Main(nefergui.Class("content"))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"))
68 switch m.Page {134 switch m.Page {
69 case "home":135 case "home":
70 home(content, m)136 home(content, m)
71 case "settings":137 case "settings":
72 settings(content, m)138 settings(content, m)
73 }139 }
74- root.Footer(nefergui.Class("status-bar")).Text(m.Status)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"))
75 }144 }
76 func main() {145 func main() {
77 static := flag.Bool("static", false, "render a fixed number of frames and exit")146 static := flag.Bool("static", false, "render a fixed number of frames and exit")
@@ -123,16 +192,12 @@ func main() {
123 fail(err)192 fail(err)
124 }193 }
125 }194 }
126- model := Model{Page: "home", Status: "Prêt", Density: "comfortable", Volume: 40}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."}
127 start := time.Now()196 start := time.Now()
128 var requested time.Time197 var requested time.Time
129 commit := func(frame uint64) error {198 commit := func(frame uint64) error {
130 if dir != "" {199 if dir != "" {
131- data, err := json.Marshal(struct {200+ data, err := json.Marshal(model)
132- Page, Name, Status, Density string
133- DarkMode bool
134- Volume float64
135- }{model.Page, model.Name, model.Status, model.Density, model.DarkMode, model.Volume})
136 if err != nil {201 if err != nil {
137 return err202 return err
138 }203 }
@@ -161,7 +226,7 @@ func main() {
161 }226 }
162 return nil227 return nil
163 }228 }
164- opts := []nefergui.WindowOption{nefergui.Title("Demo"), nefergui.Size(960, 640), nefergui.Styles(path)}229+ opts := []nefergui.WindowOption{nefergui.Title("NeferGUI — Untitled.txt"), nefergui.Size(960, 640), nefergui.Styles(path)}
165 var err error230 var err error
166 if *static {231 if *static {
167 // The commit callback ends the run with errDone; the frame limit is a backstop.232 // The commit callback ends the run with errDone; the frame limit is a backstop.
M examples/demo/main_test.go +17 -0
@@ -7,6 +7,23 @@ import (
77 "testing"
88 )
99
10+func TestNewDocumentRequiresConfirmation(t *testing.T) {
11+ m := Model{Notes: "Keep this text", Page: "home"}
12+ m.requestNew()
13+ if m.Notes != "Keep this text" || !m.NewPending {
14+ t.Fatalf("first request discarded text: %+v", m)
15+ }
16+ m.NewPending = false // Cancel
17+ m.requestNew()
18+ if m.Notes != "Keep this text" || !m.NewPending {
19+ t.Fatalf("cancel failed to reset confirmation: %+v", m)
20+ }
21+ m.requestNew()
22+ if m.Notes != "" || m.NewPending {
23+ t.Fatalf("confirmation did not clear document: %+v", m)
24+ }
25+}
26+
1027 func TestUnexpectedKeepsCloseErrors(t *testing.T) {
1128 closeErr := errors.New("close failed")
1229 cases := []struct {
@@ -7,6 +7,23 @@ import (
7 "testing"7 "testing"
8 )8 )
9 9
10+func TestNewDocumentRequiresConfirmation(t *testing.T) {
11+ m := Model{Notes: "Keep this text", Page: "home"}
12+ m.requestNew()
13+ if m.Notes != "Keep this text" || !m.NewPending {
14+ t.Fatalf("first request discarded text: %+v", m)
15+ }
16+ m.NewPending = false // Cancel
17+ m.requestNew()
18+ if m.Notes != "Keep this text" || !m.NewPending {
19+ t.Fatalf("cancel failed to reset confirmation: %+v", m)
20+ }
21+ m.requestNew()
22+ if m.Notes != "" || m.NewPending {
23+ t.Fatalf("confirmation did not clear document: %+v", m)
24+ }
25+}
26+
10 func TestUnexpectedKeepsCloseErrors(t *testing.T) {27 func TestUnexpectedKeepsCloseErrors(t *testing.T) {
11 closeErr := errors.New("close failed")28 closeErr := errors.New("close failed")
12 cases := []struct {29 cases := []struct {
M examples/rect/main.go +4 -0
@@ -228,6 +228,10 @@ func styleCommands() []layout.Command {
228228 {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1},
229229 {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1},
230230 {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
231+ // Zero-width sides retain deliberately contrasting colors to catch
232+ // accidental corner contributions from unpainted edges.
233+ {Op: "border", Rect: layout.Rect{X: 300, Y: 40, W: 80, H: 50}, Widths: layout.Edges{Top: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{G: 1, A: 1}, Right: css.Color{G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
234+ {Op: "border", Rect: layout.Rect{X: 300, Y: 240, W: 80, H: 50}, Widths: layout.Edges{Top: 8, Left: 2}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{B: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
231235 // Layout has already expanded this rect by the 4px outline offset.
232236 {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1},
233237 }
@@ -228,6 +228,10 @@ func styleCommands() []layout.Command {
228 {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1},228 {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1},
229 {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1},229 {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1},
230 {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},230 {Op: "border", Rect: box, Radii: radius, Widths: layout.Edges{Top: 8, Right: 8, Bottom: 8, Left: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Right: css.Color{G: 1, A: 1}, Bottom: css.Color{B: 1, A: 1}, Left: css.Color{R: 1, G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
231+ // Zero-width sides retain deliberately contrasting colors to catch
232+ // accidental corner contributions from unpainted edges.
233+ {Op: "border", Rect: layout.Rect{X: 300, Y: 40, W: 80, H: 50}, Widths: layout.Edges{Top: 8}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{G: 1, A: 1}, Right: css.Color{G: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
234+ {Op: "border", Rect: layout.Rect{X: 300, Y: 240, W: 80, H: 50}, Widths: layout.Edges{Top: 8, Left: 2}, Colors: css.ColorSides{Top: css.Color{R: 1, A: 1}, Left: css.Color{B: 1, A: 1}}, Color: css.Color{A: 1}, Opacity: 1},
231 // Layout has already expanded this rect by the 4px outline offset.235 // Layout has already expanded this rect by the 4px outline offset.
232 {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1},236 {Op: "outline", Rect: layout.Rect{X: 116, Y: 116, W: 168, H: 128}, Radii: radius, Widths: layout.Edges{Top: 3, Right: 3, Bottom: 3, Left: 3}, Color: css.Color{R: 1, G: 0, B: 1, A: 1}, Opacity: 1},
233 }237 }
M internal/presentation/shaders/list.frag +11 -7
@@ -80,13 +80,17 @@ void main() {
8080 float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0;
8181 coverage *= 1.0 - hole;
8282 if (fragKind < 2.5) {
83- // Diagonal split: classify by the nearest normalized edge of the
84- // outer box. Ties go to top/bottom (stable at 45-degree corners).
85- vec2 uv = (p - fragBounds.xy) / fragBounds.zw;
86- float h = min(uv.x, 1.0 - uv.x);
87- float v = min(uv.y, 1.0 - uv.y);
88- color = v <= h ? (uv.y < .5 ? fragSideTop : fragSideBottom)
89- : (uv.x < .5 ? fragSideLeft : fragSideRight);
83+ // Split corners using each painted side's width. A zero-width
84+ // side must never contribute its default color at the corners.
85+ vec2 edge = p - fragBounds.xy;
86+ vec2 farEdge = fragBounds.zw - edge;
87+ float top = fragWidths.x > 0.0 ? edge.y / fragWidths.x : 1e20;
88+ float right = fragWidths.y > 0.0 ? farEdge.x / fragWidths.y : 1e20;
89+ float bottom = fragWidths.z > 0.0 ? farEdge.y / fragWidths.z : 1e20;
90+ float left = fragWidths.w > 0.0 ? edge.x / fragWidths.w : 1e20;
91+ float nearest = min(min(top, bottom), min(left, right));
92+ color = nearest == top ? fragSideTop : nearest == bottom ? fragSideBottom
93+ : nearest == left ? fragSideLeft : fragSideRight;
9094 }
9195 } else if (fragKind > 3.5) {
9296 vec4 box = fragShape;
@@ -80,13 +80,17 @@ void main() {
80 float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0;80 float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0;
81 coverage *= 1.0 - hole;81 coverage *= 1.0 - hole;
82 if (fragKind < 2.5) {82 if (fragKind < 2.5) {
83- // Diagonal split: classify by the nearest normalized edge of the83+ // Split corners using each painted side's width. A zero-width
84- // outer box. Ties go to top/bottom (stable at 45-degree corners).84+ // side must never contribute its default color at the corners.
85- vec2 uv = (p - fragBounds.xy) / fragBounds.zw;85+ vec2 edge = p - fragBounds.xy;
86- float h = min(uv.x, 1.0 - uv.x);86+ vec2 farEdge = fragBounds.zw - edge;
87- float v = min(uv.y, 1.0 - uv.y);87+ float top = fragWidths.x > 0.0 ? edge.y / fragWidths.x : 1e20;
88- color = v <= h ? (uv.y < .5 ? fragSideTop : fragSideBottom)88+ float right = fragWidths.y > 0.0 ? farEdge.x / fragWidths.y : 1e20;
89- : (uv.x < .5 ? fragSideLeft : fragSideRight);89+ float bottom = fragWidths.z > 0.0 ? farEdge.y / fragWidths.z : 1e20;
90+ float left = fragWidths.w > 0.0 ? edge.x / fragWidths.w : 1e20;
91+ float nearest = min(min(top, bottom), min(left, right));
92+ color = nearest == top ? fragSideTop : nearest == bottom ? fragSideBottom
93+ : nearest == left ? fragSideLeft : fragSideRight;
90 }94 }
91 } else if (fragKind > 3.5) {95 } else if (fragKind > 3.5) {
92 vec4 box = fragShape;96 vec4 box = fragShape;
M internal/presentation/shaders/list.frag.spv +0 -0 binary file: no text diff

binary file: no text diff

A internal/presentation/systemtest/demo_discard_test.go +51 -0
@@ -0,0 +1,51 @@
1+//go:build linux
2+
3+package systemtest
4+
5+import (
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+func TestDemoDiscardProtection(t *testing.T) {
15+ requireHarness(t)
16+ bin := demoBinary(t)
17+ for _, tc := range []struct {
18+ name, input string
19+ empty, pending bool
20+ }{
21+ {"armed", "move 140 24\nclick left\n", false, true},
22+ {"cancel", "move 140 24\nclick left\nsleep 300ms\nmove 275 24\nclick left\n", false, false},
23+ {"confirmed", "move 140 24\nclick left\nsleep 300ms\nmove 140 24\nclick left\n", true, false},
24+ {"edited", "move 140 24\nclick left\nsleep 300ms\nmove 300 120\nclick left\ntype Added\n", false, false},
25+ } {
26+ t.Run(tc.name, func(t *testing.T) {
27+ script := filepath.Join(t.TempDir(), "input.txt")
28+ if err := os.WriteFile(script, []byte("sleep 8s\n"+tc.input+"sleep 1s\n"), 0600); err != nil {
29+ t.Fatal(err)
30+ }
31+ res := runDemo(t, bin, 1, 400, script, 11*time.Second)
32+ data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ var state struct {
37+ Notes string
38+ NewPending bool
39+ }
40+ if err := json.Unmarshal(data, &state); err != nil {
41+ t.Fatal(err)
42+ }
43+ if (state.Notes == "") != tc.empty || state.NewPending != tc.pending {
44+ t.Fatalf("discard state: %+v", state)
45+ }
46+ if tc.name == "edited" && !strings.Contains(state.Notes, "Added") {
47+ t.Fatal("editor input not delivered")
48+ }
49+ })
50+ }
51+}
@@ -0,0 +1,51 @@
1+//go:build linux
2+
3+package systemtest
4+
5+import (
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+func TestDemoDiscardProtection(t *testing.T) {
15+ requireHarness(t)
16+ bin := demoBinary(t)
17+ for _, tc := range []struct {
18+ name, input string
19+ empty, pending bool
20+ }{
21+ {"armed", "move 140 24\nclick left\n", false, true},
22+ {"cancel", "move 140 24\nclick left\nsleep 300ms\nmove 275 24\nclick left\n", false, false},
23+ {"confirmed", "move 140 24\nclick left\nsleep 300ms\nmove 140 24\nclick left\n", true, false},
24+ {"edited", "move 140 24\nclick left\nsleep 300ms\nmove 300 120\nclick left\ntype Added\n", false, false},
25+ } {
26+ t.Run(tc.name, func(t *testing.T) {
27+ script := filepath.Join(t.TempDir(), "input.txt")
28+ if err := os.WriteFile(script, []byte("sleep 8s\n"+tc.input+"sleep 1s\n"), 0600); err != nil {
29+ t.Fatal(err)
30+ }
31+ res := runDemo(t, bin, 1, 400, script, 11*time.Second)
32+ data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ var state struct {
37+ Notes string
38+ NewPending bool
39+ }
40+ if err := json.Unmarshal(data, &state); err != nil {
41+ t.Fatal(err)
42+ }
43+ if (state.Notes == "") != tc.empty || state.NewPending != tc.pending {
44+ t.Fatalf("discard state: %+v", state)
45+ }
46+ if tc.name == "edited" && !strings.Contains(state.Notes, "Added") {
47+ t.Fatal("editor input not delivered")
48+ }
49+ })
50+ }
51+}
M internal/presentation/systemtest/demo_test.go +20 -20
@@ -63,8 +63,8 @@ func TestDemoStatic(t *testing.T) {
6363 x, y int
6464 want color.NRGBA
6565 }{
66- {"background", 5, 5, color.NRGBA{245, 247, 250, 255}},
67- {"button", 110, 390, color.NRGBA{117, 148, 201, 255}},
66+ {"background", 5, 5, color.NRGBA{255, 255, 255, 255}},
67+ {"button", 740, 185, color.NRGBA{212, 212, 216, 255}},
6868 } {
6969 x, y := p.x*scale, p.y*scale
7070 t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)))
@@ -81,10 +81,10 @@ func TestDemoStatic(t *testing.T) {
8181 x0, y0, x1, y1 int
8282 fill color.NRGBA
8383 }{
84- {"Continuer", 110, 360, 222, 380, color.NRGBA{117, 148, 201, 255}}, // disabled primary
85- {"Accueil", 48, 88, 108, 114, color.NRGBA{143, 166, 207, 255}}, // active menu item
86- {"Thème sombre", 60, 126, 200, 152, color.NRGBA{218, 229, 245, 255}}, // header, right of the checkbox indicator
87- {"Ada placeholder", 68, 290, 180, 318, color.NRGBA{241, 245, 252, 255}}, // input
84+ {"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}},
85+ {"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}},
86+ {"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}},
87+ {"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}},
8888 } {
8989 n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)
9090 if n < 40*scale*scale || n > total/2 {
@@ -125,7 +125,7 @@ func TestDemoCursorShapes(t *testing.T) {
125125 script := filepath.Join(t.TempDir(), "input.txt")
126126 // Same cold-start allowance as TestDemoInputAndResources: the script
127127 // starts at compositor launch, not when the demo maps.
128- if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 1s\nmove 150 310\nsleep 1s\nmove 600 500\nsleep 1s\n"), 0600); err != nil {
128+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil {
129129 t.Fatal(err)
130130 }
131131 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
@@ -155,7 +155,7 @@ func TestDemoInputAndResources(t *testing.T) {
155155 // wait past cold start (font discovery) before injecting input. The demo
156156 // keeps presenting for 12 s so the run outlasts the ~10 s script however
157157 // fast frames render.
158- if err := os.WriteFile(script, []byte("sleep 8s\nmove 120 310\nsleep 200ms\nclick left\nsleep 200ms\ntype Ada\nsleep 200ms\nmove 130 390\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {
158+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {
159159 t.Fatal(err)
160160 }
161161 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
@@ -167,7 +167,7 @@ func TestDemoInputAndResources(t *testing.T) {
167167 if err = json.Unmarshal(data, &state); err != nil {
168168 t.Fatal(err)
169169 }
170- if state.Name != "Ada" || state.Status != "Bonjour Ada" {
170+ if state.Name != "Example" || state.Status != "Author set to Example · session only" {
171171 layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))
172172 t.Fatalf("state %+v layout=%s", state, layout)
173173 }
@@ -186,7 +186,7 @@ func TestDemoInputAndResources(t *testing.T) {
186186 }
187187 }
188188
189-// TestDemoIndicators opens the settings page, selects the second radio and
189+// TestDemoIndicators returns to the editor, selects the second radio and
190190 // clicks the slider track, then checks the model and the painted indicators
191191 // in the captured frame: the selected radio and the slider fill up to the
192192 // thumb use the accent color, the rest of the track does not.
@@ -195,7 +195,7 @@ func TestDemoIndicators(t *testing.T) {
195195 bin := demoBinary(t)
196196 script := filepath.Join(t.TempDir(), "input.txt")
197197 // Same cold-start allowance as TestDemoInputAndResources.
198- if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 200ms\nclick left\nsleep 800ms\nmove 74 311\nsleep 200ms\nclick left\nsleep 500ms\nmove 274 371\nsleep 200ms\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {
198+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {
199199 t.Fatal(err)
200200 }
201201 res := runDemo(t, bin, 1, 400, script, 13*time.Second)
@@ -210,23 +210,23 @@ func TestDemoIndicators(t *testing.T) {
210210 if err = json.Unmarshal(data, &state); err != nil {
211211 t.Fatal(err)
212212 }
213- if state.Page != "settings" || state.Density != "compact" || state.Volume != 70 {
213+ if state.Page != "home" || state.Density != "compact" || state.Volume != 70 {
214214 t.Fatalf("state %+v", state)
215215 }
216216 img := lastMappedFrame(t, res.Artifacts["frames"])
217- accent := color.NRGBA{53, 132, 228, 255} // UA accent-color #3584e4
218- // Muted paint is the demo text color at 45% alpha over white. Each probe
217+ accent := color.NRGBA{82, 82, 91, 255} // light theme accent
218+ // Muted paint is the demo text color at 45% alpha over the inspector. Each probe
219219 // expects a specific color, so missing paint (plain white) fails too.
220- muted := color.NRGBA{151, 157, 167, 255}
220+ muted := color.NRGBA{151, 151, 152, 255}
221221 for _, p := range []struct {
222222 name string
223223 x, y int
224224 want color.NRGBA
225225 }{
226- {"selected radio", 68, 311, accent},
227- {"unselected radio outline", 68, 276, muted},
228- {"slider fill", 150, 371, accent},
229- {"slider rail", 330, 371, muted},
226+ {"selected radio", 743, 304, accent},
227+ {"unselected radio outline", 741, 273, muted},
228+ {"slider fill", 790, 377, accent},
229+ {"slider rail", 930, 377, muted},
230230 } {
231231 t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))
232232 if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil {
@@ -249,7 +249,7 @@ func lastMappedFrame(t *testing.T, dir string) image.Image {
249249 if err != nil {
250250 continue // possibly still being written
251251 }
252- if harness.PixelProbe(img, 5, 5, color.NRGBA{245, 247, 250, 255}, 8) == nil {
252+ if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil {
253253 return img
254254 }
255255 }
@@ -63,8 +63,8 @@ func TestDemoStatic(t *testing.T) {
63 x, y int63 x, y int
64 want color.NRGBA64 want color.NRGBA
65 }{65 }{
66- {"background", 5, 5, color.NRGBA{245, 247, 250, 255}},66+ {"background", 5, 5, color.NRGBA{255, 255, 255, 255}},
67- {"button", 110, 390, color.NRGBA{117, 148, 201, 255}},67+ {"button", 740, 185, color.NRGBA{212, 212, 216, 255}},
68 } {68 } {
69 x, y := p.x*scale, p.y*scale69 x, y := p.x*scale, p.y*scale
70 t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)))70 t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)))
@@ -81,10 +81,10 @@ func TestDemoStatic(t *testing.T) {
81 x0, y0, x1, y1 int81 x0, y0, x1, y1 int
82 fill color.NRGBA82 fill color.NRGBA
83 }{83 }{
84- {"Continuer", 110, 360, 222, 380, color.NRGBA{117, 148, 201, 255}}, // disabled primary84+ {"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}},
85- {"Accueil", 48, 88, 108, 114, color.NRGBA{143, 166, 207, 255}}, // active menu item85+ {"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}},
86- {"Thème sombre", 60, 126, 200, 152, color.NRGBA{218, 229, 245, 255}}, // header, right of the checkbox indicator86+ {"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}},
87- {"Ada placeholder", 68, 290, 180, 318, color.NRGBA{241, 245, 252, 255}}, // input87+ {"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}},
88 } {88 } {
89 n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)89 n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)
90 if n < 40*scale*scale || n > total/2 {90 if n < 40*scale*scale || n > total/2 {
@@ -125,7 +125,7 @@ func TestDemoCursorShapes(t *testing.T) {
125 script := filepath.Join(t.TempDir(), "input.txt")125 script := filepath.Join(t.TempDir(), "input.txt")
126 // Same cold-start allowance as TestDemoInputAndResources: the script126 // Same cold-start allowance as TestDemoInputAndResources: the script
127 // starts at compositor launch, not when the demo maps.127 // starts at compositor launch, not when the demo maps.
128- if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 1s\nmove 150 310\nsleep 1s\nmove 600 500\nsleep 1s\n"), 0600); err != nil {128+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil {
129 t.Fatal(err)129 t.Fatal(err)
130 }130 }
131 res := runDemo(t, bin, 1, 400, script, 12*time.Second)131 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
@@ -155,7 +155,7 @@ func TestDemoInputAndResources(t *testing.T) {
155 // wait past cold start (font discovery) before injecting input. The demo155 // wait past cold start (font discovery) before injecting input. The demo
156 // keeps presenting for 12 s so the run outlasts the ~10 s script however156 // keeps presenting for 12 s so the run outlasts the ~10 s script however
157 // fast frames render.157 // fast frames render.
158- if err := os.WriteFile(script, []byte("sleep 8s\nmove 120 310\nsleep 200ms\nclick left\nsleep 200ms\ntype Ada\nsleep 200ms\nmove 130 390\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {158+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {
159 t.Fatal(err)159 t.Fatal(err)
160 }160 }
161 res := runDemo(t, bin, 1, 400, script, 12*time.Second)161 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
@@ -167,7 +167,7 @@ func TestDemoInputAndResources(t *testing.T) {
167 if err = json.Unmarshal(data, &state); err != nil {167 if err = json.Unmarshal(data, &state); err != nil {
168 t.Fatal(err)168 t.Fatal(err)
169 }169 }
170- if state.Name != "Ada" || state.Status != "Bonjour Ada" {170+ if state.Name != "Example" || state.Status != "Author set to Example · session only" {
171 layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))171 layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))
172 t.Fatalf("state %+v layout=%s", state, layout)172 t.Fatalf("state %+v layout=%s", state, layout)
173 }173 }
@@ -186,7 +186,7 @@ func TestDemoInputAndResources(t *testing.T) {
186 }186 }
187 }187 }
188 188
189-// TestDemoIndicators opens the settings page, selects the second radio and189+// TestDemoIndicators returns to the editor, selects the second radio and
190 // clicks the slider track, then checks the model and the painted indicators190 // clicks the slider track, then checks the model and the painted indicators
191 // in the captured frame: the selected radio and the slider fill up to the191 // in the captured frame: the selected radio and the slider fill up to the
192 // thumb use the accent color, the rest of the track does not.192 // thumb use the accent color, the rest of the track does not.
@@ -195,7 +195,7 @@ func TestDemoIndicators(t *testing.T) {
195 bin := demoBinary(t)195 bin := demoBinary(t)
196 script := filepath.Join(t.TempDir(), "input.txt")196 script := filepath.Join(t.TempDir(), "input.txt")
197 // Same cold-start allowance as TestDemoInputAndResources.197 // Same cold-start allowance as TestDemoInputAndResources.
198- if err := os.WriteFile(script, []byte("sleep 8s\nmove 180 102\nsleep 200ms\nclick left\nsleep 800ms\nmove 74 311\nsleep 200ms\nclick left\nsleep 500ms\nmove 274 371\nsleep 200ms\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {198+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {
199 t.Fatal(err)199 t.Fatal(err)
200 }200 }
201 res := runDemo(t, bin, 1, 400, script, 13*time.Second)201 res := runDemo(t, bin, 1, 400, script, 13*time.Second)
@@ -210,23 +210,23 @@ func TestDemoIndicators(t *testing.T) {
210 if err = json.Unmarshal(data, &state); err != nil {210 if err = json.Unmarshal(data, &state); err != nil {
211 t.Fatal(err)211 t.Fatal(err)
212 }212 }
213- if state.Page != "settings" || state.Density != "compact" || state.Volume != 70 {213+ if state.Page != "home" || state.Density != "compact" || state.Volume != 70 {
214 t.Fatalf("state %+v", state)214 t.Fatalf("state %+v", state)
215 }215 }
216 img := lastMappedFrame(t, res.Artifacts["frames"])216 img := lastMappedFrame(t, res.Artifacts["frames"])
217- accent := color.NRGBA{53, 132, 228, 255} // UA accent-color #3584e4217+ accent := color.NRGBA{82, 82, 91, 255} // light theme accent
218- // Muted paint is the demo text color at 45% alpha over white. Each probe218+ // Muted paint is the demo text color at 45% alpha over the inspector. Each probe
219 // expects a specific color, so missing paint (plain white) fails too.219 // expects a specific color, so missing paint (plain white) fails too.
220- muted := color.NRGBA{151, 157, 167, 255}220+ muted := color.NRGBA{151, 151, 152, 255}
221 for _, p := range []struct {221 for _, p := range []struct {
222 name string222 name string
223 x, y int223 x, y int
224 want color.NRGBA224 want color.NRGBA
225 }{225 }{
226- {"selected radio", 68, 311, accent},226+ {"selected radio", 743, 304, accent},
227- {"unselected radio outline", 68, 276, muted},227+ {"unselected radio outline", 741, 273, muted},
228- {"slider fill", 150, 371, accent},228+ {"slider fill", 790, 377, accent},
229- {"slider rail", 330, 371, muted},229+ {"slider rail", 930, 377, muted},
230 } {230 } {
231 t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))231 t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))
232 if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil {232 if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil {
@@ -249,7 +249,7 @@ func lastMappedFrame(t *testing.T, dir string) image.Image {
249 if err != nil {249 if err != nil {
250 continue // possibly still being written250 continue // possibly still being written
251 }251 }
252- if harness.PixelProbe(img, 5, 5, color.NRGBA{245, 247, 250, 255}, 8) == nil {252+ if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil {
253 return img253 return img
254 }254 }
255 }255 }
A internal/presentation/systemtest/demo_theme_test.go +47 -0
@@ -0,0 +1,47 @@
1+//go:build linux
2+
3+package systemtest
4+
5+import (
6+ "encoding/json"
7+ "image/color"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefergui/internal/harness"
14+)
15+
16+func TestDemoThemeAndPreferences(t *testing.T) {
17+ requireHarness(t)
18+ bin := demoBinary(t)
19+ script := filepath.Join(t.TempDir(), "input.txt")
20+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 890 24\nclick left\nsleep 500ms\nmove 80 166\nclick left\nsleep 500ms\nmove 600 580\nsleep 1s\n"), 0600); err != nil {
21+ t.Fatal(err)
22+ }
23+ res := runDemo(t, bin, 1, 400, script, 12*time.Second)
24+ data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ var state struct {
29+ Page string
30+ DarkMode bool
31+ }
32+ if err := json.Unmarshal(data, &state); err != nil {
33+ t.Fatal(err)
34+ }
35+ if !state.DarkMode || state.Page != "settings" {
36+ t.Fatalf("theme/navigation did not update: %+v", state)
37+ }
38+ img := lastMappedFrame(t, res.Artifacts["frames"])
39+ for _, p := range []struct {
40+ x, y int
41+ want color.NRGBA
42+ }{{600, 80, color.NRGBA{24, 24, 27, 255}}, {240, 230, color.NRGBA{24, 24, 27, 255}}} {
43+ if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil {
44+ t.Error(err)
45+ }
46+ }
47+}
@@ -0,0 +1,47 @@
1+//go:build linux
2+
3+package systemtest
4+
5+import (
6+ "encoding/json"
7+ "image/color"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefergui/internal/harness"
14+)
15+
16+func TestDemoThemeAndPreferences(t *testing.T) {
17+ requireHarness(t)
18+ bin := demoBinary(t)
19+ script := filepath.Join(t.TempDir(), "input.txt")
20+ if err := os.WriteFile(script, []byte("sleep 8s\nmove 890 24\nclick left\nsleep 500ms\nmove 80 166\nclick left\nsleep 500ms\nmove 600 580\nsleep 1s\n"), 0600); err != nil {
21+ t.Fatal(err)
22+ }
23+ res := runDemo(t, bin, 1, 400, script, 12*time.Second)
24+ data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ var state struct {
29+ Page string
30+ DarkMode bool
31+ }
32+ if err := json.Unmarshal(data, &state); err != nil {
33+ t.Fatal(err)
34+ }
35+ if !state.DarkMode || state.Page != "settings" {
36+ t.Fatalf("theme/navigation did not update: %+v", state)
37+ }
38+ img := lastMappedFrame(t, res.Artifacts["frames"])
39+ for _, p := range []struct {
40+ x, y int
41+ want color.NRGBA
42+ }{{600, 80, color.NRGBA{24, 24, 27, 255}}, {240, 230, color.NRGBA{24, 24, 27, 255}}} {
43+ if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil {
44+ t.Error(err)
45+ }
46+ }
47+}
M internal/presentation/systemtest/system_test.go +4 -0
@@ -389,6 +389,10 @@ func TestListStyles(t *testing.T) {
389389 {"rounded corner", 113 * scale, 113 * scale, bg},
390390 {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}},
391391 {"far", 200 * scale, 285 * scale, bg},
392+ {"single-side left corner", 300 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
393+ {"single-side right corner", 379 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
394+ {"unequal corner top", 301 * scale, 242 * scale, color.NRGBA{255, 0, 0, 255}},
395+ {"unequal corner left", 300 * scale, 246 * scale, color.NRGBA{0, 0, 255, 255}},
392396 }
393397 for _, p := range probes {
394398 got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)
@@ -389,6 +389,10 @@ func TestListStyles(t *testing.T) {
389 {"rounded corner", 113 * scale, 113 * scale, bg},389 {"rounded corner", 113 * scale, 113 * scale, bg},
390 {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}},390 {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}},
391 {"far", 200 * scale, 285 * scale, bg},391 {"far", 200 * scale, 285 * scale, bg},
392+ {"single-side left corner", 300 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
393+ {"single-side right corner", 379 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
394+ {"unequal corner top", 301 * scale, 242 * scale, color.NRGBA{255, 0, 0, 255}},
395+ {"unequal corner left", 300 * scale, 246 * scale, color.NRGBA{0, 0, 255, 255}},
392 }396 }
393 for _, p := range probes {397 for _, p := range probes {
394 got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)398 got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)
M internal/ui/performance_test.go +53 -16
@@ -1,29 +1,61 @@
11 package ui
22
33 import (
4+ "os"
45 "testing"
56
7+ "github.com/bnema/nefergui/internal/css"
8+
69 "github.com/bnema/nefergui/internal/text"
710 )
811
912 // demoFrame mirrors the product example's home view without opening a display.
1013 func demoFrame(f *Frame) {
11- root := f.Root(Class("app"))
14+ root := f.Root(Class("app"), Class("light"), Class("comfortable"))
1215 header := root.Header(Class("header"))
13- header.Heading("Demo", Level(1), Class("logo"))
14- menu := header.Nav(Class("menu"))
15- menu.Button("Accueil", Key("home"), Class("menu-item"))
16- menu.Button("Paramètres", Key("settings"), Class("menu-item"))
17- dark := false
18- header.Checkbox("Thème sombre", &dark, Key("dark-mode"))
19- content := root.Main(Class("content"))
20- section := content.Section(Class("welcome"))
21- section.Heading("Bienvenue", Level(2))
22- section.Text("Une interface native écrite entièrement en Go.")
23- name := "Ada"
24- section.Input("Votre nom", &name, Key("name"), Placeholder("Ada"))
25- section.Row(Class("actions")).Button("Continuer", Key("continue"), Class("primary"))
26- root.Footer(Class("status-bar")).Text("Bonjour Ada")
16+ header.Heading("NeferGUI", Level(1), Class("logo"))
17+ header.Button("New", Key("new-document"))
18+ header.Text("Untitled.txt", Class("muted"))
19+ header.Spacer(Class("stretch"))
20+ header.Button("Dark mode", Key("dark-mode"), Class("theme-toggle"))
21+ body := root.Row(Class("body"))
22+ sidebar := body.Aside(Class("sidebar"))
23+ sidebar.Text("DOCUMENTS", Class("eyebrow"))
24+ menu := sidebar.Nav(Class("menu"))
25+ menu.Button("Untitled.txt", Key("home"), Class("menu-item"), Class("active"))
26+ menu.Button("Preferences", Key("settings"), Class("menu-item"))
27+ sidebar.Spacer(Class("stretch"))
28+ sidebar.Text("Local workspace", Class("sidebar-note"))
29+ sidebar.Text("1 document · in memory", Class("hint"))
30+ content := body.Main(Class("content"))
31+ workspace := content.Row(Class("workspace"))
32+ editor := workspace.Column(Class("editor-pane"))
33+ tab := editor.Row(Class("pane-bar"))
34+ tab.Text("Untitled.txt", Class("document-title"))
35+ tab.Spacer(Class("stretch"))
36+ tab.Text("Plain text", Class("hint"))
37+ 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."
38+ editor.Textarea("Document", &notes, Key("document"), Class("document-editor"), Placeholder("Start writing…"))
39+ inspector := workspace.Aside(Class("inspector"))
40+ inspector.Text("PROPERTIES", Class("eyebrow"))
41+ inspector.Text("Author", Class("field-label"))
42+ name := ""
43+ inspector.Input("Author", &name, Key("name"), Placeholder("Your name"))
44+ inspector.Button("Apply", Key("continue"), Disabled(true))
45+ inspector.Separator()
46+ inspector.Text("Interface density", Class("field-label"))
47+ density := "comfortable"
48+ inspector.Radio("Comfortable", "comfortable", &density, Key("comfortable"))
49+ inspector.Radio("Compact", "compact", &density, Key("compact"))
50+ inspector.Separator()
51+ volume := 40.0
52+ inspector.Slider("Volume 40 %", &volume, 0, 100, 5, Key("volume"))
53+ inspector.Spacer(Class("stretch"))
54+ inspector.Text("Changes stay in this session.", Class("hint"))
55+ footer := root.Footer(Class("status-bar"))
56+ footer.Text("Ready")
57+ footer.Spacer(Class("stretch"))
58+ footer.Text("UTF-8 • Plain text", Class("hint"))
2759 }
2860
2961 func demoRuntime(tb testing.TB) *runtime {
@@ -33,6 +65,11 @@ func demoRuntime(tb testing.TB) *runtime {
3365 tb.Fatal(err)
3466 }
3567 r := newRuntime()
68+ stylesheet, err := os.ReadFile("../../examples/demo/app.css")
69+ if err != nil {
70+ tb.Fatal(err)
71+ }
72+ r.styles = css.Compile(css.UA(), css.Parse(string(stylesheet)))
3673 r.setTextEngine(text.NewEngine(catalog))
3774 return r
3875 }
@@ -66,7 +103,7 @@ func TestDemoFrameAllocationBudget(t *testing.T) {
66103 panic("demo frame failed")
67104 }
68105 })
69- const budget = 500
106+ const budget = 1100
70107 if allocs > budget {
71108 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)
72109 }
@@ -1,29 +1,61 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ "os"
4 "testing"5 "testing"
5 6
7+ "github.com/bnema/nefergui/internal/css"
8+
6 "github.com/bnema/nefergui/internal/text"9 "github.com/bnema/nefergui/internal/text"
7 )10 )
8 11
9 // demoFrame mirrors the product example's home view without opening a display.12 // demoFrame mirrors the product example's home view without opening a display.
10 func demoFrame(f *Frame) {13 func demoFrame(f *Frame) {
11- root := f.Root(Class("app"))14+ root := f.Root(Class("app"), Class("light"), Class("comfortable"))
12 header := root.Header(Class("header"))15 header := root.Header(Class("header"))
13- header.Heading("Demo", Level(1), Class("logo"))16+ header.Heading("NeferGUI", Level(1), Class("logo"))
14- menu := header.Nav(Class("menu"))17+ header.Button("New", Key("new-document"))
15- menu.Button("Accueil", Key("home"), Class("menu-item"))18+ header.Text("Untitled.txt", Class("muted"))
16- menu.Button("Paramètres", Key("settings"), Class("menu-item"))19+ header.Spacer(Class("stretch"))
17- dark := false20+ header.Button("Dark mode", Key("dark-mode"), Class("theme-toggle"))
18- header.Checkbox("Thème sombre", &dark, Key("dark-mode"))21+ body := root.Row(Class("body"))
19- content := root.Main(Class("content"))22+ sidebar := body.Aside(Class("sidebar"))
20- section := content.Section(Class("welcome"))23+ sidebar.Text("DOCUMENTS", Class("eyebrow"))
21- section.Heading("Bienvenue", Level(2))24+ menu := sidebar.Nav(Class("menu"))
22- section.Text("Une interface native écrite entièrement en Go.")25+ menu.Button("Untitled.txt", Key("home"), Class("menu-item"), Class("active"))
23- name := "Ada"26+ menu.Button("Preferences", Key("settings"), Class("menu-item"))
24- section.Input("Votre nom", &name, Key("name"), Placeholder("Ada"))27+ sidebar.Spacer(Class("stretch"))
25- section.Row(Class("actions")).Button("Continuer", Key("continue"), Class("primary"))28+ sidebar.Text("Local workspace", Class("sidebar-note"))
26- root.Footer(Class("status-bar")).Text("Bonjour Ada")29+ sidebar.Text("1 document · in memory", Class("hint"))
30+ content := body.Main(Class("content"))
31+ workspace := content.Row(Class("workspace"))
32+ editor := workspace.Column(Class("editor-pane"))
33+ tab := editor.Row(Class("pane-bar"))
34+ tab.Text("Untitled.txt", Class("document-title"))
35+ tab.Spacer(Class("stretch"))
36+ tab.Text("Plain text", Class("hint"))
37+ 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."
38+ editor.Textarea("Document", &notes, Key("document"), Class("document-editor"), Placeholder("Start writing…"))
39+ inspector := workspace.Aside(Class("inspector"))
40+ inspector.Text("PROPERTIES", Class("eyebrow"))
41+ inspector.Text("Author", Class("field-label"))
42+ name := ""
43+ inspector.Input("Author", &name, Key("name"), Placeholder("Your name"))
44+ inspector.Button("Apply", Key("continue"), Disabled(true))
45+ inspector.Separator()
46+ inspector.Text("Interface density", Class("field-label"))
47+ density := "comfortable"
48+ inspector.Radio("Comfortable", "comfortable", &density, Key("comfortable"))
49+ inspector.Radio("Compact", "compact", &density, Key("compact"))
50+ inspector.Separator()
51+ volume := 40.0
52+ inspector.Slider("Volume 40 %", &volume, 0, 100, 5, Key("volume"))
53+ inspector.Spacer(Class("stretch"))
54+ inspector.Text("Changes stay in this session.", Class("hint"))
55+ footer := root.Footer(Class("status-bar"))
56+ footer.Text("Ready")
57+ footer.Spacer(Class("stretch"))
58+ footer.Text("UTF-8 • Plain text", Class("hint"))
27 }59 }
28 60
29 func demoRuntime(tb testing.TB) *runtime {61 func demoRuntime(tb testing.TB) *runtime {
@@ -33,6 +65,11 @@ func demoRuntime(tb testing.TB) *runtime {
33 tb.Fatal(err)65 tb.Fatal(err)
34 }66 }
35 r := newRuntime()67 r := newRuntime()
68+ stylesheet, err := os.ReadFile("../../examples/demo/app.css")
69+ if err != nil {
70+ tb.Fatal(err)
71+ }
72+ r.styles = css.Compile(css.UA(), css.Parse(string(stylesheet)))
36 r.setTextEngine(text.NewEngine(catalog))73 r.setTextEngine(text.NewEngine(catalog))
37 return r74 return r
38 }75 }
@@ -66,7 +103,7 @@ func TestDemoFrameAllocationBudget(t *testing.T) {
66 panic("demo frame failed")103 panic("demo frame failed")
67 }104 }
68 })105 })
69- const budget = 500106+ const budget = 1100
70 if allocs > budget {107 if allocs > budget {
71 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)108 t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget)
72 }109 }

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