diff --git a/README.md b/README.md index 5ac9ba3..e45c7bd 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,27 @@ embedded browser, no C++ toolchain to learn. ```go package main -import g "github.com/nv404/gova" - -type Counter struct{} - -func (Counter) Body(s *g.Scope) g.View { - count := g.State(s, 0) - return g.VStack( - g.Text(count.Format("Count: %d")).Font(g.Title), - g.HStack( - g.Button("-", func() { count.Set(count.Get() - 1) }), - g.Button("+", func() { count.Set(count.Get() + 1) }), - ).Spacing(g.SpaceMD), - ).Padding(g.SpaceLG) -} - -func main() { - g.Run("Counter", g.Component(Counter{})) -} +import . "github.com/nv404/gova" + +var Counter = Define(func(s *Scope) View { + count := State(s, 0) + return VStack( + Text(count.Format("Count: %d")), + Button("+", func() { count.Update(func(n int) int { return n + 1 }) }), + Button("-", func() { count.Update(func(n int) int { return n - 1 }) }), + ) +}) + +func main() { Run("Counter", Counter) } ``` -Screenshot 2026-04-24 at 2 44 59 PM - +

+ Counter example window on macOS +

## Why Gova diff --git a/docs-site/app/(home)/page.tsx b/docs-site/app/(home)/page.tsx index 77ce28a..7385bce 100644 --- a/docs-site/app/(home)/page.tsx +++ b/docs-site/app/(home)/page.tsx @@ -6,6 +6,7 @@ export default function HomePage() {
+ @@ -226,7 +227,7 @@ function HeroCode() { {' '}return VStack( , <> - {' '}Text(count.Format("Count: %d")).Font(Title), + {' '}Text(count.Format("Count: %d")), , <> {' '}Button("+", func() {'{'} count.Update(func(n int) int {'{'} return n + 1 {'}'}) {'}'}), @@ -338,6 +339,138 @@ function Metrics() { ); } +/* ---------- Showcase ---------- */ + +function Showcase() { + const apps: Array<{ + name: string; + src: string; + href: string; + }> = [ + { + name: 'counter', + src: '/screenshots/counter-example.png', + href: 'https://github.com/nv404/gova/tree/main/examples/counter', + }, + { + name: 'todo', + src: '/screenshots/todo-example.png', + href: 'https://github.com/nv404/gova/tree/main/examples/todo', + }, + { + name: 'fancytodo', + src: '/screenshots/fancytodo-example.png', + href: 'https://github.com/nv404/gova/tree/main/examples/fancytodo', + }, + ]; + return ( +
+

+ Each one is a single Go file in{' '} + ./examples. Click through to read the + source. +

+ +
+ {apps.map((a) => ( + +
+ {`${a.name} +
+
+

+ {a.name} +

+ + source + +
+ + ))} +
+
+ ); +} + /* ---------- Features ---------- */ function Features() { diff --git a/docs-site/app/global.css b/docs-site/app/global.css index e15fc3a..e543c34 100644 --- a/docs-site/app/global.css +++ b/docs-site/app/global.css @@ -281,6 +281,19 @@ body { background: var(--gova-ink-2); } +/* Showcase cards — image-led examples grid */ +.gova-showcase-card:hover { + border-color: rgba(247, 244, 229, 0.28); + transform: translateY(-2px); + box-shadow: 0 22px 56px -22px rgba(0, 0, 0, 0.7); +} +.gova-showcase-card:hover img { + transform: scale(1.015); +} +.gova-showcase-card img { + transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + /* Responsive tweaks */ @media (max-width: 960px) { .gova-hero-grid { grid-template-columns: 1fr !important; } @@ -288,6 +301,7 @@ body { .gova-features-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } .gova-metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 1.5rem !important; } .gova-quickstart-row { grid-template-columns: 1fr !important; gap: 1.1rem !important; } + .gova-showcase-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } } @media (max-width: 720px) { /* Platform-support table: stack into one card per feature with @@ -317,6 +331,7 @@ body { @media (max-width: 600px) { .gova-features-grid { grid-template-columns: 1fr !important; } .gova-metrics-grid { grid-template-columns: 1fr !important; } + .gova-showcase-grid { grid-template-columns: 1fr !important; } } /* Fumadocs nav title size tweak */ diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 45caa85..ea19ecf 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -151,5 +151,50 @@ Save the file and run `go run .`. You should see a window with a title, an input with an Add button, and a scrolling list of todos below. Try adding, toggling, and deleting. Resize the window to confirm the input grows with it. +
+ The Todo example running on macOS, showing two items and an input field +
+ The finished todo app, a single Go file, native window. +
+
+ +Want a richer take on the same idea: categories, filters, custom theme? See +[`examples/fancytodo`](https://github.com/nv404/gova/tree/main/examples/fancytodo). + +
+ The fancytodo example with categorized items and filter chips +
+ Once this is working, continue with [Core concepts](/docs/concepts) to learn what `View`, `Viewable`, and `Scope` mean precisely. diff --git a/docs-site/content/docs/index.mdx b/docs-site/content/docs/index.mdx index 85c5442..9aab92a 100644 --- a/docs-site/content/docs/index.mdx +++ b/docs-site/content/docs/index.mdx @@ -33,24 +33,44 @@ package main import . "github.com/nv404/gova" -type Counter struct{} - -func (Counter) Body(s *Scope) View { +var Counter = Define(func(s *Scope) View { count := State(s, 0) - return VStack( - Text(count.Format("count: %d")).Font(Title), - Button("Increment", func() { - count.Update(func(n int) int { return n + 1 }) - }), - ).Spacing(SpaceMD).Padding(SpaceLG) -} + Text(count.Format("Count: %d")), + Button("+", func() { count.Update(func(n int) int { return n + 1 }) }), + Button("-", func() { count.Update(func(n int) int { return n - 1 }) }), + ) +}) -func main() { - Run("Counter", Component(Counter{})) -} +func main() { Run("Counter", Counter) } ``` +
+ The counter example running as a native window on macOS +
+ The program above, running on macOS. +
+
+ The rest of the documentation walks through every concept and every exported identifier. Start with [Getting started](/docs/getting-started/installation) if you are new. diff --git a/docs-site/public/screenshots/counter-example.png b/docs-site/public/screenshots/counter-example.png new file mode 100644 index 0000000..c94d4ac Binary files /dev/null and b/docs-site/public/screenshots/counter-example.png differ diff --git a/docs-site/public/screenshots/fancytodo-example.png b/docs-site/public/screenshots/fancytodo-example.png new file mode 100644 index 0000000..73758a9 Binary files /dev/null and b/docs-site/public/screenshots/fancytodo-example.png differ diff --git a/docs-site/public/screenshots/todo-example.png b/docs-site/public/screenshots/todo-example.png new file mode 100644 index 0000000..1ca0a91 Binary files /dev/null and b/docs-site/public/screenshots/todo-example.png differ diff --git a/examples/fancytodo/main.go b/examples/fancytodo/main.go index 4ce2782..009c979 100644 --- a/examples/fancytodo/main.go +++ b/examples/fancytodo/main.go @@ -167,7 +167,6 @@ var FancyTodo = g.Define(func(s *g.Scope) g.View { g.TextField(input). Placeholder("What needs to be done?"). OnSubmit(func(string) { add() }). - MinHeight(36). Grow(), addBtn, ).Spacing(g.SpaceSM) @@ -293,7 +292,7 @@ func todoRow(t Todo, alpha float64, onToggle func(int, bool), onDelete func(int) g.Text(string(t.Category)).Font(g.Caption).Color(g.Secondary), ).Spacing(g.SpaceXS) - body := g.VStack(title, meta).Spacing(g.SpaceXS).Align(g.Leading) + body := g.VStack(title, meta).Spacing(g.SpaceXS).Align(g.Leading).Grow() deleteBtn := g.HStack( g.Text("Delete").Font(g.Caption).Color(g.Secondary).Bold(), @@ -307,7 +306,6 @@ func todoRow(t Todo, alpha float64, onToggle func(int, bool), onDelete func(int) return g.HStack( g.Toggle(t.Done).OnChange(func(done bool) { onToggle(t.ID, done) }), body, - g.Spacer(), deleteBtn, ).Spacing(g.SpaceSM). PaddingH(g.SpaceMD). diff --git a/examples/notes/main.go b/examples/notes/main.go index ab0cc33..e627606 100644 --- a/examples/notes/main.go +++ b/examples/notes/main.go @@ -62,14 +62,16 @@ var NotesTab = g.Define(func(s *g.Scope) g.View { func(i int, note Note) g.View { id := note.ID return g.HStack( - g.Text(note.Title), - g.Spacer(), + g.Text(note.Title).Grow(), g.Button("Delete", func() { model.Update(func(m NotesModel) NotesModel { return removeNote(m, id) }) }).Color(g.Red), - ) + ). + Spacing(g.SpaceSM). + PaddingH(g.SpaceLG). + PaddingV(g.SpaceSM) }, ), ), @@ -80,13 +82,12 @@ var NotesTab = g.Define(func(s *g.Scope) g.View { g.TextField(input). Placeholder("New note title..."). OnSubmit(func(val string) { add() }). - MinHeight(36). Grow(), g.Button("Add", add), ).Spacing(g.SpaceSM), - ).Spacing(g.SpaceMD).Padding(g.SpaceMD), + ).Spacing(g.SpaceMD).Padding(g.SpaceLG), ).Bottom( - g.Text(count).Font(g.Caption).Color(g.Secondary).Padding(g.SpaceMD), + g.Text(count).Font(g.Caption).Color(g.Secondary).Padding(g.SpaceLG), ) }) @@ -102,7 +103,7 @@ var StatsTab = g.Define(func(s *g.Scope) g.View { g.Divider(), g.Text(count), g.Spacer(), - ).Padding(16) + ).Padding(g.SpaceLG) }) var ErrorTab = g.Define(func(s *g.Scope) g.View { @@ -126,7 +127,7 @@ var ErrorTab = g.Define(func(s *g.Scope) g.View { ) }), g.Spacer(), - ).Padding(16) + ).Padding(g.SpaceLG) }) func riskyOperation() error { diff --git a/examples/themed/main.go b/examples/themed/main.go index 6354460..748b9a6 100644 --- a/examples/themed/main.go +++ b/examples/themed/main.go @@ -18,7 +18,7 @@ var App = g.Define(func(s *g.Scope) g.View { g.Divider(), g.HStack( - g.Text("Dark Mode"), + g.Text("Dark Mode").NoWrap(), g.Spacer(), g.Toggle(isDark.Get()).OnChange(func(dark bool) { isDark.Set(dark) @@ -74,7 +74,7 @@ var App = g.Define(func(s *g.Scope) g.View { g.Text("Small caption").Font(g.Caption).Color(g.Gray), g.Spacer(), - ).Padding(20) + ).Padding(g.SpaceLG) }) func main() { diff --git a/examples/todo/main.go b/examples/todo/main.go index 52fed49..22b6ed2 100644 --- a/examples/todo/main.go +++ b/examples/todo/main.go @@ -72,17 +72,19 @@ var TodoApp = g.Define(func(s *g.Scope) g.View { return toggleTodo(m, id, done) }) }), - g.Text(todo.Text), - g.Spacer(), + g.Text(todo.Text).Grow(), g.Button("X", func() { model.Update(func(m Model) Model { return removeTodo(m, id) }) }).Color(g.Red), - ) + ). + Spacing(g.SpaceSM). + PaddingH(g.SpaceLG). + PaddingV(g.SpaceSM) }, ), - ).Padding(g.SpaceLG), + ), ).Top( g.VStack( g.Text("Todos").Font(g.Title).Bold(), @@ -90,7 +92,6 @@ var TodoApp = g.Define(func(s *g.Scope) g.View { g.TextField(input). Placeholder("What needs to be done?"). OnSubmit(func(val string) { add() }). - MinHeight(36). Grow(), g.Button("Add", add), ).Spacing(g.SpaceSM), diff --git a/gova.go b/gova.go index b068f2f..5923326 100644 --- a/gova.go +++ b/gova.go @@ -81,16 +81,7 @@ func provideOverlays(s *Scope, a fyne.App, w fyne.Window, bridge *fyneBridge.Fyn } }, setTheme: func(t *Theme) { - tc := fyneBridge.ThemeConfig{ - Variant: int(t.Variant), - Accent: t.Accent, - } - if t.Colors != nil { - tc.Colors = make(map[int]color.Color, len(t.Colors)) - for k, v := range t.Colors { - tc.Colors[int(k)] = v - } - } + tc := buildThemeConfig(t) fyne.Do(func() { a.Settings().SetTheme(fyneBridge.NewTheme(tc)) }) @@ -106,19 +97,12 @@ func Run(title string, root View) { func RunWithConfig(config AppConfig, root View) { a := fyneApp.New() - if config.Theme != nil { - tc := fyneBridge.ThemeConfig{ - Variant: int(config.Theme.Variant), - Accent: config.Theme.Accent, - } - if config.Theme.Colors != nil { - tc.Colors = make(map[int]color.Color, len(config.Theme.Colors)) - for k, v := range config.Theme.Colors { - tc.Colors[int(k)] = v - } - } - a.Settings().SetTheme(fyneBridge.NewTheme(tc)) + activeTheme := config.Theme + if activeTheme == nil { + activeTheme = GovaTheme() + config.Theme = activeTheme } + a.Settings().SetTheme(fyneBridge.NewTheme(buildThemeConfig(activeTheme))) iconBytes := loadIconBytes(config) if len(iconBytes) > 0 { @@ -189,6 +173,26 @@ func RunWithConfig(config AppConfig, root View) { scope.destroy() } +func buildThemeConfig(t *Theme) fyneBridge.ThemeConfig { + tc := fyneBridge.ThemeConfig{ + Variant: int(t.Variant), + Accent: t.Accent, + } + if len(t.Colors) > 0 { + tc.Colors = make(map[int]color.Color, len(t.Colors)) + for k, v := range t.Colors { + tc.Colors[int(k)] = v + } + } + if len(t.Sizes) > 0 { + tc.Sizes = make(map[int]float32, len(t.Sizes)) + for k, v := range t.Sizes { + tc.Sizes[int(k)] = v + } + } + return tc +} + // loadIconBytes resolves AppConfig.Icon / IconBytes into a byte slice. // Returns nil on any error; a missing icon should never crash the app. func loadIconBytes(cfg AppConfig) []byte { diff --git a/internal/fyne/bridge.go b/internal/fyne/bridge.go index 23546cc..1278484 100644 --- a/internal/fyne/bridge.go +++ b/internal/fyne/bridge.go @@ -291,16 +291,19 @@ func fontSizeName(fontStyle int) fyne.ThemeSizeName { func (b *FyneBridge) mountText(spec ViewSpec) *MountedNode { if spec.HasColor { - ct := canvas.NewText(spec.TextValue, specColor(spec.TextColor)) - ct.TextSize = theme.Size(fontSizeName(spec.FontStyle)) - ct.TextStyle.Bold = spec.Bold - ct.TextStyle.Italic = spec.Italic - node := &MountedNode{Spec: spec, Object: ct} + ct := newColoredText( + spec.TextValue, + specColor(spec.TextColor), + fontSizeName(spec.FontStyle), + spec.Bold, + spec.Italic, + spec.NoWrap, + ) + node := &MountedNode{Spec: spec, Object: ct, Inner: ct} if spec.TextSubscribe != nil { unsub := spec.TextSubscribe(func(newText string) { fyne.Do(func() { - ct.Text = newText - ct.Refresh() + ct.setText(newText) }) }) node.Unsubs = append(node.Unsubs, unsub) @@ -317,7 +320,10 @@ func (b *FyneBridge) mountText(spec ViewSpec) *MountedNode { }, } rt := widget.NewRichText(seg) - node := &MountedNode{Spec: spec, Object: rt} + if !spec.NoWrap { + rt.Wrapping = fyne.TextWrapWord + } + node := &MountedNode{Spec: spec, Object: rt, Inner: rt} if spec.TextSubscribe != nil { unsub := spec.TextSubscribe(func(newText string) { fyne.Do(func() { @@ -337,7 +343,10 @@ func (b *FyneBridge) mountText(spec ViewSpec) *MountedNode { if spec.Italic { label.TextStyle.Italic = true } - node := &MountedNode{Spec: spec, Object: label} + if !spec.NoWrap { + label.Wrapping = fyne.TextWrapWord + } + node := &MountedNode{Spec: spec, Object: label, Inner: label} if spec.TextSubscribe != nil { unsub := spec.TextSubscribe(func(newText string) { fyne.Do(func() { @@ -675,18 +684,12 @@ func (b *FyneBridge) mountScroll(spec ViewSpec) *MountedNode { func (b *FyneBridge) reconcileText(m *MountedNode, s ViewSpec) { inner := m.innerObject() - if ct, ok := inner.(*canvas.Text); ok { - changed := false + if ct, ok := inner.(*coloredText); ok { if s.TextValue != m.Spec.TextValue && s.TextSubscribe == nil { - ct.Text = s.TextValue - changed = true + ct.setText(s.TextValue) } if s.HasColor && s.TextColor != m.Spec.TextColor { - ct.Color = specColor(s.TextColor) - changed = true - } - if changed { - ct.Refresh() + ct.setColor(specColor(s.TextColor)) } return } @@ -700,6 +703,14 @@ func (b *FyneBridge) reconcileText(m *MountedNode, s ViewSpec) { } } } + if s.NoWrap != m.Spec.NoWrap { + if s.NoWrap { + rt.Wrapping = fyne.TextWrapOff + } else { + rt.Wrapping = fyne.TextWrapWord + } + rt.Refresh() + } return } @@ -711,6 +722,14 @@ func (b *FyneBridge) reconcileText(m *MountedNode, s ViewSpec) { label.TextStyle.Bold = s.Bold label.Refresh() } + if s.NoWrap != m.Spec.NoWrap { + if s.NoWrap { + label.Wrapping = fyne.TextWrapOff + } else { + label.Wrapping = fyne.TextWrapWord + } + label.Refresh() + } } } diff --git a/internal/fyne/coloredtext.go b/internal/fyne/coloredtext.go new file mode 100644 index 0000000..fca43a5 --- /dev/null +++ b/internal/fyne/coloredtext.go @@ -0,0 +1,234 @@ +package fyneBridge + +import ( + "image/color" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +type coloredText struct { + widget.BaseWidget + + text string + color color.Color + bold bool + italic bool + sizeName fyne.ThemeSizeName + noWrap bool +} + +func newColoredText(text string, c color.Color, sizeName fyne.ThemeSizeName, bold, italic, noWrap bool) *coloredText { + w := &coloredText{ + text: text, + color: c, + bold: bold, + italic: italic, + sizeName: sizeName, + noWrap: noWrap, + } + w.ExtendBaseWidget(w) + return w +} + +func (t *coloredText) setText(s string) { + if t.text == s { + return + } + t.text = s + t.Refresh() +} + +func (t *coloredText) setColor(c color.Color) { + if sameColor(t.color, c) { + return + } + t.color = c + t.Refresh() +} + +func (t *coloredText) CreateRenderer() fyne.WidgetRenderer { + r := &coloredTextRenderer{owner: t} + r.rebuild(0) + return r +} + +func sameColor(a, b color.Color) bool { + if a == nil || b == nil { + return a == b + } + ar, ag, ab, aa := a.RGBA() + br, bg, bbl, ba := b.RGBA() + return ar == br && ag == bg && ab == bbl && aa == ba +} + +type coloredTextRenderer struct { + owner *coloredText + lines []*canvas.Text + width float32 +} + +func (r *coloredTextRenderer) textStyle() fyne.TextStyle { + return fyne.TextStyle{Bold: r.owner.bold, Italic: r.owner.italic} +} + +func (r *coloredTextRenderer) textSize() float32 { + th := theme.CurrentForWidget(r.owner) + if th == nil { + th = theme.Current() + } + sz := th.Size(r.owner.sizeName) + if sz <= 0 { + sz = th.Size(theme.SizeNameText) + } + return sz +} + +// rebuild produces the canvas.Text instances for the current text, color, +// and width. Reuses existing canvas.Text objects when possible so the +// renderer's Objects() identity stays stable across reflows. Pass +// width=0 when the parent hasn't allocated a size yet, we'll emit a +// single line in that case. +func (r *coloredTextRenderer) rebuild(width float32) { + r.width = width + style := r.textStyle() + sz := r.textSize() + + var texts []string + if r.owner.noWrap || width <= 0 { + texts = []string{r.owner.text} + } else { + texts = wrapLines(r.owner.text, width, sz, style) + } + + for len(r.lines) < len(texts) { + r.lines = append(r.lines, canvas.NewText("", r.owner.color)) + } + if len(r.lines) > len(texts) { + r.lines = r.lines[:len(texts)] + } + + for i, t := range texts { + ct := r.lines[i] + ct.Text = t + ct.TextSize = sz + ct.TextStyle = style + ct.Color = r.owner.color + } +} + +func (r *coloredTextRenderer) Layout(size fyne.Size) { + r.rebuild(size.Width) + + if len(r.lines) == 1 { + // Single-line fast path: behave like a bare canvas.Text. The + // canvas.Text gets the full widget size, and Fyne's painter will + // vertically center the glyph within that box (see drawText in + // fyne.io/fyne/v2/internal/painter/gl/draw.go). + ct := r.lines[0] + ct.Move(fyne.NewPos(0, 0)) + ct.Resize(size) + return + } + + // Multi-line wrap: stack each line at a font-derived stride so + // inter-line spacing matches what widget.Label produces. canvas.Text + // uses its natural MinSize.Height; we don't resize it, so the painter + // renders the glyph at its natural baseline within the line. + style := r.textStyle() + sz := r.textSize() + stride := fyne.MeasureText("Mg", sz, style).Height + for i, line := range r.lines { + line.Move(fyne.NewPos(0, float32(i)*stride)) + line.Resize(line.MinSize()) + } +} + +func (r *coloredTextRenderer) MinSize() fyne.Size { + style := r.textStyle() + sz := r.textSize() + + if r.owner.noWrap { + // Same min size a bare canvas.Text would report, letting HStack + // height fall to the natural text height keeps vertical centering + // consistent across colored and uncolored text. + return fyne.MeasureText(r.owner.text, sz, style) + } + + // Wrap mode. We must allow the parent to hand us less width than + // the full text would need, otherwise we'd never wrap. Reporting + // the longest single word width as the minimum lets the parent + // shrink us until that point; below it we still draw, just clipping. + longest := float32(0) + for _, w := range strings.Fields(r.owner.text) { + wm := fyne.MeasureText(w, sz, style).Width + if wm > longest { + longest = wm + } + } + if longest == 0 { + // No whitespace; treat as a single token whose width is the text. + longest = fyne.MeasureText(r.owner.text, sz, style).Width + } + + if r.width <= 0 || len(r.lines) <= 1 { + // Pre-layout, or text actually fits on one line. + return fyne.NewSize(longest, fyne.MeasureText(r.owner.text, sz, style).Height) + } + stride := fyne.MeasureText("Mg", sz, style).Height + return fyne.NewSize(longest, stride*float32(len(r.lines))) +} + +func (r *coloredTextRenderer) Refresh() { + r.rebuild(r.width) + canvas.Refresh(r.owner) +} + +func (r *coloredTextRenderer) Objects() []fyne.CanvasObject { + objs := make([]fyne.CanvasObject, len(r.lines)) + for i, l := range r.lines { + objs[i] = l + } + return objs +} + +func (r *coloredTextRenderer) Destroy() {} + +// wrapLines greedily packs words onto lines whose rendered width fits in +// the given pixel width. At least one word per line is guaranteed so a +// too-narrow container never produces zero-width lines (we'd rather show +// horizontal overflow than collapse the layout). +func wrapLines(text string, width, size float32, style fyne.TextStyle) []string { + words := strings.Fields(text) + if len(words) == 0 { + return []string{text} + } + + spaceW := fyne.MeasureText(" ", size, style).Width + var lines []string + var current []string + var currentW float32 + + for _, w := range words { + wW := fyne.MeasureText(w, size, style).Width + next := currentW + wW + if len(current) > 0 { + next += spaceW + } + if next <= width || len(current) == 0 { + current = append(current, w) + currentW = next + } else { + lines = append(lines, strings.Join(current, " ")) + current = []string{w} + currentW = wW + } + } + if len(current) > 0 { + lines = append(lines, strings.Join(current, " ")) + } + return lines +} diff --git a/internal/fyne/fonts/Inter-Bold.ttf b/internal/fyne/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/internal/fyne/fonts/Inter-Bold.ttf differ diff --git a/internal/fyne/fonts/Inter-LICENSE.txt b/internal/fyne/fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/internal/fyne/fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/internal/fyne/fonts/Inter-Medium.ttf b/internal/fyne/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/internal/fyne/fonts/Inter-Medium.ttf differ diff --git a/internal/fyne/fonts/Inter-Regular.ttf b/internal/fyne/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/internal/fyne/fonts/Inter-Regular.ttf differ diff --git a/internal/fyne/fonts/Inter-SemiBold.ttf b/internal/fyne/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/internal/fyne/fonts/Inter-SemiBold.ttf differ diff --git a/internal/fyne/fonts/fonts.go b/internal/fyne/fonts/fonts.go new file mode 100644 index 0000000..ccc104b --- /dev/null +++ b/internal/fyne/fonts/fonts.go @@ -0,0 +1,28 @@ +package fonts + +import ( + _ "embed" + + "fyne.io/fyne/v2" +) + +//go:embed Inter-Regular.ttf +var interRegular []byte + +//go:embed Inter-Medium.ttf +var interMedium []byte + +//go:embed Inter-SemiBold.ttf +var interSemiBold []byte + +//go:embed Inter-Bold.ttf +var interBold []byte + +// Cached resources: Fyne re-reads these on every Font() call so we hand +// out the same *StaticResource each time rather than allocating per call. +var ( + SansRegular = fyne.NewStaticResource("Inter-Regular.ttf", interRegular) + SansMedium = fyne.NewStaticResource("Inter-Medium.ttf", interMedium) + SansSemiBold = fyne.NewStaticResource("Inter-SemiBold.ttf", interSemiBold) + SansBold = fyne.NewStaticResource("Inter-Bold.ttf", interBold) +) diff --git a/internal/fyne/spec.go b/internal/fyne/spec.go index 22c96c3..7886de1 100644 --- a/internal/fyne/spec.go +++ b/internal/fyne/spec.go @@ -93,6 +93,7 @@ type ViewSpec struct { FontStyle int TextColor [4]uint8 HasColor bool + NoWrap bool PaddingTop, PaddingRight, PaddingBottom, PaddingLeft float32 HasPadding bool @@ -108,10 +109,10 @@ type ViewSpec struct { CornerRadius float32 HasCornerRadius bool - ShadowColor [4]uint8 - ShadowRadius float32 + ShadowColor [4]uint8 + ShadowRadius float32 ShadowOffsetX, ShadowOffsetY float32 - HasShadow bool + HasShadow bool StrokeColor [4]uint8 StrokeWidth float32 diff --git a/internal/fyne/theme.go b/internal/fyne/theme.go index a289821..2df98e2 100644 --- a/internal/fyne/theme.go +++ b/internal/fyne/theme.go @@ -5,16 +5,17 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" + + "github.com/nv404/gova/internal/fyne/fonts" ) type ThemeConfig struct { Variant int // 0=light, 1=dark, 2=system Accent color.Color - Colors map[int]color.Color // keyed by gova ThemeColorName + Colors map[int]color.Color + Sizes map[int]float32 } -// govaTheme implements fyne.Theme, delegating to user overrides and -// falling back to Fyne's default theme. type govaTheme struct { config ThemeConfig base fyne.Theme @@ -32,35 +33,103 @@ func NewTheme(config ThemeConfig) fyne.Theme { case 1: t.variant = theme.VariantDark default: - t.variant = 255 // sentinel: use system + t.variant = 255 // sentinel: pass through whatever Fyne reports } return t } -var colorNameMap = map[int]fyne.ThemeColorName{ - 0: theme.ColorNamePrimary, - 1: theme.ColorNameBackground, - 2: theme.ColorNameForeground, - 3: theme.ColorNameButton, - 4: theme.ColorNameError, - 5: theme.ColorNameSuccess, - 6: theme.ColorNameWarning, - 7: theme.ColorNameInputBackground, - 8: theme.ColorNameInputBorder, - 9: theme.ColorNameDisabled, - 10: theme.ColorNameFocus, - 11: theme.ColorNameSelection, - 12: theme.ColorNameHover, -} +const ( + govaColorPrimary = iota + govaColorBackground + govaColorForeground + govaColorButton + govaColorError + govaColorSuccess + govaColorWarning + govaColorInputBackground + govaColorInputBorder + govaColorDisabled + govaColorFocus + govaColorSelection + govaColorHover + govaColorSecondary + govaColorSurface + govaColorAccent + govaColorBorder + govaColorOnPrimary + govaColorOverlay + govaColorMenu + govaColorPlaceholder + govaColorPressed + govaColorHeader + govaColorSeparator + govaColorHyperlink + govaColorDisabledBG + govaColorOnError + govaColorOnSuccess + govaColorOnWarning +) -// reverse map: fyne color name → gova int (built once) -var fyneToGovaColor map[fyne.ThemeColorName]int +const ( + govaSizePadding = iota + govaSizeInnerPadding + govaSizeText + govaSizeHeadingText + govaSizeSubHeadingText + govaSizeCaptionText + govaSizeSeparatorThickness + govaSizeInputBorder + govaSizeInputRadius + govaSizeSelectionRadius + govaSizeScrollBar + govaSizeScrollBarRadius + govaSizeIconInline + govaSizeLineSpacing +) -func init() { - fyneToGovaColor = make(map[fyne.ThemeColorName]int, len(colorNameMap)) - for k, v := range colorNameMap { - fyneToGovaColor[v] = k - } +var fyneToGovaColor = map[fyne.ThemeColorName]int{ + theme.ColorNamePrimary: govaColorPrimary, + theme.ColorNameBackground: govaColorBackground, + theme.ColorNameForeground: govaColorForeground, + theme.ColorNameButton: govaColorButton, + theme.ColorNameError: govaColorError, + theme.ColorNameSuccess: govaColorSuccess, + theme.ColorNameWarning: govaColorWarning, + theme.ColorNameInputBackground: govaColorInputBackground, + theme.ColorNameInputBorder: govaColorInputBorder, + theme.ColorNameDisabled: govaColorDisabled, + theme.ColorNameFocus: govaColorFocus, + theme.ColorNameSelection: govaColorSelection, + theme.ColorNameHover: govaColorHover, + theme.ColorNameMenuBackground: govaColorMenu, + theme.ColorNameOverlayBackground: govaColorOverlay, + theme.ColorNamePlaceHolder: govaColorPlaceholder, + theme.ColorNamePressed: govaColorPressed, + theme.ColorNameHeaderBackground: govaColorHeader, + theme.ColorNameSeparator: govaColorSeparator, + theme.ColorNameHyperlink: govaColorHyperlink, + theme.ColorNameDisabledButton: govaColorDisabledBG, + theme.ColorNameForegroundOnPrimary: govaColorOnPrimary, + theme.ColorNameForegroundOnError: govaColorOnError, + theme.ColorNameForegroundOnSuccess: govaColorOnSuccess, + theme.ColorNameForegroundOnWarning: govaColorOnWarning, +} + +var fyneToGovaSize = map[fyne.ThemeSizeName]int{ + theme.SizeNamePadding: govaSizePadding, + theme.SizeNameInnerPadding: govaSizeInnerPadding, + theme.SizeNameText: govaSizeText, + theme.SizeNameHeadingText: govaSizeHeadingText, + theme.SizeNameSubHeadingText: govaSizeSubHeadingText, + theme.SizeNameCaptionText: govaSizeCaptionText, + theme.SizeNameSeparatorThickness: govaSizeSeparatorThickness, + theme.SizeNameInputBorder: govaSizeInputBorder, + theme.SizeNameInputRadius: govaSizeInputRadius, + theme.SizeNameSelectionRadius: govaSizeSelectionRadius, + theme.SizeNameScrollBar: govaSizeScrollBar, + theme.SizeNameScrollBarRadius: govaSizeScrollBarRadius, + theme.SizeNameInlineIcon: govaSizeIconInline, + theme.SizeNameLineSpacing: govaSizeLineSpacing, } func (t *govaTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { @@ -68,27 +137,184 @@ func (t *govaTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) c variant = t.variant } - if govaName, ok := fyneToGovaColor[name]; ok { + govaName, mapped := fyneToGovaColor[name] + if mapped { if c, ok := t.config.Colors[govaName]; ok { return c } + // Accent overrides Primary specifically — buttons, focus, etc. + if name == theme.ColorNamePrimary && t.config.Accent != nil { + return t.config.Accent + } + if c := govaColor(govaName, variant); c != nil { + return c + } } - if name == theme.ColorNamePrimary && t.config.Accent != nil { - return t.config.Accent - } - + // Fall through: tokens we don't have a direct opinion on (scrollbar + // fill, shadow, etc.) borrow from Fyne's default. return t.base.Color(name, variant) } +func (t *govaTheme) Size(name fyne.ThemeSizeName) float32 { + govaName, mapped := fyneToGovaSize[name] + if mapped { + if s, ok := t.config.Sizes[govaName]; ok { + return s + } + if s, ok := govaSize(govaName); ok { + return s + } + } + return t.base.Size(name) +} + func (t *govaTheme) Font(style fyne.TextStyle) fyne.Resource { - return t.base.Font(style) + switch { + case style.Bold: + return fonts.SansSemiBold + case style.Italic: + return fonts.SansRegular + } + return fonts.SansRegular } func (t *govaTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) } -func (t *govaTheme) Size(name fyne.ThemeSizeName) float32 { - return t.base.Size(name) +func govaColor(token int, variant fyne.ThemeVariant) color.Color { + if variant == theme.VariantLight { + return govaLight(token) + } + return govaDark(token) +} + +func govaDark(token int) color.Color { + switch token { + case govaColorBackground: + return rgb(0x0F, 0x0F, 0x0F) + case govaColorSurface, govaColorInputBackground: + return rgb(0x17, 0x17, 0x17) + case govaColorMenu: + return rgb(0x1A, 0x1A, 0x1A) + case govaColorOverlay: + return rgba(0x0F, 0x0F, 0x0F, 0xF2) + case govaColorHeader: + return rgb(0x0F, 0x0F, 0x0F) + case govaColorButton: + return rgb(0x27, 0x27, 0x27) + case govaColorDisabledBG: + return rgb(0x1F, 0x1F, 0x1F) + case govaColorBorder, govaColorSeparator, govaColorInputBorder: + return rgb(0x27, 0x27, 0x27) + case govaColorForeground, govaColorPrimary, govaColorAccent, govaColorHyperlink: + return rgb(0xF1, 0xF1, 0xF1) + case govaColorOnPrimary: + return rgb(0x0F, 0x0F, 0x0F) + case govaColorSecondary: + return rgb(0xA3, 0xA3, 0xA3) + case govaColorPlaceholder: + return rgb(0x73, 0x73, 0x73) + case govaColorDisabled: + return rgb(0x5C, 0x5C, 0x5C) + case govaColorHover: + return rgba(0xFF, 0xFF, 0xFF, 0x14) + case govaColorPressed: + return rgba(0xFF, 0xFF, 0xFF, 0x1F) + case govaColorFocus: + return rgba(0xF1, 0xF1, 0xF1, 0x59) + case govaColorSelection: + return rgba(0xF1, 0xF1, 0xF1, 0x29) + case govaColorError: + return rgb(0xE5, 0x48, 0x4D) + case govaColorSuccess: + return rgb(0x46, 0xA7, 0x58) + case govaColorWarning: + return rgb(0xE5, 0xA2, 0x3B) + case govaColorOnError, govaColorOnSuccess, govaColorOnWarning: + return rgb(0xF1, 0xF1, 0xF1) + } + return nil +} + +func govaLight(token int) color.Color { + switch token { + case govaColorBackground: + return rgb(0xF1, 0xF1, 0xF1) + case govaColorSurface, govaColorInputBackground, govaColorMenu: + return rgb(0xFF, 0xFF, 0xFF) + case govaColorOverlay: + return rgba(0xFF, 0xFF, 0xFF, 0xF2) + case govaColorHeader: + return rgb(0xF1, 0xF1, 0xF1) + case govaColorButton: + return rgb(0xE5, 0xE5, 0xE5) + case govaColorDisabledBG: + return rgb(0xEB, 0xEB, 0xEB) + case govaColorBorder, govaColorSeparator, govaColorInputBorder: + return rgb(0xD4, 0xD4, 0xD4) + case govaColorForeground, govaColorPrimary, govaColorAccent, govaColorHyperlink: + return rgb(0x0F, 0x0F, 0x0F) + case govaColorOnPrimary: + return rgb(0xF1, 0xF1, 0xF1) + case govaColorSecondary, govaColorPlaceholder: + return rgb(0x73, 0x73, 0x73) + case govaColorDisabled: + return rgb(0xA3, 0xA3, 0xA3) + case govaColorHover: + return rgba(0x00, 0x00, 0x00, 0x0A) + case govaColorPressed: + return rgba(0x00, 0x00, 0x00, 0x14) + case govaColorFocus: + return rgba(0x0F, 0x0F, 0x0F, 0x59) + case govaColorSelection: + return rgba(0x0F, 0x0F, 0x0F, 0x29) + case govaColorError: + return rgb(0xCD, 0x2B, 0x31) + case govaColorSuccess: + return rgb(0x2A, 0x7E, 0x3B) + case govaColorWarning: + return rgb(0xAD, 0x57, 0x00) + case govaColorOnError, govaColorOnSuccess, govaColorOnWarning: + return rgb(0xF1, 0xF1, 0xF1) + } + return nil } + +func govaSize(token int) (float32, bool) { + switch token { + case govaSizePadding: + return 6, true + case govaSizeInnerPadding: + return 7, true + case govaSizeText: + return 13, true + case govaSizeHeadingText: + return 19, true + case govaSizeSubHeadingText: + return 15, true + case govaSizeCaptionText: + return 11, true + case govaSizeSeparatorThickness: + return 1, true + case govaSizeInputBorder: + return 1, true + case govaSizeInputRadius: + return 6, true + case govaSizeSelectionRadius: + return 4, true + case govaSizeScrollBar: + return 8, true + case govaSizeScrollBarRadius: + return 4, true + case govaSizeIconInline: + return 14, true + case govaSizeLineSpacing: + return 3, true + } + return 0, false +} + +func rgb(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0xFF} } +func rgba(r, g, b, a uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: a} } diff --git a/modifier.go b/modifier.go index 8d08feb..bc48d24 100644 --- a/modifier.go +++ b/modifier.go @@ -27,6 +27,7 @@ type modifierSet struct { hasNavTitle bool navToolbar *navToolbarData grow bool + noWrap bool } type paddingModifier struct { @@ -250,6 +251,11 @@ func (n *viewNode) Grow() *viewNode { return n } +func (n *viewNode) NoWrap() *viewNode { + n.modifier.noWrap = true + return n +} + // OnTap makes any view tappable. Panics if applied to a Button (which // already has a handler via its constructor). func (n *viewNode) OnTap(fn func()) *viewNode { diff --git a/render.go b/render.go index b57729e..9ea0cc7 100644 --- a/render.go +++ b/render.go @@ -311,6 +311,7 @@ func toSpecWithScope(node *viewNode, scope *Scope) fyneBridge.ViewSpec { } } spec.Grow = node.modifier.grow + spec.NoWrap = node.modifier.noWrap return spec } @@ -412,9 +413,8 @@ func navStackSpec(node *viewNode, scope *Scope, theme *Theme) fyneBridge.ViewSpe return fyneBridge.ViewSpec{Kind: fyneBridge.KindGroup} } - // Resolve the top view (it may be a component) to capture its nav modifiers. - topSpec := renderSlot(top, scope, "nav:top") - resolvedTop := resolveNavModifiers(top, scope) + topScope := scope.childScopeFor("nav:top") + resolvedTop, topSpec := renderTopForNav(top, topScope) barTitle := "" var toolbar *navToolbarData @@ -450,13 +450,19 @@ func navStackSpec(node *viewNode, scope *Scope, theme *Theme) fyneBridge.ViewSpe } } -// resolveNavModifiers walks a (possibly component) node to find the final -// viewNode whose modifiers carry NavTitle / NavToolbar. -func resolveNavModifiers(node *viewNode, scope *Scope) *viewNode { +func renderTopForNav(node *viewNode, topScope *Scope) (*viewNode, fyneBridge.ViewSpec) { cur := node - for cur != nil && cur.componentRef != nil && scope != nil { - rendered := renderComponent(cur.componentRef, scope) + curScope := topScope + for cur != nil && cur.componentRef != nil && curScope != nil { + rendered := renderComponent(cur.componentRef, curScope) cur = rendered.viewNode() + // If Body returned another component, descend with a stable + // nested-scope key so state below the boundary persists across + // renders. Most apps wrap a single Viewable per nav frame, so + // this loop typically runs once. + if cur != nil && cur.componentRef != nil { + curScope = curScope.childScopeFor("nav:nested") + } } - return cur + return cur, toSpecWithScope(cur, curScope) } diff --git a/style.go b/style.go index 5493d57..505fd90 100644 --- a/style.go +++ b/style.go @@ -30,6 +30,37 @@ const ( ColorSurface ColorAccent ColorBorder + ColorOnPrimary + ColorOverlay + ColorMenu + ColorPlaceholder + ColorPressed + ColorHeader + ColorSeparator + ColorHyperlink + ColorDisabledBG + ColorOnError + ColorOnSuccess + ColorOnWarning +) + +type ThemeSizeName int + +const ( + SizePadding ThemeSizeName = iota + SizeInnerPadding + SizeText + SizeHeadingText + SizeSubHeadingText + SizeCaptionText + SizeSeparatorThickness + SizeInputBorder + SizeInputRadius + SizeSelectionRadius + SizeScrollBar + SizeScrollBarRadius + SizeIconInline + SizeLineSpacing ) // themeColor is a sentinel value for a theme-resolved color. It implements @@ -80,61 +111,141 @@ func resolveColor(c any, theme *Theme) color.Color { } func defaultSemantic(name ThemeColorName, theme *Theme) color.Color { - dark := theme != nil && theme.Variant == ThemeDark + dark := theme == nil || theme.Variant != ThemeLight + if dark { + return govaDarkColor(name) + } + return govaLightColor(name) +} + +func govaDarkColor(name ThemeColorName) color.Color { switch name { - case ColorForeground, ColorPrimary: - if dark { - return color.NRGBA{R: 240, G: 240, B: 240, A: 255} - } - return color.NRGBA{R: 20, G: 20, B: 20, A: 255} + case ColorBackground: + return rgb(0x0F, 0x0F, 0x0F) + case ColorSurface: + return rgb(0x17, 0x17, 0x17) + case ColorInputBackground: + return rgb(0x17, 0x17, 0x17) + case ColorMenu: + return rgb(0x1A, 0x1A, 0x1A) + case ColorOverlay: + return rgba(0x0F, 0x0F, 0x0F, 0xF2) + case ColorHeader: + return rgb(0x0F, 0x0F, 0x0F) + case ColorButton: + return rgb(0x27, 0x27, 0x27) + case ColorDisabledBG: + return rgb(0x1F, 0x1F, 0x1F) + case ColorBorder, ColorSeparator, ColorInputBorder: + return rgb(0x27, 0x27, 0x27) + case ColorForeground, ColorPrimary, ColorAccent, ColorHyperlink: + return rgb(0xF1, 0xF1, 0xF1) + case ColorOnPrimary: + return rgb(0x0F, 0x0F, 0x0F) case ColorSecondary: - if dark { - return color.NRGBA{R: 170, G: 170, B: 170, A: 255} - } - return color.NRGBA{R: 110, G: 110, B: 110, A: 255} + return rgb(0xA3, 0xA3, 0xA3) + case ColorPlaceholder: + return rgb(0x73, 0x73, 0x73) + case ColorDisabled: + return rgb(0x5C, 0x5C, 0x5C) + case ColorHover: + return rgba(0xFF, 0xFF, 0xFF, 0x14) + case ColorPressed: + return rgba(0xFF, 0xFF, 0xFF, 0x1F) + case ColorFocus: + return rgba(0xF1, 0xF1, 0xF1, 0x59) + case ColorSelection: + return rgba(0xF1, 0xF1, 0xF1, 0x29) + case ColorError: + return rgb(0xE5, 0x48, 0x4D) + case ColorSuccess: + return rgb(0x46, 0xA7, 0x58) + case ColorWarning: + return rgb(0xE5, 0xA2, 0x3B) + case ColorOnError, ColorOnSuccess, ColorOnWarning: + return rgb(0xF1, 0xF1, 0xF1) + } + return color.Black +} + +func govaLightColor(name ThemeColorName) color.Color { + switch name { case ColorBackground: - if dark { - return color.NRGBA{R: 20, G: 20, B: 22, A: 255} - } - return color.NRGBA{R: 250, G: 250, B: 250, A: 255} + return rgb(0xF1, 0xF1, 0xF1) case ColorSurface: - if dark { - return color.NRGBA{R: 40, G: 40, B: 44, A: 255} - } - return color.NRGBA{R: 255, G: 255, B: 255, A: 255} - case ColorAccent: - return color.NRGBA{R: 0, G: 122, B: 255, A: 255} + return rgb(0xFF, 0xFF, 0xFF) + case ColorInputBackground: + return rgb(0xFF, 0xFF, 0xFF) + case ColorMenu: + return rgb(0xFF, 0xFF, 0xFF) + case ColorOverlay: + return rgba(0xFF, 0xFF, 0xFF, 0xF2) + case ColorHeader: + return rgb(0xF1, 0xF1, 0xF1) + case ColorButton: + return rgb(0xE5, 0xE5, 0xE5) + case ColorDisabledBG: + return rgb(0xEB, 0xEB, 0xEB) + case ColorBorder, ColorSeparator, ColorInputBorder: + return rgb(0xD4, 0xD4, 0xD4) + case ColorForeground, ColorPrimary, ColorAccent, ColorHyperlink: + return rgb(0x0F, 0x0F, 0x0F) + case ColorOnPrimary: + return rgb(0xF1, 0xF1, 0xF1) + case ColorSecondary: + return rgb(0x73, 0x73, 0x73) + case ColorPlaceholder: + return rgb(0x73, 0x73, 0x73) + case ColorDisabled: + return rgb(0xA3, 0xA3, 0xA3) + case ColorHover: + return rgba(0x00, 0x00, 0x00, 0x0A) + case ColorPressed: + return rgba(0x00, 0x00, 0x00, 0x14) + case ColorFocus: + return rgba(0x0F, 0x0F, 0x0F, 0x59) + case ColorSelection: + return rgba(0x0F, 0x0F, 0x0F, 0x29) case ColorError: - return color.NRGBA{R: 255, G: 59, B: 48, A: 255} + return rgb(0xCD, 0x2B, 0x31) case ColorSuccess: - return color.NRGBA{R: 52, G: 199, B: 89, A: 255} + return rgb(0x2A, 0x7E, 0x3B) case ColorWarning: - return color.NRGBA{R: 255, G: 149, B: 0, A: 255} - case ColorBorder: - if dark { - return color.NRGBA{R: 70, G: 70, B: 75, A: 255} - } - return color.NRGBA{R: 200, G: 200, B: 205, A: 255} + return rgb(0xAD, 0x57, 0x00) + case ColorOnError, ColorOnSuccess, ColorOnWarning: + return rgb(0xF1, 0xF1, 0xF1) } return color.Black } +func rgb(r, g, b uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: 0xFF} } +func rgba(r, g, b, a uint8) color.Color { return color.NRGBA{R: r, G: g, B: b, A: a} } + type Theme struct { Variant ThemeVariant Accent color.Color Colors map[ThemeColorName]color.Color + Sizes map[ThemeSizeName]float32 } func LightTheme() *Theme { - return &Theme{Variant: ThemeLight, Colors: make(map[ThemeColorName]color.Color)} + return &Theme{Variant: ThemeLight} } func DarkTheme() *Theme { - return &Theme{Variant: ThemeDark, Colors: make(map[ThemeColorName]color.Color)} + return &Theme{Variant: ThemeDark} } func SystemTheme() *Theme { - return &Theme{Variant: ThemeSystem, Colors: make(map[ThemeColorName]color.Color)} + return &Theme{Variant: ThemeSystem} +} + +func GovaTheme() *Theme { + return &Theme{Variant: ThemeDark} +} + +func GovaLightTheme() *Theme { + return &Theme{Variant: ThemeLight} } func (t *Theme) WithAccent(c color.Color) *Theme { @@ -150,6 +261,14 @@ func (t *Theme) SetColor(name ThemeColorName, c color.Color) *Theme { return t } +func (t *Theme) SetSize(name ThemeSizeName, v float32) *Theme { + if t.Sizes == nil { + t.Sizes = make(map[ThemeSizeName]float32) + } + t.Sizes[name] = v + return t +} + // FadeColor returns c with its alpha channel multiplied by alpha (0..1). // Useful for hand-rolled fade animations: pick a base color, drive a // float32 from UseAnimation, and pass FadeColor(base, progress) as the