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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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; } | |
| 17 | 67 | 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 | |||
| 24 | 24 | ||
| 25 | 25 | type Model struct { | |
| 26 | 26 | Page, Name, Status string | |
| 27 | + | Notes string | |
| 28 | + | NewPending bool | |
| 27 | 29 | DarkMode bool | |
| 28 | 30 | Density string | |
| 29 | 31 | Volume float64 | |
| @@ -39,39 +41,106 @@ func navButton(parent nefergui.Node, m *Model, label, page string) { | |||
| 39 | 41 | } | |
| 40 | 42 | } | |
| 41 | 43 | 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" | |
| 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 | 71 | 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 | |
| 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 | 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 | 102 | 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")) | |
| 68 | 134 | switch m.Page { | |
| 69 | 135 | case "home": | |
| 70 | 136 | home(content, m) | |
| 71 | 137 | case "settings": | |
| 72 | 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 | 145 | func main() { | |
| 77 | 146 | static := flag.Bool("static", false, "render a fixed number of frames and exit") | |
| @@ -123,16 +192,12 @@ func main() { | |||
| 123 | 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 | 196 | start := time.Now() | |
| 128 | 197 | var requested time.Time | |
| 129 | 198 | commit := func(frame uint64) error { | |
| 130 | 199 | 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) | |
| 136 | 201 | if err != nil { | |
| 137 | 202 | return err | |
| 138 | 203 | } | |
| @@ -161,7 +226,7 @@ func main() { | |||
| 161 | 226 | } | |
| 162 | 227 | 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 | 230 | var err error | |
| 166 | 231 | if *static { | |
| 167 | 232 | // 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 string | 26 | Page, Name, Status string | ||
| 27 | + | Notes string | |||
| 28 | + | NewPending bool | |||
| 27 | DarkMode bool | 29 | DarkMode bool | ||
| 28 | Density string | 30 | Density string | ||
| 29 | Volume float64 | 31 | 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.Name | 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" | |||
| 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.Time | 197 | 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 err | 202 | return err | ||
| 138 | } | 203 | } | ||
| @@ -161,7 +226,7 @@ func main() { | |||||
| 161 | } | 226 | } | ||
| 162 | return nil | 227 | 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 error | 230 | 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 ( | |||
| 7 | 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 | 27 | func TestUnexpectedKeepsCloseErrors(t *testing.T) { | |
| 11 | 28 | closeErr := errors.New("close failed") | |
| 12 | 29 | 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 { | |||
| 228 | 228 | {Op: "shadow", Rect: box, Radii: radius, Shadow: &sh, Opacity: 1}, | |
| 229 | 229 | {Op: "rect", Rect: box, Radii: radius, Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}, | |
| 230 | 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 | 235 | // Layout has already expanded this rect by the 4px outline offset. | |
| 232 | 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 | } | |
| @@ -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() { | |||
| 80 | 80 | float hole = inner.z > 0.0 && inner.w > 0.0 ? cover(roundedDistance(p, inner, innerRadii)) : 0.0; | |
| 81 | 81 | coverage *= 1.0 - hole; | |
| 82 | 82 | 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; | |
| 90 | 94 | } | |
| 91 | 95 | } else if (fragKind > 3.5) { | |
| 92 | 96 | 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 the | 83 | + | // 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) { | |||
| 63 | 63 | x, y int | |
| 64 | 64 | want color.NRGBA | |
| 65 | 65 | }{ | |
| 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}}, | |
| 68 | 68 | } { | |
| 69 | 69 | x, y := p.x*scale, p.y*scale | |
| 70 | 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 | 81 | x0, y0, x1, y1 int | |
| 82 | 82 | fill color.NRGBA | |
| 83 | 83 | }{ | |
| 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}}, | |
| 88 | 88 | } { | |
| 89 | 89 | n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill) | |
| 90 | 90 | if n < 40*scale*scale || n > total/2 { | |
| @@ -125,7 +125,7 @@ func TestDemoCursorShapes(t *testing.T) { | |||
| 125 | 125 | script := filepath.Join(t.TempDir(), "input.txt") | |
| 126 | 126 | // Same cold-start allowance as TestDemoInputAndResources: the script | |
| 127 | 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 | 129 | t.Fatal(err) | |
| 130 | 130 | } | |
| 131 | 131 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | |
| @@ -155,7 +155,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||
| 155 | 155 | // wait past cold start (font discovery) before injecting input. The demo | |
| 156 | 156 | // keeps presenting for 12 s so the run outlasts the ~10 s script however | |
| 157 | 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 | 159 | t.Fatal(err) | |
| 160 | 160 | } | |
| 161 | 161 | res := runDemo(t, bin, 1, 400, script, 12*time.Second) | |
| @@ -167,7 +167,7 @@ func TestDemoInputAndResources(t *testing.T) { | |||
| 167 | 167 | if err = json.Unmarshal(data, &state); err != nil { | |
| 168 | 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 | 171 | layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json")) | |
| 172 | 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 and | |
| 189 | + | // TestDemoIndicators returns to the editor, selects the second radio and | |
| 190 | 190 | // clicks the slider track, then checks the model and the painted indicators | |
| 191 | 191 | // in the captured frame: the selected radio and the slider fill up to the | |
| 192 | 192 | // thumb use the accent color, the rest of the track does not. | |
| @@ -195,7 +195,7 @@ func TestDemoIndicators(t *testing.T) { | |||
| 195 | 195 | bin := demoBinary(t) | |
| 196 | 196 | script := filepath.Join(t.TempDir(), "input.txt") | |
| 197 | 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 | 199 | t.Fatal(err) | |
| 200 | 200 | } | |
| 201 | 201 | res := runDemo(t, bin, 1, 400, script, 13*time.Second) | |
| @@ -210,23 +210,23 @@ func TestDemoIndicators(t *testing.T) { | |||
| 210 | 210 | if err = json.Unmarshal(data, &state); err != nil { | |
| 211 | 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 | 214 | t.Fatalf("state %+v", state) | |
| 215 | 215 | } | |
| 216 | 216 | 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 | |
| 219 | 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 | 221 | for _, p := range []struct { | |
| 222 | 222 | name string | |
| 223 | 223 | x, y int | |
| 224 | 224 | want color.NRGBA | |
| 225 | 225 | }{ | |
| 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}, | |
| 230 | 230 | } { | |
| 231 | 231 | t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y))) | |
| 232 | 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 | 249 | if err != nil { | |
| 250 | 250 | 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 | 253 | return img | |
| 254 | 254 | } | |
| 255 | 255 | } | |
| @@ -63,8 +63,8 @@ func TestDemoStatic(t *testing.T) { | |||||
| 63 | x, y int | 63 | x, y int | ||
| 64 | want color.NRGBA | 64 | 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*scale | 69 | 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 int | 81 | x0, y0, x1, y1 int | ||
| 82 | fill color.NRGBA | 82 | fill color.NRGBA | ||
| 83 | }{ | 83 | }{ | ||
| 84 | - | {"Continuer", 110, 360, 222, 380, color.NRGBA{117, 148, 201, 255}}, // disabled primary | 84 | + | {"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 item | 85 | + | {"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 indicator | 86 | + | {"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}}, // input | 87 | + | {"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 script | 126 | // 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 demo | 155 | // 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 however | 156 | // 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 and | 189 | + | // TestDemoIndicators returns to the editor, selects the second radio and |
| 190 | // clicks the slider track, then checks the model and the painted indicators | 190 | // 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 the | 191 | // 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 #3584e4 | 217 | + | accent := color.NRGBA{82, 82, 91, 255} // light theme accent |
| 218 | - | // Muted paint is the demo text color at 45% alpha over white. Each probe | 218 | + | // 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 string | 222 | name string | ||
| 223 | x, y int | 223 | x, y int | ||
| 224 | want color.NRGBA | 224 | 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 written | 250 | 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 img | 253 | 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) { | |||
| 389 | 389 | {"rounded corner", 113 * scale, 113 * scale, bg}, | |
| 390 | 390 | {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}}, | |
| 391 | 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 | 397 | for _, p := range probes { | |
| 394 | 398 | 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 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | ||
| 6 | 9 | "github.com/bnema/nefergui/internal/text" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | 12 | // demoFrame mirrors the product example's home view without opening a display. | |
| 10 | 13 | func demoFrame(f *Frame) { | |
| 11 | - | root := f.Root(Class("app")) | |
| 14 | + | root := f.Root(Class("app"), Class("light"), Class("comfortable")) | |
| 12 | 15 | 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", ¬es, 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 | 61 | func demoRuntime(tb testing.TB) *runtime { | |
| @@ -33,6 +65,11 @@ func demoRuntime(tb testing.TB) *runtime { | |||
| 33 | 65 | tb.Fatal(err) | |
| 34 | 66 | } | |
| 35 | 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 | 73 | r.setTextEngine(text.NewEngine(catalog)) | |
| 37 | 74 | return r | |
| 38 | 75 | } | |
| @@ -66,7 +103,7 @@ func TestDemoFrameAllocationBudget(t *testing.T) { | |||
| 66 | 103 | panic("demo frame failed") | |
| 67 | 104 | } | |
| 68 | 105 | }) | |
| 69 | - | const budget = 500 | |
| 106 | + | const budget = 1100 | |
| 70 | 107 | if allocs > budget { | |
| 71 | 108 | t.Fatalf("demo frame allocations: %.0f > %d", allocs, budget) | |
| 72 | 109 | } | |
| @@ -1,29 +1,61 @@ | |||||
| 1 | package ui | 1 | 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 := false | 20 | + | 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", ¬es, 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 r | 74 | 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 = 500 | 106 | + | 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 | } | ||