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) }
```
-
-
+
+
+
## 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}
+
+
+ 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 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).
+
+
+
+
+
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 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