diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35b7e255..93c2254a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -284,7 +284,7 @@ jobs:
# native structure ABI tests are here for the same reason: they build only
# on Windows.
- name: Material contracts and controls (WARP)
- run: go test ./internal/platform/windows ./ui ./components ./headless ./app -run 'Test(Backdrop|WindowBackdrop|Notification|TaskbarCreated|CompositionClear|CompositionFallback|CompositionCreation|CustomBackdrop|AcrylicAccent|LegacyAcrylic|RoundedOutline|Translucent|MonospaceFamilies|AlwaysOnTop|SaveDialogCheckbox|FileIcon|NativeStructureABI)' -count=1 -v
+ run: go test ./internal/platform/windows ./ui ./components ./headless ./app -run 'Test(Backdrop|WindowBackdrop|Notification|TaskbarCreated|CompositionClear|CompositionFallback|CompositionCreation|CustomBackdrop|AcrylicAccent|LegacyAcrylic|RoundedOutline|Translucent|MonospaceFamilies|EngineReportsTheFontsUnderline|AlwaysOnTop|SaveDialogCheckbox|FileIcon|NativeStructureABI)' -count=1 -v
- name: Terminal keyboard routing
run: go test ./internal/platform/windows -run '^TestATerminalGetsEditingKeysAndChordsAsRawKeys$' -count=1 -v
- name: Native Windows global shortcut
diff --git a/README.md b/README.md
index c55c24d3..4cf42a9b 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ Viva reaches Cocoa and Metal through cgo; on Linux, X11 with the system XCB and
Vulkan loaders; on Windows, Win32 with Direct3D 11 and DirectWrite. Viva loads
the system libraries at runtime and bundles none of them.
-The current release is `v0.6.1`; name it explicitly (`@v0.6.1`) to pin it. Guides,
+The current release is `v0.6.3`; name it explicitly (`@v0.6.3`) to pin it. Guides,
the API reference, and a component catalog with live previews live in
[`website/`](website/); run it with `npm run dev` there, or read them on
[vivaui.dev](https://vivaui.dev/en/), which is also available in
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 77a137ce..c3237e9c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -34,7 +34,7 @@ viva dev . # hot reload: save a file, the window updates and keeps its st
环境要求:Go 1.26 或更新版本(缺失时 viva 会自己下载)。macOS 上需要 Xcode 命令行工具,因为 Viva 通过 cgo 访问 Cocoa 和 Metal;Linux 上需要带系统 XCB 和 Vulkan 加载库的 X11;Windows 上需要 Win32、Direct3D 11 和 DirectWrite。Viva 在运行时加载系统库,自身不捆绑任何一个。
-当前版本是 `v0.6.1`;要固定版本就写明(`@v0.6.1`)。指南、API 参考和带实时预览的组件目录在 [`website/`](website/) 里,本地用 `npm run dev` 运行;也已经发布在 [vivaui.dev](https://vivaui.dev/zh/)。
+当前版本是 `v0.6.3`;要固定版本就写明(`@v0.6.3`)。指南、API 参考和带实时预览的组件目录在 [`website/`](website/) 里,本地用 `npm run dev` 运行;也已经发布在 [vivaui.dev](https://vivaui.dev/zh/)。
下面的一切描述的都是这个仓库:它的测量、它自己的程序,以及如何开发框架本身,而不是如何用框架。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index a159bf81..62324ea1 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -260,3 +260,54 @@ The documentation site's JavaScript and CSS dependencies are pinned in
`website/package-lock.json`. Their license texts are part of the packages
installed from that lockfile; a deployed build should carry those notices in
its own license page.
+
+### github.com/gabstv/go-bsdiff
+
+The MIT License (MIT)
+
+Copyright (c) 2019 Gabriel Ochsenhofer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+### github.com/dsnet/compress
+
+Copyright © 2015, Joe Tsai and The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+* Neither the copyright holder nor the names of its contributors may be used
+to endorse or promote products derived from this software without specific
+prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/app/accessnodes.go b/app/accessnodes.go
new file mode 100644
index 00000000..87093ee5
--- /dev/null
+++ b/app/accessnodes.go
@@ -0,0 +1,136 @@
+package app
+
+import (
+ "github.com/vivaui/viva/gfx"
+ "github.com/vivaui/viva/internal/semantic"
+)
+
+// AccessNode is what assistive technology sees of one control in a frame: the
+// same information the platform adapters send to the system, without the
+// platform. Driver.Accessibility returns the last frame's tree.
+type AccessNode struct {
+ // ID names the control, as the hit it was made from does.
+ ID gfx.ID
+ // Role is the control's role; zero when it is a text without one.
+ Role gfx.Role
+ // Bounds is where the control was drawn, in logical pixels.
+ Bounds gfx.Rect
+
+ // Label is the accessible name, Value the control's value and Hint the
+ // short instruction usually read after the name.
+ Label string
+ Value string
+ Hint string
+ // Description is a longer explanation than Hint.
+ Description string
+
+ Focused bool
+ Disabled bool
+ Checked bool
+ Mixed bool
+ Selected bool
+ Expanded bool
+ Multiline bool
+ ReadOnly bool
+ Secure bool
+ Focusable bool
+ // Expandable marks a control that opens and closes something, whatever its
+ // state now.
+ Expandable bool
+
+ // Level is a heading's level or a tree item's depth; zero when not stated.
+ Level uint8
+ // PosInSet and SetSize place the node among its siblings, counting from 1.
+ PosInSet, SetSize int32
+ // Live marks a region that announces changes to its content.
+ Live gfx.LiveRegion
+ // Range is a slider's or progress bar's number.
+ Range gfx.ValueRange
+ // Selection is the selected span of a focused text field's Value.
+ Selection AccessSelection
+}
+
+// AccessSelection is a span of a text node's Value in Unicode code points:
+// Start and End are the anchor and the caret, so End < Start for a selection
+// made backwards. Valid is false when the node has no caret.
+type AccessSelection struct {
+ Start, End int32
+ Valid bool
+}
+
+// Accessibility returns the accessibility tree of the last painted frame, in
+// reading order. It is safe to call from the goroutine that drives the Driver
+// (the main thread in a test), as Snapshot is.
+func (driver *Driver) Accessibility() []AccessNode {
+ if driver == nil || driver.painter == nil {
+ return nil
+ }
+ nodes := driver.painter.accessibility
+ if len(nodes) == 0 {
+ return nil
+ }
+ out := make([]AccessNode, len(nodes))
+ for index, node := range nodes {
+ out[index] = AccessNode{
+ ID: node.ID, Role: accessRole(semantic.Role(node.Role)),
+ Bounds: gfx.Rect{X: node.X, Y: node.Y, Width: node.Width, Height: node.Height},
+ Label: node.Label, Value: node.Value, Hint: node.Hint, Description: node.Description,
+ Focused: node.Focused, Disabled: node.Disabled, Checked: node.Checked, Mixed: node.Mixed,
+ Selected: node.Selected, Expanded: node.Expanded, Multiline: node.Multiline,
+ ReadOnly: node.ReadOnly, Secure: node.Secure, Focusable: node.Focusable,
+ Expandable: node.Expandable, Level: node.Level, PosInSet: node.PosInSet, SetSize: node.SetSize,
+ Live: node.Live, Range: node.Range,
+ Selection: AccessSelection{Start: node.Selection.Start, End: node.Selection.End, Valid: node.Selection.Valid},
+ }
+ }
+ return out
+}
+
+// accessRole maps the semantic role the platform adapters receive back to the
+// gfx.Role the application declared. Roles that have no gfx counterpart (a
+// text, an image, the application itself) are zero.
+func accessRole(role semantic.Role) gfx.Role {
+ switch role {
+ case semantic.RoleButton:
+ return gfx.RoleButton
+ case semantic.RoleLink:
+ return gfx.RoleLink
+ case semantic.RoleCheckbox:
+ return gfx.RoleCheckbox
+ case semantic.RoleRadio:
+ return gfx.RoleRadio
+ case semantic.RoleTab:
+ return gfx.RoleTab
+ case semantic.RoleMenuItem:
+ return gfx.RoleMenuItem
+ case semantic.RoleCombobox:
+ return gfx.RoleCombobox
+ case semantic.RoleSwitch:
+ return gfx.RoleSwitch
+ case semantic.RoleTextField:
+ return gfx.RoleTextField
+ case semantic.RoleSlider:
+ return gfx.RoleSlider
+ case semantic.RoleGroup:
+ return gfx.RoleGroup
+ case semantic.RoleMenu:
+ return gfx.RoleMenu
+ case semantic.RoleMenubar:
+ return gfx.RoleMenubar
+ case semantic.RoleListBox:
+ return gfx.RoleListBox
+ case semantic.RoleOption:
+ return gfx.RoleOption
+ case semantic.RoleGrid:
+ return gfx.RoleGrid
+ case semantic.RoleGridCell:
+ return gfx.RoleGridCell
+ case semantic.RoleHeading:
+ return gfx.RoleHeading
+ case semantic.RoleStatus:
+ return gfx.RoleStatus
+ case semantic.RoleSeparator:
+ return gfx.RoleSeparator
+ }
+ return 0
+}
diff --git a/app/app.go b/app/app.go
index 672b5c2b..6b1beb0e 100644
--- a/app/app.go
+++ b/app/app.go
@@ -81,6 +81,10 @@ type Config struct {
// that. Zero means 28,
// the system titlebar's own height. It only applies to WindowCustomTitlebar.
TitlebarHeight float32
+ // TrafficLightScale scales native macOS traffic lights in a custom titlebar.
+ // Values in (0, 1] shrink the controls around their original centers; zero
+ // preserves the system size. Other platforms ignore this setting.
+ TrafficLightScale float32
// WindowControls styles the minimize, maximize and close buttons Viva draws at
// the trailing end of a WindowCustomTitlebar's strip on Windows and Linux,
// where the system draws none for a window without a titlebar; macOS has its
diff --git a/app/application.go b/app/application.go
index 10e77c48..df70dc9e 100644
--- a/app/application.go
+++ b/app/application.go
@@ -20,6 +20,7 @@ var ErrNoWindows = errors.New("app: application opened no windows")
// and Fullscreen() bool. It reports transitions through Config.OnFullscreen
// before resize/frame callbacks and repaints afterward.
// An always-on-top host additionally implements SetAlwaysOnTop(bool) error.
+// A size-capable host additionally implements SetSize(int, int) error.
type WindowControl interface {
Close()
Hide()
diff --git a/app/capabilities.go b/app/capabilities.go
index a8a642f1..56761c77 100644
--- a/app/capabilities.go
+++ b/app/capabilities.go
@@ -14,6 +14,7 @@ const (
CapabilityClipboardImage
CapabilityClipboardImageAsync
CapabilityClipboardFiles
+ CapabilityClipboardHTML
CapabilityFileDialogs
CapabilityFileDialogsAsync
CapabilityDirectoryDialogs
diff --git a/app/capabilities_native.go b/app/capabilities_native.go
index ab82c3d4..5cb9a289 100644
--- a/app/capabilities_native.go
+++ b/app/capabilities_native.go
@@ -13,6 +13,7 @@ func nativeCapabilities() Capabilities {
CapabilityClipboardImage |
CapabilityClipboardImageAsync |
CapabilityClipboardFiles |
+ CapabilityClipboardHTML |
CapabilityFileDialogs |
CapabilityFileDialogsAsync |
CapabilityDirectoryDialogs |
diff --git a/app/capabilities_web.go b/app/capabilities_web.go
index be9fd61e..8b6468fb 100644
--- a/app/capabilities_web.go
+++ b/app/capabilities_web.go
@@ -6,6 +6,7 @@ func nativeCapabilities() Capabilities {
return Capabilities(
CapabilityFileDrop |
CapabilityClipboardTextAsync |
+ CapabilityClipboardHTML |
CapabilityClipboardImageAsync |
CapabilityFileDialogsAsync |
CapabilitySecretStoreAsync |
diff --git a/app/clipboard_html.go b/app/clipboard_html.go
new file mode 100644
index 00000000..5042a22d
--- /dev/null
+++ b/app/clipboard_html.go
@@ -0,0 +1,37 @@
+package app
+
+import (
+ "fmt"
+ "strings"
+)
+
+// SetClipboardHTML makes the system clipboard hold html as its rich flavour and
+// alt as its plain one, in a single write, for a copy a reader may take either
+// way: a query result that pastes with its colours into a document and as text
+// into an editor. alt is what plain-text readers get — deriving it from html is
+// the caller's job, not the clipboard's.
+//
+// The flavours are what each platform calls rich text: NSPasteboard's
+// public.html beside public.utf8-plain-text, Windows' registered "HTML Format"
+// (CF_HTML) with the plain text as CF_UNICODETEXT, and the CLIPBOARD
+// selection's text/html target beside UTF8_STRING. Nothing is written when
+// either string contains NUL; an empty html writes alt as plain text only, with
+// exactly SetClipboardText's shape, so the degradation is the copy the caller
+// already spelled out. The Web target cannot answer a synchronous write
+// (Clipboard.write resolves a Promise) and returns ErrAsyncRequired;
+// SetClipboardHTMLAsync writes both flavours there.
+func SetClipboardHTML(html, alt string) error {
+ if strings.IndexByte(html, 0) >= 0 {
+ return fmt.Errorf("app: clipboard html contains NUL")
+ }
+ if strings.IndexByte(alt, 0) >= 0 {
+ return fmt.Errorf("app: clipboard text contains NUL")
+ }
+ if html == "" {
+ return SetClipboardText(alt)
+ }
+ if err, handled := driverSetClipboardHTML(html, alt); handled {
+ return err
+ }
+ return nativeSetClipboardHTML(html, alt)
+}
diff --git a/app/clipboard_html_darwin.go b/app/clipboard_html_darwin.go
new file mode 100644
index 00000000..2d2a9346
--- /dev/null
+++ b/app/clipboard_html_darwin.go
@@ -0,0 +1,9 @@
+//go:build darwin
+
+package app
+
+import darwinplatform "github.com/vivaui/viva/internal/platform/darwin"
+
+func nativeSetClipboardHTML(html, alt string) error {
+ return darwinplatform.SetClipboardHTML(html, alt)
+}
diff --git a/app/clipboard_html_js.go b/app/clipboard_html_js.go
new file mode 100644
index 00000000..f26268f8
--- /dev/null
+++ b/app/clipboard_html_js.go
@@ -0,0 +1,9 @@
+//go:build js && wasm
+
+package app
+
+import webplatform "github.com/vivaui/viva/internal/platform/web"
+
+func nativeSetClipboardHTML(html, alt string) error {
+ return webSystemError("write clipboard html", webplatform.SetClipboardHTML(html, alt))
+}
diff --git a/app/clipboard_html_linux.go b/app/clipboard_html_linux.go
new file mode 100644
index 00000000..b3e171b0
--- /dev/null
+++ b/app/clipboard_html_linux.go
@@ -0,0 +1,9 @@
+//go:build linux
+
+package app
+
+import linuxplatform "github.com/vivaui/viva/internal/platform/linux"
+
+func nativeSetClipboardHTML(html, alt string) error {
+ return linuxSystemError(linuxplatform.SetClipboardHTML(html, alt))
+}
diff --git a/app/clipboard_html_test.go b/app/clipboard_html_test.go
new file mode 100644
index 00000000..1b4cf698
--- /dev/null
+++ b/app/clipboard_html_test.go
@@ -0,0 +1,136 @@
+//go:build darwin || windows || linux
+
+package app
+
+import (
+ "testing"
+)
+
+// TestDriverSetClipboardHoldsBothFlavours exercises the headless store of a
+// rich copy: both flavours are answered by their own readers, the plain one is
+// the driver's clipboard (the text path a paste already uses), and anything
+// else copied replaces the pair, as on a real clipboard.
+func TestDriverSetClipboardHoldsBothFlavours(t *testing.T) {
+ driver, err := NewDriver(Config{}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer driver.Close()
+
+ const html = `bold and 中文`
+ const alt = "bold and 中文"
+ if err := SetClipboardHTML(html, alt); err != nil {
+ t.Fatal(err)
+ }
+ if got := driver.ClipboardHTML(); got != html {
+ t.Fatalf("driver html = %q, want %q", got, html)
+ }
+ if text, err := ClipboardText(); err != nil || text != alt {
+ t.Fatalf("plain flavour = %q, %v, want %q", text, err, alt)
+ }
+ if err := SetClipboardText("typed"); err != nil {
+ t.Fatal(err)
+ }
+ if got := driver.ClipboardHTML(); got != "" {
+ t.Fatalf("after text replaced the pair: html = %q, want empty", got)
+ }
+
+ driver.SetClipboardHTML("one", "one")
+ if err := driver.SetClipboardImage(onePixelPNG(t)); err != nil {
+ t.Fatal(err)
+ }
+ if got := driver.ClipboardHTML(); got != "" {
+ t.Fatalf("after an image replaced the pair: html = %q, want empty", got)
+ }
+
+ // The pre-seeded half of the pair: tests put a rich copy on the private
+ // clipboard the way another application would.
+ driver.SetClipboardHTML("y", "y")
+ if got := driver.ClipboardHTML(); got != "y" {
+ t.Fatalf("SetClipboardHTML of the driver = %q", got)
+ }
+ if text, err := ClipboardText(); err != nil || text != "y" {
+ t.Fatalf("after the seeded pair: text = %q, %v", text, err)
+ }
+}
+
+// TestSetClipboardHTMLDegradesToSetClipboardText pins the empty-html path to
+// SetClipboardText's own shape: the copy is plain either way, and a refused
+// copy writes nothing.
+func TestSetClipboardHTMLDegradesToSetClipboardText(t *testing.T) {
+ driver, err := NewDriver(Config{}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer driver.Close()
+
+ if err := SetClipboardHTML("", "plain only"); err != nil {
+ t.Fatal(err)
+ }
+ if text, err := ClipboardText(); err != nil || text != "plain only" {
+ t.Fatalf("plain flavour = %q, %v", text, err)
+ }
+ if got := driver.ClipboardHTML(); got != "" {
+ t.Fatalf("an empty html left a rich flavour behind: %q", got)
+ }
+
+ if err := SetClipboardHTML("", ""); err != nil {
+ t.Fatal(err)
+ }
+ if text, err := ClipboardText(); err != nil || text != "" {
+ t.Fatalf("empty copy: text = %q, %v", text, err)
+ }
+
+ if err := SetClipboardHTML("a\x00b", "alt"); err == nil {
+ t.Fatal("html with NUL was accepted")
+ }
+ if err := SetClipboardHTML("x", "a\x00b"); err == nil {
+ t.Fatal("alt with NUL was accepted")
+ }
+ if text, _ := ClipboardText(); text != "" {
+ t.Fatalf("a refused copy wrote %q", text)
+ }
+}
+
+// TestSetClipboardHTMLAsyncOfADriverRun pins the async write to the same store;
+// on native targets it completes before the call returns.
+func TestSetClipboardHTMLAsyncOfADriverRun(t *testing.T) {
+ driver, err := NewDriver(Config{}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer driver.Close()
+
+ var written bool
+ if err := SetClipboardHTMLAsync("q", "q", func(err error) {
+ written = true
+ if err != nil {
+ t.Fatal(err)
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !written {
+ t.Fatal("the async write did not complete on a driver-backed run")
+ }
+ if got := driver.ClipboardHTML(); got != "q" {
+ t.Fatalf("driver html = %q", got)
+ }
+ if text, err := ClipboardText(); err != nil || text != "q" {
+ t.Fatalf("plain flavour = %q, %v", text, err)
+ }
+
+ if err := SetClipboardHTMLAsync("", "plain", func(err error) {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if got := driver.ClipboardHTML(); got != "" {
+ t.Fatalf("an empty async html left a rich flavour behind: %q", got)
+ }
+ if text, _ := ClipboardText(); text != "plain" {
+ t.Fatalf("after the empty-html async copy: text = %q", text)
+ }
+}
diff --git a/app/clipboard_html_unsupported.go b/app/clipboard_html_unsupported.go
new file mode 100644
index 00000000..7df134a5
--- /dev/null
+++ b/app/clipboard_html_unsupported.go
@@ -0,0 +1,5 @@
+//go:build !darwin && !windows && !linux && !(js && wasm)
+
+package app
+
+func nativeSetClipboardHTML(html, alt string) error { return ErrUnsupported }
diff --git a/app/clipboard_html_windows.go b/app/clipboard_html_windows.go
new file mode 100644
index 00000000..7799e8b1
--- /dev/null
+++ b/app/clipboard_html_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package app
+
+import windowsplatform "github.com/vivaui/viva/internal/platform/windows"
+
+func nativeSetClipboardHTML(html, alt string) error {
+ return windowsplatform.SetClipboardHTML(html, alt)
+}
diff --git a/app/content_window.go b/app/content_window.go
index 2ec096d2..21776242 100644
--- a/app/content_window.go
+++ b/app/content_window.go
@@ -1,6 +1,7 @@
package app
import (
+ "fmt"
"sync"
"github.com/vivaui/viva/gfx"
@@ -110,10 +111,36 @@ func (content *ContentWindow) Show() {
content.window.Show()
}
}
+
+// SetSize asks the window manager for a new content size, as Window.SetSize
+// does. Config.OnResize reports the size actually granted.
+func (content *ContentWindow) SetSize(width, height int) error {
+ if content == nil || content.window == nil {
+ return fmt.Errorf("app: content window is closed")
+ }
+ return content.window.SetSize(width, height)
+}
func (content *ContentWindow) Closed() bool {
return content == nil || content.window == nil || content.window.Closed()
}
+// Frame reports where the content area is on screen, as Window.Frame does: a
+// drag that ends over another of the application's windows finds it by comparing
+// its pointer with their frames. The second result is false once the window is
+// closed.
+func (content *ContentWindow) Frame() (gfx.Rect, bool) {
+ if content == nil || content.window == nil {
+ return gfx.Rect{}, false
+ }
+ return content.window.Frame()
+}
+
+// Minimized reports whether the window manager has the window minimized, as
+// Window.Minimized does. A minimized window covers no screen point.
+func (content *ContentWindow) Minimized() bool {
+ return content != nil && content.window.Minimized()
+}
+
// Close force-closes the window, as application teardown does. RequestClose is
// the ordinary UI action and honors unsaved-content confirmation.
func (content *ContentWindow) Close() {
diff --git a/app/content_window_test.go b/app/content_window_test.go
index 9fba5f3d..b3c51666 100644
--- a/app/content_window_test.go
+++ b/app/content_window_test.go
@@ -5,6 +5,8 @@ package app
import (
"sync"
"testing"
+
+ "github.com/vivaui/viva/gfx"
)
func TestContentWindowDeliversUpdatesAndHonorsClosePolicy(t *testing.T) {
@@ -67,6 +69,41 @@ func TestContentWindowDeliversUpdatesAndHonorsClosePolicy(t *testing.T) {
}
}
+func TestContentWindowReportsFrameAndMinimizeState(t *testing.T) {
+ host := &fakeHost{}
+ useHost(t, host)
+ if err := RunApplication(func(application *Application) error {
+ origin := gfx.Point{X: 120, Y: 240}
+ content, err := application.OpenContentWindow(Config{Width: 500, Height: 300, Origin: &origin}, nil)
+ if err != nil {
+ return err
+ }
+ frame, ok := content.Frame()
+ if !ok || frame != (gfx.Rect{X: 120, Y: 240, Width: 500, Height: 300}) {
+ t.Fatalf("frame = %+v, ok = %t", frame, ok)
+ }
+ if content.Minimized() {
+ t.Fatal("a new content window reports minimized")
+ }
+ host.windows[0].config.OnMinimize(true)
+ if !content.Minimized() {
+ t.Fatal("minimize state did not follow the window")
+ }
+ host.windows[0].config.OnMinimize(false)
+ if content.Minimized() {
+ t.Fatal("minimize state stayed after restore")
+ }
+ content.Close()
+ host.windows[0].config.OnClosed()
+ if _, ok := content.Frame(); ok {
+ t.Fatal("closed content window still reports a frame")
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestContentWindowRejectedOpenAndPendingUpdateTeardown(t *testing.T) {
host := &fakeHost{reject: "rejected"}
useHost(t, host)
diff --git a/app/driver.go b/app/driver.go
index 9c80938b..6074af20 100644
--- a/app/driver.go
+++ b/app/driver.go
@@ -67,6 +67,8 @@ type Driver struct {
modifierKeys uint8
clipboard string
+ notifications []Notification
+ notificationMu sync.Mutex
openedURL string
hasOpenedURL bool
openedPath string
@@ -144,6 +146,7 @@ func NewDriver(config Config, canvas *gfx.Canvas) (*Driver, error) {
}
driver.painter.setClipboardText = func(value string) {
driver.clipboard, driver.system.clipboardImage, driver.system.clipboardFiles = value, nil, nil
+ driver.system.clipboardHTML = ""
}
driver.painter.setCursor = func(Cursor) error { return nil }
driver.painter.setCursorImage = func(*gfx.CursorImage) error { return nil }
@@ -230,6 +233,16 @@ func (driver *Driver) Transitioning() bool {
// Now is the driver's virtual time, which starts at zero.
func (driver *Driver) Now() time.Duration { return driver.now }
+// Paint processes work other goroutines posted and paints one frame now. A
+// test that changed what a view shows from outside it, without input, calls it
+// to see the result without moving the clock.
+func (driver *Driver) Paint() error {
+ if err := driver.Pump(); err != nil {
+ return err
+ }
+ return driver.redraw()
+}
+
// Advance moves virtual time forward by duration, repainting at frame cadence
// while an animation is in flight and applying background wakes. An idle
// application repaints nothing, as in a window.
@@ -942,6 +955,7 @@ func (driver *Driver) Clipboard() string { return driver.clipboard }
// SetClipboard replaces the driver's private clipboard.
func (driver *Driver) SetClipboard(value string) {
driver.clipboard, driver.system.clipboardImage, driver.system.clipboardFiles = value, nil, nil
+ driver.system.clipboardHTML = ""
}
// LastOpenedURL returns the most recent target passed to OpenURL while this
diff --git a/app/driver_system.go b/app/driver_system.go
index f29a05a9..75190a4c 100644
--- a/app/driver_system.go
+++ b/app/driver_system.go
@@ -27,6 +27,7 @@ type driverSystem struct {
driver *Driver
clipboardImage []byte // PNG bytes; the clipboard holds this or text, never both
clipboardFiles []string // paths of copied files, held beside the text a file manager adds
+ clipboardHTML string // the rich flavour SetClipboardHTML last wrote beside the plain one
primary string // the primary selection (Linux only), private like the clipboard
schemes []string // the URL schemes SetSchemeHandlers declared as registered
}
@@ -80,10 +81,24 @@ func driverSetClipboardText(value string) (error, bool) {
if system == nil {
return nil, false
}
- system.driver.clipboard, system.driver.system.clipboardImage, system.driver.system.clipboardFiles = value, nil, nil
+ system.driver.SetClipboard(value)
return nil, true
}
+// SetClipboardHTML makes the driver's private clipboard hold both flavours of a
+// rich copy, as SetClipboardHTML does on the system clipboard: html beside alt
+// as the plain text, replacing the image and the files. An empty html writes
+// alt as plain text only, like SetClipboard.
+func (driver *Driver) SetClipboardHTML(html, alt string) {
+ driver.system.clipboardHTML = html
+ driver.clipboard, driver.system.clipboardImage, driver.system.clipboardFiles = alt, nil, nil
+}
+
+// ClipboardHTML is the rich flavour the driver's private clipboard holds, ""
+// when the last copy was plain. The string is the driver's own; do not modify
+// it.
+func (driver *Driver) ClipboardHTML() string { return driver.system.clipboardHTML }
+
func driverSetClipboardFiles(paths []string) (error, bool) {
system := activeDriverSystem.Load()
if system == nil {
@@ -93,6 +108,18 @@ func driverSetClipboardFiles(paths []string) (error, bool) {
return nil, true
}
+// driverSetClipboardHTML answers the rich copy of a driver-backed run: the
+// driver's private clipboard holds html beside alt as its text, the shape the
+// native paste paths already read, and anything else copied clears both.
+func driverSetClipboardHTML(html, alt string) (error, bool) {
+ system := activeDriverSystem.Load()
+ if system == nil {
+ return nil, false
+ }
+ system.driver.SetClipboardHTML(html, alt)
+ return nil, true
+}
+
// driverSetPrimarySelection and driverPrimarySelection are the primary selection of a driver-backed
// run, which exists where the real one does, on Linux.
func driverSetPrimarySelection(value string) (error, bool) {
@@ -161,6 +188,26 @@ func driverDeleteSecret(service, account string) (error, bool) {
return nil, true
}
+func driverSendNotification(notification Notification) (error, bool) {
+ system := activeDriverSystem.Load()
+ if system == nil {
+ return nil, false
+ }
+ system.driver.notificationMu.Lock()
+ system.driver.notifications = append(system.driver.notifications, notification)
+ system.driver.notificationMu.Unlock()
+ return nil, true
+}
+
+// Notifications returns the notifications the application sent in this
+// driver-backed run, in order. A Driver has no notification center, so sending
+// one records it instead of showing it.
+func (driver *Driver) Notifications() []Notification {
+ driver.notificationMu.Lock()
+ defer driver.notificationMu.Unlock()
+ return append([]Notification(nil), driver.notifications...)
+}
+
func driverOpenURL(target string) (error, bool) {
system := activeDriverSystem.Load()
if system == nil {
@@ -491,7 +538,7 @@ func (driver *Driver) SetClipboardImage(png []byte) error {
}
driver.system.clipboardImage = append([]byte(nil), png...)
driver.system.clipboardFiles = nil
- driver.clipboard = ""
+ driver.clipboard, driver.system.clipboardHTML = "", ""
return nil
}
@@ -532,5 +579,5 @@ func (driver *Driver) SetClipboardFiles(paths []string) {
}
names = append(names, filepath.Base(path)...)
}
- driver.clipboard = string(names)
+ driver.clipboard, driver.system.clipboardHTML = string(names), ""
}
diff --git a/app/driver_system_test.go b/app/driver_system_test.go
index 71a3d62d..3c318f85 100644
--- a/app/driver_system_test.go
+++ b/app/driver_system_test.go
@@ -299,6 +299,27 @@ func BenchmarkDriverClipboardAndOpenURL(b *testing.B) {
}
}
+func BenchmarkDriverClipboardHTML(b *testing.B) {
+ driver, err := NewDriver(Config{}, nil)
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer driver.Close()
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ if err := SetClipboardHTML("remote", "remote text"); err != nil {
+ b.Fatal(err)
+ }
+ if value, err := ClipboardText(); err != nil || value != "remote text" {
+ b.Fatalf("ClipboardText = %q, %v", value, err)
+ }
+ if html := driver.ClipboardHTML(); html != "remote" {
+ b.Fatalf("ClipboardHTML = %q", html)
+ }
+ }
+}
+
func TestDriverInterceptsOpenDirectory(t *testing.T) {
driver, err := NewDriver(Config{}, nil)
if err != nil {
@@ -379,6 +400,26 @@ func TestDriverRecordsOpenPathAndOpensNothing(t *testing.T) {
if opened, ok := driver.LastOpenedPath(); !ok || opened != file {
t.Fatalf("LastOpenedPath = %q, %t", opened, ok)
}
+ directory := t.TempDir()
+ if err := driver.QueueOpenDirectory(directory, nil); err != nil {
+ t.Fatal(err)
+ }
+ path, err := OpenDirectoryPath(OpenDirectoryOptions{Title: "Choose project"})
+ if err != nil || path != directory {
+ t.Fatalf("OpenDirectoryPath = %q, %v; want %q", path, err, directory)
+ }
+ if opened, _ := driver.LastOpenedPath(); opened != file {
+ t.Fatalf("OpenDirectoryPath unexpectedly opened %q", opened)
+ }
+ if err := driver.QueueOpenDirectory("", ErrCanceled); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := OpenDirectoryPath(OpenDirectoryOptions{}); !errors.Is(err, ErrCanceled) {
+ t.Fatalf("canceled OpenDirectoryPath error = %v", err)
+ }
+ if opened, ok := driver.LastOpenedPath(); !ok || opened != file {
+ t.Fatalf("LastOpenedPath = %q, %t", opened, ok)
+ }
program := filepath.Join(t.TempDir(), "run.sh")
if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
diff --git a/app/driver_system_unsupported.go b/app/driver_system_unsupported.go
index 0785b0b6..1f35e4d2 100644
--- a/app/driver_system_unsupported.go
+++ b/app/driver_system_unsupported.go
@@ -18,6 +18,10 @@ func driverClipboardFiles() (paths []string, err error, handled bool) { return n
func driverSetClipboardFiles([]string) (error, bool) { return nil, false }
+func driverSetClipboardHTML(string, string) (error, bool) { return nil, false }
+
+func driverSendNotification(Notification) (error, bool) { return nil, false }
+
func driverOpenURL(string) (error, bool) { return nil, false }
func driverOpenPath(string) (error, bool) { return nil, false }
func driverRevealPath(string) (error, bool) { return nil, false }
diff --git a/app/open_path.go b/app/open_path.go
index 6e360f17..a440dc5e 100644
--- a/app/open_path.go
+++ b/app/open_path.go
@@ -42,6 +42,13 @@ func OpenPath(path string) error {
return nativeOpenURL(openPathTarget(runtime.GOOS, path))
}
+// OpenDirectoryPath asks the user to choose one existing local directory and
+// returns its path through the same integration as OpenDirectory. Cancellation
+// is ErrCanceled and the Web target returns ErrUnsupported.
+func OpenDirectoryPath(options OpenDirectoryOptions) (string, error) {
+ return OpenDirectory(options)
+}
+
// openPathTarget is what the platform's opener is given: a file URL where the
// opener takes URLs (macOS), the path itself elsewhere.
func openPathTarget(goos, path string) string {
diff --git a/app/painter.go b/app/painter.go
index 79106ae5..b3b70a99 100644
--- a/app/painter.go
+++ b/app/painter.go
@@ -277,6 +277,7 @@ func (painter *painter) pointerEvent(event platform.PointerEvent) (platform.Fram
return platform.Frame{}, nil
}
painter.pointer.SetHovered(gfx.ID{})
+ painter.pointer.SetHoveredRole(gfx.Role(0))
return painter.repaintAfterInput()
}
beforeHovered, beforePressed := painter.pointer.Hovered(), painter.pointer.Pressed()
@@ -341,9 +342,11 @@ func (painter *painter) pointerEvent(event platform.PointerEvent) (platform.Fram
// application answered and whether focus and the pointer's edge state changed.
func (painter *painter) pointerStep(event platform.PointerEvent) (result gfx.EventResult, focusChanged, edgeChanged bool) {
painter.pointer.SetHovered(gfx.ID{})
+ painter.pointer.SetHoveredRole(gfx.Role(0))
if event.Kind != platform.PointerExit {
- if target, ok := painter.focus.Router().HitTest(event.X, event.Y); ok {
- painter.pointer.SetHovered(target)
+ if target, ok := painter.focus.Router().HitTestRegion(event.X, event.Y); ok {
+ painter.pointer.SetHovered(target.Target)
+ painter.pointer.SetHoveredRole(target.Role)
}
}
edgeChanged = painter.trackEdge(event)
@@ -856,6 +859,7 @@ func (painter *painter) paintCanvas() {
}
focused, visible := painter.focus.PaintTarget()
painter.canvas.SetInteraction(painter.pointer.Hovered(), painter.pointer.Pressed(), focused, visible)
+ painter.canvas.SetHoveredRole(painter.pointer.HoveredRole())
painter.canvas.SetScroll(painter.scrollX, painter.scrollY)
painter.canvas.BeginFrame()
painter.paint(painter.canvas, painter.size)
@@ -1138,6 +1142,8 @@ func cursorFromHit(cursor gfx.PointerCursor) Cursor {
return CursorResizeNESW
case gfx.PointerCursorNotAllowed:
return CursorNotAllowed
+ case gfx.PointerCursorGrabbing:
+ return CursorGrabbing
case gfx.PointerCursorHidden:
return CursorHidden
case gfx.PointerCursorImage:
@@ -1171,7 +1177,7 @@ func (painter *painter) focusedTextField() (TextInputContext, gfx.TextPlacement,
ID: hit.ID, Bounds: hit.Bounds, Layer: hit.Layer, Terminal: hit.Role == gfx.RoleTerminal, TypeAhead: hit.TypeAhead && hit.Role != gfx.RoleTerminal,
Multiline: hit.Multiline, ReadOnly: hit.ReadOnly, Secure: hit.Secure,
NavigationKeys: hit.NavigationKeys,
- TextBounds: text.Bounds, FontSize: text.Size, FontWeight: text.Weight, TextColor: text.Color,
+ TextBounds: text.Bounds, FontSize: text.Size, FontWeight: text.Weight, TextColor: text.Color, Alignment: text.Alignment,
HideNativeOverlays: hit.HideNativeOverlays,
}
return context, text.Placement, index
@@ -1215,6 +1221,7 @@ func (painter *painter) syncTextInput() platform.TextInputUpdate {
}
// What the application leaves unset comes from the component's declaration.
bounds, fontSize, weight, tint := input.Bounds, input.FontSize, input.Weight, input.Color
+ alignment := input.Alignment
if bounds == (gfx.Rect{}) {
bounds = context.TextBounds
if bounds == (gfx.Rect{}) {
@@ -1233,6 +1240,9 @@ func (painter *painter) syncTextInput() platform.TextInputUpdate {
if tint == (gfx.Color{}) {
tint = context.TextColor
}
+ if alignment == gfx.AlignLeft {
+ alignment = context.Alignment
+ }
if !(fontSize > 0) || math.IsInf(float64(fontSize), 0) {
// Every platform refuses an editor without a font size. What neither the
// application nor the declaration set is the body text's.
@@ -1252,9 +1262,12 @@ func (painter *painter) syncTextInput() platform.TextInputUpdate {
cellWidth, _ = painter.canvas.MonoCellSize(fontSize, input.MonoFace)
baseline = painter.canvas.MonoBaseline(fontSize, input.MonoFace, bounds.Height)
}
- alignment := gfx.AlignLeft
- if !context.Terminal && !context.TypeAhead && placement == nil {
- alignment = resolveTextInputAlignment(input.Alignment, input.State.Value, input.State.Selection.End)
+ if context.Terminal || context.TypeAhead || placement != nil {
+ // Terminal and self-placed inputs keep the fixed origin they had before
+ // alignment existed.
+ alignment = gfx.AlignLeft
+ } else {
+ alignment = resolveTextInputAlignment(alignment, input.State.Value, input.State.Selection.End)
}
native := platform.TextInput{
Target: context.ID,
diff --git a/app/painter_test.go b/app/painter_test.go
index aa7dc7c1..4ada51e4 100644
--- a/app/painter_test.go
+++ b/app/painter_test.go
@@ -49,6 +49,49 @@ func TestPointerFocusDoesNotFlashKeyboardRing(t *testing.T) {
}
}
+func TestCodeEditorGutterMarkerRoutesRealClicksAndIgnoresLineWhitespace(t *testing.T) {
+ var canvas gfx.Canvas
+ var editor components.CodeEditor
+ var clicks []gfx.ID
+ config := Config{Title: "gutter markers", Width: 400, Height: 200}
+ config.Paint = func(canvas *gfx.Canvas, size gfx.Size) {
+ editor.Paint(canvas, gfx.LightTheme(), gfx.Rect{X: 10, Y: 20, Width: 300, Height: 160}, components.CodeEditorSnapshot{
+ ID: gfx.Item(1), Value: "first\n\nthird", GutterMarkers: []components.CodeEditorGutterMarker{
+ {Line: 0, ID: gfx.Item(20), Color: gfx.RGB(20, 80, 160), Label: "Toggle breakpoint"},
+ },
+ })
+ }
+ onClick(&config, func(id gfx.ID) {
+ if id == gfx.Item(20) {
+ clicks = append(clicks, id)
+ }
+ })
+ painter := newTestPainter(t, &canvas, config)
+ marker, ok := canvas.HitBounds(gfx.Item(20))
+ if !ok || marker.Width == 0 || marker.Height == 0 {
+ t.Fatalf("missing gutter marker hit: %+v, %v", marker, ok)
+ }
+ click := func(x, y float32) {
+ t.Helper()
+ for _, event := range []platform.PointerEvent{
+ {Kind: platform.PointerDown, Button: gfx.PointerButtonPrimary, X: x, Y: y},
+ {Kind: platform.PointerUp, Button: gfx.PointerButtonPrimary, X: x, Y: y},
+ } {
+ if _, err := painter.pointerEvent(event); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ click(marker.X+1, marker.Y+marker.Height/2)
+ if len(clicks) != 1 || clicks[0] != gfx.Item(20) {
+ t.Fatalf("marker-only clicks = %v, want [20]", clicks)
+ }
+ click(marker.X+1, marker.Y+marker.Height*1.5)
+ if len(clicks) != 1 {
+ t.Fatalf("blank-line whitespace clicked a marker: %v", clicks)
+ }
+}
+
func TestFocusTrapEscapeDismissesAndRestoresFocus(t *testing.T) {
var canvas gfx.Canvas
open := false
diff --git a/app/reload_worker.go b/app/reload_worker.go
index d4095f11..c75c8228 100644
--- a/app/reload_worker.go
+++ b/app/reload_worker.go
@@ -30,7 +30,8 @@ func newReloadWorkerConfig(config Config, canvas *gfx.Canvas) reloadwire.WorkerC
Title: config.Title, Width: config.Width, Height: config.Height,
MinWidth: config.MinWidth, MinHeight: config.MinHeight,
WindowMode: uint8(config.WindowMode), TitlebarHeight: uint16(max(config.TitlebarHeight+0.5, 0)),
- ClearRed: config.ClearColor.Red, ClearGreen: config.ClearColor.Green,
+ TrafficLightScale: config.TrafficLightScale,
+ ClearRed: config.ClearColor.Red, ClearGreen: config.ClearColor.Green,
ClearBlue: config.ClearColor.Blue, ClearAlpha: config.ClearColor.Alpha,
HasClick: config.Paint != nil, HasPointer: config.OnPointer != nil || config.Paint != nil,
HasKey: config.OnKey != nil, HasShortcut: config.OnShortcut != nil, HasScroll: config.OnScroll != nil, HasModifierTap: config.OnModifierTap != nil,
diff --git a/app/reload_worker_test.go b/app/reload_worker_test.go
index fc92e9ba..c638f4ff 100644
--- a/app/reload_worker_test.go
+++ b/app/reload_worker_test.go
@@ -67,13 +67,19 @@ func TestReloadWorkerConfigForwardsInputHandlers(t *testing.T) {
// A development host opens the window, so the custom titlebar's height has to
// reach it from the worker's configuration, rounded to a whole pixel.
func TestReloadWorkerCarriesTheTitlebarHeightToTheHost(t *testing.T) {
- worker := newReloadWorkerConfig(Config{Title: "bar", Width: 100, Height: 100, MinWidth: 80, MinHeight: 60, WindowMode: WindowCustomTitlebar, TitlebarHeight: 36}, &gfx.Canvas{})
+ worker := newReloadWorkerConfig(Config{Title: "bar", Width: 100, Height: 100, MinWidth: 80, MinHeight: 60, WindowMode: WindowCustomTitlebar, TitlebarHeight: 36, TrafficLightScale: 0.9}, &gfx.Canvas{})
if worker.Window.MinWidth != 80 || worker.Window.MinHeight != 60 {
t.Fatalf("minimum lost by reload worker: %+v", worker.Window)
}
if worker.Window.TitlebarHeight != 36 || worker.Window.WindowMode != uint8(WindowCustomTitlebar) {
t.Fatalf("window = %+v", worker.Window)
}
+ if worker.Window.TrafficLightScale != 0.9 {
+ t.Fatalf("traffic light scale lost by reload worker: %+v", worker.Window)
+ }
+ if got := newReloadWorkerConfig(Config{}, &gfx.Canvas{}).Window.TrafficLightScale; got != 0 {
+ t.Fatalf("an unset scale became %v", got)
+ }
if got := newReloadWorkerConfig(Config{TitlebarHeight: 35.6}, &gfx.Canvas{}).Window.TitlebarHeight; got != 36 {
t.Fatalf("35.6 became %d", got)
}
diff --git a/app/run.go b/app/run.go
index 5afd8329..93a1b3d0 100644
--- a/app/run.go
+++ b/app/run.go
@@ -21,16 +21,17 @@ func Run(config Config) error {
defer runtime.UnlockOSThread()
return runNative(platform.Config{
- Title: normalized.Title,
- Class: normalized.Class,
- Icon: normalized.Icon.platform(),
- Width: normalized.Width,
- Height: normalized.Height,
- MinWidth: normalized.MinWidth,
- MinHeight: normalized.MinHeight,
- WindowMode: platform.WindowMode(normalized.WindowMode),
- WindowMaterials: normalized.windowMaterials(),
- TitlebarHeight: normalized.TitlebarHeight,
+ Title: normalized.Title,
+ Class: normalized.Class,
+ Icon: normalized.Icon.platform(),
+ Width: normalized.Width,
+ Height: normalized.Height,
+ MinWidth: normalized.MinWidth,
+ MinHeight: normalized.MinHeight,
+ WindowMode: platform.WindowMode(normalized.WindowMode),
+ WindowMaterials: normalized.windowMaterials(),
+ TitlebarHeight: normalized.TitlebarHeight,
+ TrafficLightScale: normalized.TrafficLightScale,
ClearColor: platform.Color{
Red: normalized.ClearColor.Red,
Green: normalized.ClearColor.Green,
diff --git a/app/run_canvas.go b/app/run_canvas.go
index 17e1870f..8e5831f1 100644
--- a/app/run_canvas.go
+++ b/app/run_canvas.go
@@ -152,10 +152,11 @@ func nativeWindowConfig(normalized *Config, painter *painter) (platform.Config,
Title: normalized.Title, Class: normalized.Class, Icon: normalized.Icon.platform(), Width: normalized.Width, Height: normalized.Height,
MinWidth: normalized.MinWidth, MinHeight: normalized.MinHeight,
Origin: normalized.originPoint(), HasOrigin: normalized.Origin != nil,
- WindowMode: platform.WindowMode(normalized.WindowMode),
- WindowMaterials: normalized.windowMaterials(),
- TitlebarHeight: normalized.TitlebarHeight,
- Anchor: normalized.Anchor.rect, HasAnchor: normalized.Anchor.set,
+ WindowMode: platform.WindowMode(normalized.WindowMode),
+ WindowMaterials: normalized.windowMaterials(),
+ TitlebarHeight: normalized.TitlebarHeight,
+ TrafficLightScale: normalized.TrafficLightScale,
+ Anchor: normalized.Anchor.rect, HasAnchor: normalized.Anchor.set,
ClearColor: platform.Color{
Red: normalized.ClearColor.Red, Green: normalized.ClearColor.Green,
Blue: normalized.ClearColor.Blue, Alpha: normalized.ClearColor.Alpha,
diff --git a/app/singleinstance/singleinstance.go b/app/singleinstance/singleinstance.go
index a94f5e00..1cd17d14 100644
--- a/app/singleinstance/singleinstance.go
+++ b/app/singleinstance/singleinstance.go
@@ -1,9 +1,9 @@
//go:build darwin || windows || linux
// Package singleinstance keeps an application to one running copy per user.
-// It is separate from package app because it uses a local socket and the net
-// package: an application that does not import this package does not link
-// them.
+// It is separate from package app because it uses a local socket or named pipe
+// and the net package: an application that does not import this package does not
+// link them.
package singleinstance
import (
@@ -36,8 +36,9 @@ func (claim *Instance) Close() {
//
// id names the application and must be the same for every copy, as its bundle
// or reverse-DNS identifier. Copies run by different users do not meet. The
-// claim lives in a per-user directory the platform keeps across launches, so
-// copies started from a desktop and from a terminal meet whatever temporary
+// claim lives at a per-user address the platform keeps across launches (a Unix
+// socket in a per-user directory on macOS and Linux, a named pipe on Windows),
+// so copies started from a desktop and from a terminal meet whatever temporary
// directories their shells set. An error means the claim could not be made
// either way; the caller may carry on as an unclaimed copy.
func SingleInstance(id string, args []string, onLaunch func(args []string)) (claim *Instance, primary bool, err error) {
diff --git a/app/system.go b/app/system.go
index 87144a95..621bdbb7 100644
--- a/app/system.go
+++ b/app/system.go
@@ -39,6 +39,8 @@ const (
CursorResizeNWSE
CursorResizeNESW
CursorNotAllowed
+ // CursorGrabbing is the closed hand shown while dragging something.
+ CursorGrabbing
// CursorHidden removes the pointer: a surface that draws its own (a
// remote desktop picture) uses it to keep the system pointer away.
CursorHidden
@@ -243,6 +245,9 @@ func SendNotification(notification Notification) error {
if notification.Title == "" && notification.Body == "" {
return fmt.Errorf("app: notification is empty")
}
+ if err, handled := driverSendNotification(notification); handled {
+ return err
+ }
return nativeSendNotification(notification)
}
diff --git a/app/system_async.go b/app/system_async.go
index e0b303f0..3bf11ac5 100644
--- a/app/system_async.go
+++ b/app/system_async.go
@@ -40,6 +40,28 @@ func SetClipboardTextAsync(value string, done func(error)) error {
return nativeSetClipboardTextAsync(value, done)
}
+// SetClipboardHTMLAsync starts SetClipboardHTML's write and calls done with nil
+// once the platform accepted it. The call returns immediately when the request
+// starts; an error means it could not start and done is not called. Native
+// targets complete before the call returns; the browser writes both flavours
+// through one ClipboardItem, which needs a user gesture.
+func SetClipboardHTMLAsync(html, alt string, done func(error)) error {
+ if done == nil {
+ return fmt.Errorf("app: nil clipboard callback")
+ }
+ if strings.IndexByte(html, 0) >= 0 || strings.IndexByte(alt, 0) >= 0 {
+ return fmt.Errorf("app: clipboard html write contains NUL")
+ }
+ if html == "" {
+ return SetClipboardTextAsync(alt, done)
+ }
+ if err, handled := driverSetClipboardHTML(html, alt); handled {
+ done(err)
+ return nil
+ }
+ return nativeSetClipboardHTMLAsync(html, alt, done)
+}
+
// RequestNotificationPermission asks the browser for permission to show
// notifications. On desktop it calls done with Granted because the operating
// system asks for permission when a notification is delivered. done runs
diff --git a/app/system_async_js.go b/app/system_async_js.go
index cfa83a9a..7e8f7ca0 100644
--- a/app/system_async_js.go
+++ b/app/system_async_js.go
@@ -16,6 +16,12 @@ func nativeSetClipboardTextAsync(value string, done func(error)) error {
}))
}
+func nativeSetClipboardHTMLAsync(html, alt string, done func(error)) error {
+ return webSystemError("write clipboard", webplatform.SetClipboardHTMLAsync(html, alt, func(err error) {
+ done(webSystemError("write clipboard", err))
+ }))
+}
+
func nativeRequestNotificationPermission(done func(NotificationPermission, error)) error {
return webSystemError("request notification permission", webplatform.RequestNotificationPermission(func(granted bool, err error) {
if err != nil {
diff --git a/app/system_async_other.go b/app/system_async_other.go
index 33cae1e5..5a74bea4 100644
--- a/app/system_async_other.go
+++ b/app/system_async_other.go
@@ -13,6 +13,11 @@ func nativeSetClipboardTextAsync(value string, done func(error)) error {
return nil
}
+func nativeSetClipboardHTMLAsync(html, alt string, done func(error)) error {
+ done(nativeSetClipboardHTML(html, alt))
+ return nil
+}
+
func nativeRequestNotificationPermission(done func(NotificationPermission, error)) error {
done(NotificationPermissionGranted, nil)
return nil
diff --git a/app/system_js_test.go b/app/system_js_test.go
index 17e0a3d0..461550c7 100644
--- a/app/system_js_test.go
+++ b/app/system_js_test.go
@@ -14,6 +14,9 @@ func TestBrowserPromiseOnlySystemAPIsExposeAsyncConstraint(t *testing.T) {
if err := SetClipboardText("viva"); !errors.Is(err, ErrAsyncRequired) {
t.Fatalf("SetClipboardText error = %v", err)
}
+ if err := SetClipboardHTML("viva", "viva"); !errors.Is(err, ErrAsyncRequired) {
+ t.Fatalf("SetClipboardHTML error = %v", err)
+ }
if _, err := OpenFiles(OpenFileOptions{}); !errors.Is(err, ErrAsyncRequired) {
t.Fatalf("OpenFiles error = %v", err)
}
diff --git a/app/text_input.go b/app/text_input.go
index e91fd610..61ac88f6 100644
--- a/app/text_input.go
+++ b/app/text_input.go
@@ -138,14 +138,15 @@ type TextInputContext struct {
// text being composed, at Bounds. Return an input whose OnEvent keeps the
// query the application builds from the events.
TypeAhead bool
- // TextBounds, FontSize, FontWeight and TextColor are the text geometry the component
- // declared for this field. They fill whatever the returned TextInput leaves
+ // TextBounds, FontSize, FontWeight, TextColor and Alignment are the text geometry
+ // the component declared for this field. They fill whatever the returned TextInput leaves
// unset, so a bare &TextInput{Editor: editor} draws its text exactly where
// the field painted it; set them only to deliberately override.
TextBounds gfx.Rect
FontSize float32
FontWeight float32
TextColor gfx.Color
+ Alignment gfx.Alignment
// HideNativeOverlays requests that the platform editor keep its own caret
// and selection invisible. Editing, IME and clipboard behavior are
// unchanged. Components that paint those overlays themselves set it.
diff --git a/app/text_input_js.go b/app/text_input_js.go
index d1286ae9..3c889e00 100644
--- a/app/text_input_js.go
+++ b/app/text_input_js.go
@@ -43,7 +43,7 @@ func webTextInput(provider TextInputProvider) web.TextInputProvider {
Layer: context.Layer, ReadOnly: context.ReadOnly, Secure: context.Secure,
NavigationKeys: context.NavigationKeys, Terminal: context.Terminal, TypeAhead: context.TypeAhead,
TextBounds: context.TextBounds, FontSize: context.FontSize, FontWeight: context.FontWeight,
- TextColor: context.TextColor, HideNativeOverlays: context.HideNativeOverlays,
+ TextColor: context.TextColor, Alignment: context.Alignment, HideNativeOverlays: context.HideNativeOverlays,
})
if active == nil {
return nil
@@ -70,7 +70,11 @@ func webTextInput(provider TextInputProvider) web.TextInputProvider {
} else {
native.State = platformTextState(active.State)
}
- native.Alignment = resolveTextInputAlignment(active.Alignment, native.State.Value, native.State.Selection.End)
+ alignment := active.Alignment
+ if alignment == 0 {
+ alignment = context.Alignment
+ }
+ native.Alignment = resolveTextInputAlignment(alignment, native.State.Value, native.State.Selection.End)
return &native
}
}
diff --git a/app/web.go b/app/web.go
index 948a487f..f428b435 100644
--- a/app/web.go
+++ b/app/web.go
@@ -77,6 +77,11 @@ func (host *WebHost) repaintWith(painter *painter) {
}
}
+// WebProxyDirect selects direct networking for a page, bypassing system proxies.
+// macOS requires version 14 or newer; unsupported engines return ErrUnsupported.
+// On macOS and Linux, direct pages use a nonpersistent data store.
+const WebProxyDirect = platform.WebProxyDirect
+
// WebViewConfig describes a page to open.
type WebViewConfig struct {
// URL is loaded when the page opens, unless HTML is set.
@@ -91,6 +96,7 @@ type WebViewConfig struct {
// carries all of the page's traffic, names resolved by the proxy: the page reaches only what the
// proxy reaches. Opening fails with ErrUnsupported where the engine cannot be held to it, rather
// than opening a page that connects directly.
+ // Empty follows the system proxy policy. WebProxyDirect bypasses system proxies for this page.
Proxy string
// OnMessage receives what the page posts with window.viva.postMessage(text).
OnMessage func(text string)
@@ -206,7 +212,7 @@ func (host *WebHost) Open(config WebViewConfig) (*WebView, error) {
if host == nil || host.native == nil || host.native.Open == nil {
return nil, fmt.Errorf("%w: web views", ErrUnsupported)
}
- if config.Proxy != "" && !loopbackAddress(config.Proxy) {
+ if config.Proxy != "" && config.Proxy != WebProxyDirect && !loopbackAddress(config.Proxy) {
return nil, fmt.Errorf("app: web view proxy %q is not a loopback host:port", config.Proxy)
}
id := gfx.NativeViewID(host.next.Add(1))
diff --git a/app/web_test.go b/app/web_test.go
index 181f7c9e..57990df9 100644
--- a/app/web_test.go
+++ b/app/web_test.go
@@ -53,6 +53,17 @@ func TestOpeningAPageWithoutAHostIsUnsupported(t *testing.T) {
}
}
+func TestDirectPageNetworkingReachesPlatform(t *testing.T) {
+ var proxy string
+ host := &WebHost{native: &platform.WebHost{Open: func(config platform.WebViewConfig) (platform.WebView, error) {
+ proxy = config.Proxy
+ return nil, platform.ErrWebViewUnsupported
+ }}}
+ if _, err := host.Open(WebViewConfig{Proxy: WebProxyDirect}); !errors.Is(err, ErrUnsupported) || proxy != platform.WebProxyDirect {
+ t.Fatalf("direct networking was not passed to the platform: proxy=%q err=%v", proxy, err)
+ }
+}
+
func TestAPageProxyMustBeOnThisMachine(t *testing.T) {
var opened []string
host := &WebHost{native: &platform.WebHost{Open: func(config platform.WebViewConfig) (platform.WebView, error) {
diff --git a/app/window_size.go b/app/window_size.go
new file mode 100644
index 00000000..bc1a9d9e
--- /dev/null
+++ b/app/window_size.go
@@ -0,0 +1,37 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/vivaui/viva/internal/platform"
+)
+
+const maximumProgrammaticWindowSize = 65535
+
+// SetSize asks the window manager to make this window's content area width by
+// height logical pixels. The request is asynchronous: OnResize reports the size
+// actually granted. The window manager may adjust a request, and width and
+// height must be 1 through 65535. A maximized, minimized or fullscreen window
+// ignores it. It is safe to call from any goroutine.
+//
+// Unsupported hosts return an error wrapping ErrUnsupported.
+func (window *Window) SetSize(width, height int) error {
+ if window == nil || window.closed.Load() {
+ return fmt.Errorf("app: window is closed")
+ }
+ if width < 1 || height < 1 || width > maximumProgrammaticWindowSize || height > maximumProgrammaticWindowSize {
+ return fmt.Errorf("app: invalid window size %dx%d", width, height)
+ }
+ sizer, ok := window.control.(platform.WindowSizer)
+ if !ok {
+ return fmt.Errorf("%w: programmatic window size", ErrUnsupported)
+ }
+ if err := sizer.SetSize(width, height); err != nil {
+ if errors.Is(err, platform.ErrWindowSizeUnsupported) {
+ return fmt.Errorf("%w: programmatic window size", ErrUnsupported)
+ }
+ return err
+ }
+ return nil
+}
diff --git a/app/window_size_native_test.go b/app/window_size_native_test.go
new file mode 100644
index 00000000..3ec16584
--- /dev/null
+++ b/app/window_size_native_test.go
@@ -0,0 +1,58 @@
+//go:build windows || linux
+
+package app
+
+import (
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/vivaui/viva/gfx"
+)
+
+func TestNativeProgrammaticWindowSize(t *testing.T) {
+ if os.Getenv("VIVA_NATIVE_WINDOW_SIZE_TEST") != "1" {
+ t.Skip("requires a native desktop session")
+ }
+ for _, test := range []struct {
+ name string
+ mode WindowMode
+ }{
+ {name: "titled", mode: WindowTitled},
+ {name: "custom_titlebar", mode: WindowCustomTitlebar},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ var canvas gfx.Canvas
+ var got gfx.Size
+ err := RunApplication(func(application *Application) error {
+ window, err := application.OpenWindow(Config{
+ Title: "Viva content resize test", Width: 320, Height: 240, WindowMode: test.mode,
+ Paint: func(*gfx.Canvas, gfx.Size) {},
+ OnResize: func(size gfx.Size) {
+ if size.Width >= 419 && size.Width <= 421 && size.Height >= 299 && size.Height <= 301 {
+ got = size
+ application.Quit()
+ }
+ },
+ }, &canvas)
+ if err != nil {
+ return err
+ }
+ if err := window.SetSize(420, 300); err != nil {
+ application.Quit()
+ return fmt.Errorf("request size: %w", err)
+ }
+ timer := time.AfterFunc(8*time.Second, application.Quit)
+ t.Cleanup(func() { timer.Stop() })
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Width == 0 {
+ t.Fatal("native resize never reached OnResize with the requested content size")
+ }
+ })
+ }
+}
diff --git a/app/window_size_test.go b/app/window_size_test.go
new file mode 100644
index 00000000..b4442bb6
--- /dev/null
+++ b/app/window_size_test.go
@@ -0,0 +1,79 @@
+package app
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/vivaui/viva/internal/platform"
+)
+
+type sizingWindow struct {
+ WindowControl
+ width, height int
+ err error
+}
+
+func (window *sizingWindow) SetSize(width, height int) error {
+ window.width, window.height = width, height
+ return window.err
+}
+
+func TestWindowSetSizeReachesTheNativeWindow(t *testing.T) {
+ native := &sizingWindow{}
+ window := &Window{control: native}
+ if err := window.SetSize(1440, 900); err != nil {
+ t.Fatal(err)
+ }
+ if native.width != 1440 || native.height != 900 {
+ t.Fatalf("SetSize = %dx%d", native.width, native.height)
+ }
+ if err := window.SetSize(65535, 65535); err != nil {
+ t.Fatalf("maximum size was rejected: %v", err)
+ }
+ for _, size := range [][2]int{{0, 1}, {1, 0}, {65536, 1}, {1, 65536}} {
+ if err := window.SetSize(size[0], size[1]); err == nil {
+ t.Fatalf("invalid size %dx%d was accepted", size[0], size[1])
+ }
+ }
+ window.closed.Store(true)
+ if err := window.SetSize(800, 600); err == nil || errors.Is(err, ErrUnsupported) {
+ t.Fatalf("closed window error = %v", err)
+ }
+ var nilWindow *Window
+ if err := nilWindow.SetSize(800, 600); err == nil {
+ t.Fatal("nil window accepted SetSize")
+ }
+}
+
+func TestWindowSetSizeReportsUnsupportedHosts(t *testing.T) {
+ window := &Window{control: struct{ WindowControl }{}}
+ if err := window.SetSize(800, 600); !errors.Is(err, ErrUnsupported) {
+ t.Fatalf("unsupported host error = %v", err)
+ }
+
+ window.control = &sizingWindow{err: platform.ErrWindowSizeUnsupported}
+ if err := window.SetSize(800, 600); !errors.Is(err, ErrUnsupported) {
+ t.Fatalf("unsupported native window error = %v", err)
+ }
+
+ failure := errors.New("native resize failed")
+ window.control = &sizingWindow{err: failure}
+ if err := window.SetSize(800, 600); !errors.Is(err, failure) || errors.Is(err, ErrUnsupported) {
+ t.Fatalf("native resize error = %v", err)
+ }
+}
+
+func TestContentWindowForwardsSetSize(t *testing.T) {
+ native := &sizingWindow{}
+ content := &ContentWindow{window: &Window{control: native}}
+ if err := content.SetSize(800, 600); err != nil {
+ t.Fatal(err)
+ }
+ if native.width != 800 || native.height != 600 {
+ t.Fatalf("SetSize = %dx%d", native.width, native.height)
+ }
+ var nilContent *ContentWindow
+ if err := nilContent.SetSize(800, 600); err == nil {
+ t.Fatal("nil content window accepted SetSize")
+ }
+}
diff --git a/cmd/viva/feed.go b/cmd/viva/feed.go
index cb489b91..33ee0cd4 100644
--- a/cmd/viva/feed.go
+++ b/cmd/viva/feed.go
@@ -1,6 +1,7 @@
package main
import (
+ "bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
@@ -20,6 +21,7 @@ import (
"time"
"github.com/vivaui/viva/tools/manifest"
+ "github.com/vivaui/viva/tools/patchgen"
"github.com/vivaui/viva/update"
)
@@ -40,6 +42,15 @@ func runFeed(arguments []string, output, _ io.Writer) error {
flags.StringVar(&baseURL, "base-url", "", "HTTPS address the archives are served from (required)")
flags.StringVar(¬es, "notes", "", "HTTPS page describing the release")
var validFor time.Duration
+ var patchFroms []patchFrom
+ flags.Func("patch-from", "make a delta update from this earlier release: os/arch=version=archive (repeatable; darwin and linux)", func(value string) error {
+ parsed, err := parsePatchFrom(value)
+ if err != nil {
+ return err
+ }
+ patchFroms = append(patchFroms, parsed)
+ return nil
+ })
flags.DurationVar(&validFor, "valid-for", 0, "how long clients trust the feed, such as 2160h; 0 (the default) never expires")
flags.StringVar(&keyFile, "key", "", "Ed25519 private key file (default $VIVA_UPDATE_KEY)")
positional, helped, err := parseFlags(c, flags, arguments, output)
@@ -94,6 +105,11 @@ func runFeed(arguments []string, output, _ io.Writer) error {
if err != nil {
return err
}
+ if len(patchFroms) > 0 {
+ if err := addPatches(assets, patchFroms, artifactsDir, baseURL, version, app, loaded.ExecutableName()); err != nil {
+ return err
+ }
+ }
key, err := readSigningKey(keyFile)
if err != nil {
return err
@@ -144,6 +160,13 @@ func runFeed(arguments []string, output, _ io.Writer) error {
fmt.Fprintf(output, " expires %s\n", feed.Expires.Format(time.RFC3339))
}
fmt.Fprintf(output, " assets %d\n", len(assets))
+ patches := 0
+ for _, asset := range assets {
+ patches += len(asset.Patches)
+ }
+ if patches > 0 {
+ fmt.Fprintf(output, " patches %d\n", patches)
+ }
fmt.Fprintf(output, " signed %s\n", displayPath(signaturePath))
return nil
}
@@ -395,3 +418,125 @@ func writeKeyFile(path, text string, mode os.FileMode, force bool) error {
}
return file.Close()
}
+
+// patchFrom is one --patch-from value: a previous release to diff against.
+type patchFrom struct {
+ os, arch, version, archive string
+}
+
+func parsePatchFrom(value string) (patchFrom, error) {
+ parts := strings.SplitN(value, "=", 3)
+ if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
+ return patchFrom{}, errors.New("--patch-from wants os/arch=version=archive, such as darwin/arm64=1.0.0=dist/notes-1.0.0-darwin-arm64.zip")
+ }
+ target, version, archive := parts[0], parts[1], parts[2]
+ goos, goarch, ok := strings.Cut(target, "/")
+ if !ok || (goos != "darwin" && goos != "linux") {
+ return patchFrom{}, fmt.Errorf("--patch-from %q: patches are made for darwin and linux", target)
+ }
+ if goarch != "amd64" && goarch != "arm64" {
+ return patchFrom{}, fmt.Errorf("--patch-from %q: architecture must be amd64 or arm64", target)
+ }
+ return patchFrom{os: goos, arch: goarch, version: version, archive: archive}, nil
+}
+
+// addPatches writes a patch for every --patch-from that names a listed asset
+// and adds it to the asset. Every argument must match an asset, so a typo in
+// the platform cannot publish a feed that silently lacks its delta.
+func addPatches(assets []update.Asset, froms []patchFrom, dir, baseURL, version, app, executable string) error {
+ base := strings.TrimRight(baseURL, "/")
+ byTarget := make(map[string][]patchFrom, len(froms))
+ for _, from := range froms {
+ key := from.os + "/" + from.arch
+ for _, existing := range byTarget[key] {
+ if existing.version == from.version {
+ return fmt.Errorf("feed: --patch-from %s=%s is given twice", key, from.version)
+ }
+ }
+ if cmp, err := update.Compare(from.version, version); err != nil {
+ return fmt.Errorf("feed: --patch-from %s=%s: %v", key, from.version, err)
+ } else if cmp >= 0 {
+ return fmt.Errorf("feed: --patch-from %s=%s must be older than the release %s", key, from.version, version)
+ }
+ if _, err := os.Stat(from.archive); err != nil {
+ return fmt.Errorf("feed: --patch-from %s=%s: %w", key, from.version, err)
+ }
+ byTarget[key] = append(byTarget[key], from)
+ }
+ matched := make(map[string]int, len(byTarget))
+ for i := range assets {
+ asset := &assets[i]
+ key := asset.OS + "/" + asset.Arch
+ froms := byTarget[key]
+ if len(froms) == 0 {
+ continue
+ }
+ name, err := url.PathUnescape(strings.TrimPrefix(asset.URL, base+"/"))
+ if err != nil {
+ return fmt.Errorf("feed: %s: %w", asset.URL, err)
+ }
+ newArchive := filepath.Join(dir, name)
+ for _, from := range froms {
+ var encoded bytes.Buffer
+ if _, err := patchgen.Write(patchgen.Request{
+ App: app, FromVersion: from.version, ToVersion: version,
+ OS: asset.OS, Arch: asset.Arch,
+ OldArchive: from.archive, NewArchive: newArchive,
+ Executable: executable,
+ Output: &encoded,
+ }); err != nil {
+ return fmt.Errorf("feed: patch %s from %s: %w", key, from.version, err)
+ }
+ patchName := name + "." + from.version + ".patch"
+ if err := writePatchFileBeside(filepath.Join(dir, patchName), encoded.Bytes()); err != nil {
+ return err
+ }
+ sum := sha256.Sum256(encoded.Bytes())
+ asset.Patches = append(asset.Patches, update.Patch{
+ FromVersion: from.version,
+ URL: base + "/" + url.PathEscape(patchName),
+ Size: int64(encoded.Len()),
+ SHA256: hex.EncodeToString(sum[:]),
+ })
+ matched[key]++
+ }
+ sort.Slice(asset.Patches, func(a, b int) bool {
+ order, err := update.Compare(asset.Patches[a].FromVersion, asset.Patches[b].FromVersion)
+ return err == nil && order < 0
+ })
+ }
+ for key, froms := range byTarget {
+ if matched[key] != len(froms) {
+ return fmt.Errorf("feed: --patch-from %s names no %s/%s archive in %s", key, strings.Split(key, "/")[0], strings.Split(key, "/")[1], displayPath(dir))
+ }
+ }
+ return nil
+}
+
+// writeFeedFile writes a patch beside the archives, named only when complete.
+func writePatchFileBeside(name string, data []byte) error {
+ file, err := os.CreateTemp(filepath.Dir(name), "."+filepath.Base(name)+".viva-feed-*")
+ if err != nil {
+ return err
+ }
+ temporary := file.Name()
+ if _, err := file.Write(data); err != nil {
+ file.Close()
+ os.Remove(temporary)
+ return err
+ }
+ if err := file.Chmod(0o644); err != nil {
+ file.Close()
+ os.Remove(temporary)
+ return err
+ }
+ if err := file.Close(); err != nil {
+ os.Remove(temporary)
+ return err
+ }
+ if err := os.Rename(temporary, name); err != nil {
+ os.Remove(temporary)
+ return err
+ }
+ return nil
+}
diff --git a/cmd/viva/feed_test.go b/cmd/viva/feed_test.go
index 93bb8c50..a29b0f2b 100644
--- a/cmd/viva/feed_test.go
+++ b/cmd/viva/feed_test.go
@@ -513,3 +513,114 @@ func TestFeedAndKeygenHelpListsFlags(t *testing.T) {
}
}
}
+
+func copyFeedFile(t *testing.T, source, destination string) {
+ t.Helper()
+ data, err := os.ReadFile(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(destination, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestFeedAddsADeltaFromAnEarlierRelease(t *testing.T) {
+ t.Setenv("VIVA_UPDATE_KEY", "")
+ const oldTOML = `[app]
+name = "Fixture Notes"
+id = "dev.example.notes"
+version = "2.0.0"
+`
+ dir := t.TempDir()
+ oldDir, newDir := fixture(t, oldTOML), fixture(t, feedTestBasicTOML)
+ // A different program, so the patch has something to diff.
+ if err := os.WriteFile(filepath.Join(oldDir, "main.go"), []byte("package main\n\nfunc main() { extra() }\n\nfunc extra() {}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if output, err := runLinuxPackage(t, oldDir); err != nil {
+ t.Fatalf("packaging the old release: %v\n%s", err, output)
+ }
+ if output, err := runLinuxPackage(t, newDir); err != nil {
+ t.Fatalf("packaging the new release: %v\n%s", err, output)
+ }
+ dist := filepath.Join(dir, "dist")
+ oldArchive := filepath.Join(oldDir, "build", "fixture-notes-2.0.0-linux-amd64.tar.gz")
+ newArchive := filepath.Join(newDir, "build", "fixture-notes-2.0.1-linux-amd64.tar.gz")
+ copyFeedFile(t, newArchive, filepath.Join(dist, filepath.Base(newArchive)))
+
+ key, _ := feedTestKey(t, dir)
+ feedPath := filepath.Join(dist, "latest.json")
+ var output bytes.Buffer
+ err := runCLI([]string{
+ "feed", "-C", newDir, "-dir", dist, "-base-url", "https://example.com/app",
+ "-key", key, "-out", feedPath,
+ "--patch-from", "linux/amd64=2.0.0=" + oldArchive,
+ }, &output, io.Discard)
+ if err != nil {
+ t.Fatalf("%v\n%s", err, output.String())
+ }
+ data, err := os.ReadFile(feedPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var feed update.Feed
+ if err := json.Unmarshal(data, &feed); err != nil {
+ t.Fatal(err)
+ }
+ if len(feed.Latest.Assets) != 1 || len(feed.Latest.Assets[0].Patches) != 1 {
+ t.Fatalf("the feed does not carry one patch:\n%s", data)
+ }
+ patch := feed.Latest.Assets[0].Patches[0]
+ if patch.FromVersion != "2.0.0" || patch.Size <= 0 || len(patch.SHA256) != 64 {
+ t.Fatalf("patch entry = %+v", patch)
+ }
+ patchPath := filepath.Join(dist, "fixture-notes-2.0.1-linux-amd64.tar.gz.2.0.0.patch")
+ reader, err := update.ReadPatch(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+ manifest := reader.Manifest()
+ if manifest.App != "dev.example.notes" || manifest.FromVersion != "2.0.0" || manifest.ToVersion != "2.0.1" ||
+ manifest.OS != "linux" || manifest.Arch != "amd64" || manifest.Payload != "file" || len(manifest.Files) == 0 {
+ t.Fatalf("manifest = %+v", manifest)
+ }
+ // The patch must be much smaller than the archive it replaces.
+ if patch.Size >= feed.Latest.Assets[0].Size/2 {
+ t.Errorf("patch is %d bytes for a %d-byte archive", patch.Size, feed.Latest.Assets[0].Size)
+ }
+}
+
+func TestFeedRejectsBadPatchFrom(t *testing.T) {
+ t.Setenv("VIVA_UPDATE_KEY", "")
+ dir := t.TempDir()
+ feedDir := fixture(t, feedTestBasicTOML)
+ if output, err := runLinuxPackage(t, feedDir); err != nil {
+ t.Fatalf("%v\n%s", err, output)
+ }
+ dist := filepath.Join(dir, "dist")
+ archive := filepath.Join(feedDir, "build", "fixture-notes-2.0.1-linux-amd64.tar.gz")
+ copyFeedFile(t, archive, filepath.Join(dist, filepath.Base(archive)))
+ key, _ := feedTestKey(t, dir)
+ run := func(extra ...string) error {
+ arguments := append([]string{"feed", "-C", feedDir, "-dir", dist, "-base-url", "https://example.com/app", "-key", key}, extra...)
+ var output bytes.Buffer
+ return runCLI(arguments, &output, io.Discard)
+ }
+ for name, argument := range map[string]string{
+ "windows": "windows/amd64=2.0.0=" + archive,
+ "bad platform": "linux/sparc=2.0.0=" + archive,
+ "not older": "linux/amd64=2.0.1=" + archive,
+ "unknown asset": "darwin/arm64=2.0.0=" + archive,
+ "bad syntax": "linux/amd64=2.0.0",
+ "missing file": "linux/amd64=2.0.0=/does/not/exist.tar.gz",
+ } {
+ if err := run("--patch-from", argument); err == nil {
+ t.Errorf("%s: --patch-from %q was accepted", name, argument)
+ }
+ }
+}
diff --git a/cmd/viva/help.go b/cmd/viva/help.go
index 597269ac..db2f215e 100644
--- a/cmd/viva/help.go
+++ b/cmd/viva/help.go
@@ -76,7 +76,9 @@ func commands() []command {
"signature. The feed lists only files named the way viva package names them; anything else in\n" +
"the directory is ignored. -base-url is where the archives are served from and must be HTTPS,\n" +
"as -notes must be when given. The signing key is -key, or $VIVA_UPDATE_KEY: the base64 64-byte\n" +
- "private key 'viva update-keygen' writes. App, version and archive directory default to viva.toml.",
+ "private key 'viva update-keygen' writes. App, version and archive directory default to viva.toml.\n" +
+ "--patch-from os/arch=version=archive (repeatable) writes a delta update from that earlier\n" +
+ "release beside the archives and lists it on the asset; it works for darwin and linux.",
run: func(arguments []string, output, errorOutput io.Writer) error {
return runFeed(arguments, output, errorOutput)
}},
diff --git a/cmd/viva/initfiles/AGENTS.md.txt b/cmd/viva/initfiles/AGENTS.md.txt
index cb6d15bb..8b140e21 100644
--- a/cmd/viva/initfiles/AGENTS.md.txt
+++ b/cmd/viva/initfiles/AGENTS.md.txt
@@ -228,7 +228,8 @@ func (m *model) page(c ui.C) {
- Effects, all typed and checked by the compiler (there is no class string): `Ring(3, c)` draws a line around the
outside without taking room (a focus ring); `Opacity(0.5)` fades the element and everything in it (it is still laid out
and clickable); `Gradient(ui.ToBottomRight, from, to)` and `GradientStops(angle, gfx.Stop(0, a), gfx.Stop(1, b), ...)`
- fill the element, corners included, over `Background` and under `Border`.
+ fill the element, corners included, over `Background` and under `Border`; `Dashed()` breaks the `Border` line into
+ equal dashes and gaps (a drop target's frame), and `Look{}` has `Dashed(bool)` too.
- States: an element with an identity (`Hit`, which also makes it focusable) changes with `Hover(ui.Look{}.Background(theme.Hover))`, `Pressed(...)` and
`Focus(ui.Look{}.Ring(3, theme.Ring))` (keyboard focus, as `:focus-visible`), plus `Focused(...)` for any focus (a click in a text field focuses it
without making it visible; a group box that frames its field follows the field with `StateFrom(field)`). A `Look` lists only what differs from rest and has `Background`, `Border`,
@@ -335,7 +336,9 @@ func (m *model) page(c ui.C) {
`ui.Scroll` the table is as tall as its rows and only visible rows are read; with `Height`, `Fill` or `Grow` it scrolls
its rows under a fixed header and draws its own hover bars: `Scrollbar` names the bar's hit, `ScrollbarLabel` names it
for assistive technology, `ScrollbarSize` sets its thickness (else the page's `PageConfig.ScrollbarSize`) and
- `ScrollbarGutter` keeps a strip beside the columns for it. The page gives the wheel to a self-scrolling table under the pointer; give the `app.Config` passed to
+ `ScrollbarGutter` keeps a strip beside the columns for it. An application painting its own vertical bar sets
+ `HideHoverScrollbars: true` to omit the anonymous vertical hover thumb only; the horizontal overlay, gutter, wheel,
+ and an explicitly identified draggable `Scrollbar` are unchanged. The page gives the wheel to a self-scrolling table under the pointer; give the `app.Config` passed to
`page.Config` `OnKey: m.table.Key` for Up/Down/Page/Home/End selection (a host that routes its own scroll gives
`m.table.Wheel` the wheel).
- Tabs: `if ui.Tabs(c, &m.tab, tabs, nil).Line().Changed() { ... }`, then declare the active tab's content under it with an
@@ -551,7 +554,9 @@ copied with it). They take a
Painting your own thing in a slot: `ui.Slot(c).Grow(1).Draw(func(d *ui.DrawContext) { ... })`, with `d.Canvas.Fill(rect, color)`, `Rounded(rect, radius, color)`, `Border`, `RoundedBorder`, `Elevate(rect, radius, gfx.ShadowMD)` (a drop shadow: call it before the surface),
`DrawPath` and `d.Canvas.Label(text, rect, gfx.TextStyle{Size: 14, Color: c})` (`LabelCenter` is the same, centered) are all of
-it; there is no arc, circle or line call (a circle is a `Rounded` square, or a `Box` with `Radius`).
+it; there is no arc, circle or line call (a circle is a `Rounded` square, or a `Box` with `Radius`). `d.Fade(color)` applies the
+element's opacity to a color you supply, and `d.DashedBorder(rect, radius, 1, d.Fade(color))` paints a dashed frame the
+element fades with.
For tabs, tables and text fields use `ui.Tabs`, `ui.Table` and `ui.TextField` above, not the components by hand.
## Background work
diff --git a/cmd/viva/package.go b/cmd/viva/package.go
index 5cb91716..a75ce5f7 100644
--- a/cmd/viva/package.go
+++ b/cmd/viva/package.go
@@ -18,7 +18,7 @@ import (
var packageFormats = map[string][]string{
"darwin": {"app", "zip", "dmg"},
"windows": {"nsis", "zip"},
- "linux": {"tar.gz", "deb"},
+ "linux": {"tar.gz", "deb", "pkgbuild"},
}
// packageRequest is what one target's packager is given.
@@ -39,6 +39,9 @@ type packageRequest struct {
// fileArgs are --file SRC=DEST values, resolved against the manifest when
// the Windows packages are made.
fileArgs []string
+ // pkgbuildURL is --pkgbuild-url: where makepkg downloads the tar.gz from.
+ // Empty means the archive is beside the PKGBUILD.
+ pkgbuildURL string
}
// build makes the program, unless --program supplied one.
@@ -60,10 +63,12 @@ func runPackage(arguments []string, output, errorOutput io.Writer) error {
var makeZip bool
var sign signingOptions
var extraFiles []string
+ var pkgbuildURL string
flags.StringVar(&requested, "o", "", "where to write: the .app for darwin, the output directory otherwise; default is [build] out")
flags.StringVar(&requested, "out", "", "same as -o")
- flags.StringVar(&formatList, "format", "", "comma-separated formats: darwin app, zip, dmg; windows nsis, zip; linux tar.gz, deb")
+ flags.StringVar(&formatList, "format", "", "comma-separated formats: darwin app, zip, dmg; windows nsis, zip; linux tar.gz, deb, pkgbuild")
flags.StringVar(&program, "program", "", "package this already built executable instead of building one (it must be for --target and --arch)")
+ flags.StringVar(&pkgbuildURL, "pkgbuild-url", "", "linux pkgbuild: where makepkg downloads the tar.gz from (default: beside the PKGBUILD)")
flags.BoolVar(&makeZip, "zip", false, "same as adding zip to --format")
flags.Func("file", "windows: ship SRC beside the program as DEST (repeatable, SRC=DEST; DEST is relative)", func(value string) error {
source, destination, ok := strings.Cut(value, "=")
@@ -111,7 +116,7 @@ func runPackage(arguments []string, output, errorOutput io.Writer) error {
}
defer os.RemoveAll(scratch)
request := packageRequest{
- plan: plan, formats: formats, out: requested, sign: sign, fileArgs: extraFiles,
+ plan: plan, formats: formats, out: requested, sign: sign, fileArgs: extraFiles, pkgbuildURL: pkgbuildURL,
program: filepath.Join(scratch, plan.executable), output: output, errorOutput: errorOutput,
}
if program != "" {
diff --git a/cmd/viva/package_tarball.go b/cmd/viva/package_tarball.go
index 298b2309..02ec2ad5 100644
--- a/cmd/viva/package_tarball.go
+++ b/cmd/viva/package_tarball.go
@@ -1,6 +1,8 @@
package main
import (
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"io"
"os"
@@ -10,6 +12,7 @@ import (
"github.com/vivaui/viva/tools/debpkg"
"github.com/vivaui/viva/tools/linuxicon"
"github.com/vivaui/viva/tools/manifest"
+ "github.com/vivaui/viva/tools/pkgbuild"
"github.com/vivaui/viva/tools/tarball"
)
@@ -18,10 +21,13 @@ import (
// them, so a .deb has to say so itself.
var linuxRuntimeDepends = []string{"libvulkan1", "libxcb1", "libcairo2"}
-// packageLinux builds the program and writes the Linux packages asked for, a
-// .tar.gz with an install script and a .deb, into the output directory. Both
-// are written beside their final names and renamed once every one of them is
-// complete, so a failure leaves earlier output alone.
+// linuxArchRuntimeDepends are the same libraries under Arch's names.
+var linuxArchRuntimeDepends = []string{"vulkan-icd-loader", "libxcb", "cairo"}
+
+// packageLinux builds the program and writes the Linux packages asked for: a
+// .tar.gz with an install script, a .deb, and a PKGBUILD with its .SRCINFO for
+// an Arch binary package. Each is written beside its final name and renamed
+// once every one of them is complete, so a failure leaves earlier output alone.
func packageLinux(request packageRequest) error {
plan, loaded, output := request.plan, request.plan.manifest, request.output
dest := request.out
@@ -79,35 +85,47 @@ func packageLinux(request packageRequest) error {
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
- write := func(label, name string, build func(io.Writer) error) error {
+ // write writes one artifact and returns its SHA-256, which the PKGBUILD
+ // needs for the archive it installs.
+ write := func(label, name string, build func(io.Writer) error) (string, error) {
final := filepath.Join(dest, name)
file, err := os.CreateTemp(dest, "."+name+".viva-tmp-*")
if err != nil {
- return err
+ return "", err
}
item := artifact{label: label, temporary: file.Name(), final: final}
artifacts = append(artifacts, item)
- if err := build(file); err != nil {
+ digest := sha256.New()
+ if err := build(io.MultiWriter(file, digest)); err != nil {
file.Close()
- return err
+ return "", err
}
if err := file.Chmod(0o644); err != nil {
file.Close()
- return err
+ return "", err
+ }
+ if err := file.Close(); err != nil {
+ return "", err
}
- return file.Close()
+ return hex.EncodeToString(digest.Sum(nil)), nil
}
- if request.wants("tar.gz") {
+ var largest linuxicon.Rendition
+ if len(icons) > 0 {
+ largest = linuxicon.Largest(icons)
+ }
+ tarballName, tarballHash := "", ""
+ if request.wants("tar.gz") || request.wants("pkgbuild") {
spec := tarball.Linux{
Executable: plan.executable, Name: loaded.App.Name, Version: loaded.App.Version, Arch: plan.arch,
Comment: loaded.Linux.Comment, Category: category, Keywords: loaded.Linux.Keywords, URLSchemes: loaded.App.URLSchemes, FileTypes: linuxFileTypes(loaded.App.FileTypes), Program: program,
}
- if len(icons) > 0 {
- largest := linuxicon.Largest(icons)
+ if largest.Pixels > 0 {
spec.Icon, spec.IconPixels = largest.PNG, largest.Pixels
}
- if err := write("tar.gz", spec.Directory()+".tar.gz", spec.Write); err != nil {
+ tarballName = spec.Directory() + ".tar.gz"
+ tarballHash, err = write("tar.gz", tarballName, spec.Write)
+ if err != nil {
return err
}
}
@@ -124,7 +142,39 @@ func packageLinux(request packageRequest) error {
if debName, err = spec.FileName(); err != nil {
return err
}
- if err := write("deb", debName, spec.Write); err != nil {
+ if _, err := write("deb", debName, spec.Write); err != nil {
+ return err
+ }
+ }
+ if request.wants("pkgbuild") {
+ maintainer := loaded.Linux.Maintainer
+ if maintainer == "" {
+ maintainer = loaded.App.Name
+ }
+ spec := pkgbuild.Package{
+ Executable: plan.executable, ID: loaded.App.ID, Name: loaded.App.Name, Version: loaded.App.Version, Arch: plan.arch,
+ Maintainer: maintainer, Homepage: loaded.Linux.Homepage, Comment: loaded.Linux.Comment,
+ Depends: archDepends(loaded.Linux.ArchDepends), Archive: tarballName, SHA256: tarballHash,
+ SourceURL: request.pkgbuildURL, IconPixels: largest.Pixels,
+ }
+ text, err := spec.PKGBUILD()
+ if err != nil {
+ return err
+ }
+ if _, err := write("pkgbuild", "PKGBUILD", func(w io.Writer) error {
+ _, err := w.Write(text)
+ return err
+ }); err != nil {
+ return err
+ }
+ srcinfo, err := spec.SRCINFO()
+ if err != nil {
+ return err
+ }
+ if _, err := write("srcinfo", ".SRCINFO", func(w io.Writer) error {
+ _, err := w.Write(srcinfo)
+ return err
+ }); err != nil {
return err
}
}
@@ -179,6 +229,45 @@ func debDepends(own []string) []string {
return append(depends, own...)
}
+// archDepends is debDepends for Arch, whose package names differ and whose
+// relations may be written without spaces (libxcb>=1.16).
+func archDepends(own []string) []string {
+ depends := make([]string, 0, len(linuxArchRuntimeDepends)+len(own))
+ for _, library := range linuxArchRuntimeDepends {
+ named := false
+ for _, dependency := range own {
+ if archDependencyName(dependency) == library {
+ named = true
+ }
+ }
+ if !named {
+ depends = append(depends, library)
+ }
+ }
+ seen := make(map[string]bool, len(depends)+len(own))
+ for _, dependency := range depends {
+ seen[archDependencyName(dependency)] = true
+ }
+ for _, dependency := range own {
+ if name := archDependencyName(dependency); !seen[name] {
+ seen[name] = true
+ depends = append(depends, dependency)
+ }
+ }
+ return depends
+}
+
+// archDependencyName is the package name of an Arch dependency without its
+// version relation.
+func archDependencyName(dependency string) string {
+ for _, relation := range []string{">=", "<=", ">", "<", "="} {
+ if before, _, ok := strings.Cut(dependency, relation); ok {
+ return strings.TrimSpace(before)
+ }
+ }
+ return strings.TrimSpace(dependency)
+}
+
// loadLinuxIcon reads the [app] icon, which must be a PNG, and renders its
// hicolor sizes.
func loadLinuxIcon(path string) ([]linuxicon.Rendition, int, error) {
diff --git a/cmd/viva/package_tarball_test.go b/cmd/viva/package_tarball_test.go
index 59737ca0..c542b474 100644
--- a/cmd/viva/package_tarball_test.go
+++ b/cmd/viva/package_tarball_test.go
@@ -4,9 +4,12 @@ import (
"archive/tar"
"bytes"
"compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"io"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
@@ -359,3 +362,90 @@ extensions = ["note"]
t.Errorf("mime package:\n%s", types)
}
}
+
+const linuxArchTOML = linuxTOML + `arch_depends = ["libxcb>=1.16", "cairo", "extra-lib"]
+`
+
+func TestPackageLinuxMakesAPKGBUILD(t *testing.T) {
+ dir := packageLinuxFixture(t, linuxArchTOML, 256)
+ output, err := runLinuxPackage(t, dir, "--format", "pkgbuild")
+ if err != nil {
+ t.Fatalf("%v\n%s", err, output)
+ }
+ for _, want := range []string{" tar.gz ", " pkgbuild ", " srcinfo "} {
+ if !strings.Contains(output, want) {
+ t.Errorf("the report lacks %q:\n%s", want, output)
+ }
+ }
+ archive := filepath.Join(dir, "build", "fixture-notes-2.0.1-linux-amd64.tar.gz")
+ data, err := os.ReadFile(archive)
+ if err != nil {
+ t.Fatalf("the tarball is not beside the PKGBUILD: %v", err)
+ }
+ digest := sha256.Sum256(data)
+ pkgbuildBytes, err := os.ReadFile(filepath.Join(dir, "build", "PKGBUILD"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pkgbuild := string(pkgbuildBytes)
+ for _, want := range []string{
+ "pkgname='fixture-notes-bin'",
+ "pkgver='2.0.1'",
+ "arch=('x86_64')",
+ "depends=('vulkan-icd-loader' 'libxcb>=1.16' 'cairo' 'extra-lib')",
+ "source=('fixture-notes-2.0.1-linux-amd64.tar.gz')",
+ "sha256sums=('" + hex.EncodeToString(digest[:]) + "')",
+ "_icon_size=256",
+ } {
+ if !strings.Contains(pkgbuild, want) {
+ t.Errorf("the PKGBUILD lacks %q:\n%s", want, pkgbuild)
+ }
+ }
+ if strings.Contains(pkgbuild, "libfoo1") {
+ t.Errorf("a Debian dependency reached the Arch package:\n%s", pkgbuild)
+ }
+ srcinfo, err := os.ReadFile(filepath.Join(dir, "build", ".SRCINFO"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"pkgbase = fixture-notes-bin", "\tarch = x86_64", "\tdepends = extra-lib", "pkgname = fixture-notes-bin"} {
+ if !strings.Contains(string(srcinfo), want) {
+ t.Errorf("the .SRCINFO lacks %q:\n%s", want, srcinfo)
+ }
+ }
+ if bash, err := exec.LookPath("bash"); err == nil {
+ command := exec.Command(bash, "-n")
+ command.Stdin = bytes.NewReader(pkgbuildBytes)
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("bash -n: %v\n%s", err, output)
+ }
+ }
+}
+
+func TestPackageLinuxPKGBUILDSourceURL(t *testing.T) {
+ dir := packageLinuxFixture(t, linuxTOML, 256)
+ url := "https://example.com/downloads/fixture-notes-2.0.1-linux-amd64.tar.gz"
+ output, err := runLinuxPackage(t, dir, "--format", "pkgbuild", "--pkgbuild-url", url)
+ if err != nil {
+ t.Fatalf("%v\n%s", err, output)
+ }
+ data, err := os.ReadFile(filepath.Join(dir, "build", "PKGBUILD"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "source=('fixture-notes-2.0.1-linux-amd64.tar.gz::" + url + "')"
+ if !strings.Contains(string(data), want) {
+ t.Fatalf("the PKGBUILD lacks %q:\n%s", want, data)
+ }
+}
+
+func TestPackageLinuxRefusesABadPKGBUILDURL(t *testing.T) {
+ dir := packageLinuxFixture(t, linuxTOML, 256)
+ output, err := runLinuxPackage(t, dir, "--format", "pkgbuild", "--pkgbuild-url", "file:///tmp/app.tar.gz")
+ if err == nil {
+ t.Fatalf("a file URL was accepted:\n%s", output)
+ }
+ if _, statErr := os.Stat(filepath.Join(dir, "build", "PKGBUILD")); !os.IsNotExist(statErr) {
+ t.Errorf("a PKGBUILD was written after the failure: %v", statErr)
+ }
+}
diff --git a/cmd/viva/templates/code_editor.go.txt b/cmd/viva/templates/code_editor.go.txt
index 06359803..b574f35e 100644
--- a/cmd/viva/templates/code_editor.go.txt
+++ b/cmd/viva/templates/code_editor.go.txt
@@ -35,15 +35,16 @@ type codeExtraCaret struct {
}
// CodeEditorGutterMarker decorates one zero-based logical line with a dot in the
-// gutter. It is purely visual: it is not hit-tested and is not exposed to
-// assistive technology, so state it carries must also be available elsewhere.
-// Markers are sorted by Line and unique; a wrapped line paints its marker only
-// once. They follow logical line numbers, not edits, so the caller updates the
-// slice when lines are inserted or removed. Unsorted or repeated lines are not
-// painted.
+// gutter. A nonzero ID exposes that line's marker lane as a button; the caller
+// owns the ID and handles clicks through the canvas. Markers are sorted by Line
+// and unique; a wrapped line paints and exposes its marker only once. They follow
+// logical line numbers, not edits, so the caller updates the slice when lines are
+// inserted or removed. Unsorted or repeated lines are not painted or hit-tested.
type CodeEditorGutterMarker struct {
Line int
+ ID gfx.ID
Color gfx.Color
+ Label string
}
// CodeEditorFoldRange hides logical lines after Header through LastLine,
@@ -493,7 +494,12 @@ func (editor *CodeEditor) Paint(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.
canvas.Label(snapshot.Placeholder, gfx.Rect{X: content.X, Y: content.Y + (lineHeight-cellHeight)/2, Width: content.Width, Height: cellHeight}, style)
}
currentLine := -1
- if snapshot.CurrentLine && selectionStart == selectionEnd && len(selectionRanges) == 0 && !placeholder {
+ // The caret's line is tinted even while the document is empty: editing
+ // surfaces (CodeMirror's highlightActiveLine, VS Code) paint a row highlight
+ // wherever the caret sits, including the single empty row of a fresh
+ // document. Suppressing it on the placeholder row would make an empty
+ // editor's first line the only line in the widget without a background.
+ if snapshot.CurrentLine && selectionStart == selectionEnd && len(selectionRanges) == 0 {
currentLine = int(editor.rows[caretRow].line)
}
markerIndex := 0
@@ -556,6 +562,38 @@ func (editor *CodeEditor) Paint(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.
}
canvas.HitWithOptions(snapshot.ID, bounds, snapshot.Label, options)
}
+ if markerLane > 0 && len(snapshot.GutterMarkers) != 0 {
+ markerIndex := 0
+ if firstRow < len(editor.rows) {
+ firstLine := int(editor.rows[firstRow].line)
+ markerIndex = sort.Search(len(snapshot.GutterMarkers), func(i int) bool { return snapshot.GutterMarkers[i].Line >= firstLine })
+ }
+ for index := firstRow; index < len(editor.rows); index++ {
+ y := content.Y + float32(index)*lineHeight - editor.scrollY
+ if y >= content.Y+content.Height {
+ break
+ }
+ if !editor.firstOfLine(index) {
+ continue
+ }
+ row := editor.rows[index]
+ for markerIndex < len(snapshot.GutterMarkers) && snapshot.GutterMarkers[markerIndex].Line < int(row.line) {
+ markerIndex++
+ }
+ if markerIndex >= len(snapshot.GutterMarkers) || snapshot.GutterMarkers[markerIndex].Line != int(row.line) {
+ continue
+ }
+ marker := snapshot.GutterMarkers[markerIndex]
+ markerIndex++
+ if !marker.ID.IsZero() {
+ label := marker.Label
+ if label == "" {
+ label = "Gutter marker"
+ }
+ canvas.HitWithOptions(marker.ID, gfx.Rect{X: markerX, Y: y, Width: markerLane, Height: lineHeight}, label, gfx.HitOptions{Role: gfx.RoleButton, Disabled: snapshot.Disabled})
+ }
+ }
+ }
if foldLane > 0 && len(snapshot.FoldMarkers) != 0 {
canvas.Clip(gfx.Rect{X: foldX, Y: content.Y, Width: foldLane, Height: content.Height})
markerIndex := 0
diff --git a/cmd/viva/templates/color_swatch.go.txt b/cmd/viva/templates/color_swatch.go.txt
index 62d5dec8..c6354dae 100644
--- a/cmd/viva/templates/color_swatch.go.txt
+++ b/cmd/viva/templates/color_swatch.go.txt
@@ -13,6 +13,7 @@ type ColorSwatch struct {
label string
selected, disabled bool
skipTab bool
+ flush bool
}
// BeginColorSwatch centers a color disc inside bounds, leaving room for its
@@ -25,6 +26,12 @@ func (s ColorSwatch) Label(label string) ColorSwatch { s.label = label; retu
func (s ColorSwatch) Selected(selected bool) ColorSwatch { s.selected = selected; return s }
func (s ColorSwatch) Disabled() ColorSwatch { s.disabled = true; return s }
+// Flush paints the colour disc against the border instead of leaving a
+// background-coloured ring between them. A swatch in a dense row then reads as
+// one solid colour inside a hairline circle rather than a smaller disc with a
+// visible gap.
+func (s ColorSwatch) Flush() ColorSwatch { s.flush = true; return s }
+
// TabStop says whether Tab may enter this choice. A radio set gives its
// selected choice the tab stop, marks the others false, and uses
// RadioGroupKeyDown for arrow navigation.
@@ -63,9 +70,14 @@ func (s ColorSwatch) Paint() ColorSwatchResult {
border.Alpha *= 0.4
}
s.canvas.Rounded(b, side/2, border)
- inner := gfx.Rect{X: b.X + 2, Y: b.Y + 2, Width: side - 4, Height: side - 4}
- s.canvas.Rounded(inner, inner.Width/2, s.theme.Background)
- disc := gfx.Rect{X: b.X + 5, Y: b.Y + 5, Width: side - 10, Height: side - 10}
+ var disc gfx.Rect
+ if s.flush {
+ disc = gfx.Rect{X: b.X + 1, Y: b.Y + 1, Width: max(side-2, 0), Height: max(side-2, 0)}
+ } else {
+ inner := gfx.Rect{X: b.X + 2, Y: b.Y + 2, Width: side - 4, Height: side - 4}
+ s.canvas.Rounded(inner, inner.Width/2, s.theme.Background)
+ disc = gfx.Rect{X: b.X + 5, Y: b.Y + 5, Width: side - 10, Height: side - 10}
+ }
color := s.color
if color.Alpha == 0 {
color = s.theme.Muted
diff --git a/cmd/viva/templates/dropdown_menu.go.txt b/cmd/viva/templates/dropdown_menu.go.txt
index 87871b41..973533a9 100644
--- a/cmd/viva/templates/dropdown_menu.go.txt
+++ b/cmd/viva/templates/dropdown_menu.go.txt
@@ -950,6 +950,11 @@ func (menu *menuSurface) paintItem(bounds gfx.Rect, item menuItem) {
// keep working under it.
hovered := canvas.Hovered()
claim := canvas.HoverCrossed() && !hovered.IsZero()
+ // While the pointer is over caller content inside the menu (a colour row, a
+ // switch), no row is the menu's current row: keeping the last focused row
+ // tinted would fight the control under the pointer. Menu rows themselves
+ // declare RoleMenuItem.
+ inContent := !hovered.IsZero() && canvas.HoveredRole() != 0 && canvas.HoveredRole() != gfx.RoleMenuItem
if claim && hovered == item.ID {
// A disabled row cannot take focus, but the frame is still repainted so
// the row that kept focus is tinted again on the next paint.
@@ -960,7 +965,7 @@ func (menu *menuSurface) paintItem(bounds gfx.Rect, item menuItem) {
}
// Focused, not FocusVisible: a menu row the pointer focused is the current
// row even though no key press made its focus ring visible.
- active := enabled && (claim && hovered == item.ID || !claim && canvas.Focused() == item.ID)
+ active := enabled && !inContent && (claim && hovered == item.ID || !claim && canvas.Focused() == item.ID)
if item.Kind == menuSubmenu && item.Expanded {
active = true
}
diff --git a/cmd/viva/templates/input.go.txt b/cmd/viva/templates/input.go.txt
index 3a17cca7..6b638afe 100644
--- a/cmd/viva/templates/input.go.txt
+++ b/cmd/viva/templates/input.go.txt
@@ -12,8 +12,13 @@ import (
// it a password field: the value is never exposed, DisplayValue is ignored,
// and the field paints one bullet per character.
type InputSnapshot struct {
- ID gfx.ID
- Label string
+ ID gfx.ID
+ Label string
+ // Alignment places the text within the content rectangle. Zero preserves
+ // the ordinary direction-dependent start alignment, which is left-to-right
+ // for Latin text. AlignRight suits a numeric field. The label, caret,
+ // selection, composition and platform editor all take the same alignment.
+ Alignment gfx.Alignment
Value string
DisplayValue string
Placeholder string
@@ -133,6 +138,9 @@ func PaintInputContent(canvas *gfx.Canvas, theme gfx.Theme, bounds, content gfx.
// is where the glyphs are painted: a content rectangle exactly one line tall is its
// own line box, and a taller one (a padded field, or the whole control) is not.
func paintInputContent(canvas *gfx.Canvas, theme gfx.Theme, bounds, content gfx.Rect, snapshot InputSnapshot, focused bool) {
+ if snapshot.Alignment == gfx.AlignLeft {
+ snapshot.Alignment = gfx.AlignStart
+ }
style := snapshot.Text.style(&theme)
// A mono field is painted by the component whatever the snapshot says of the platform: the
// platform's text service cannot draw its face, so its text and overlays are hidden. A
@@ -170,7 +178,7 @@ func paintInputContent(canvas *gfx.Canvas, theme gfx.Theme, bounds, content gfx.
Role: gfx.RoleTextField, Cursor: gfx.PointerCursorText, Value: snapshot.Value, Hint: snapshot.Hint,
Disabled: snapshot.Disabled, ReadOnly: snapshot.ReadOnly, Secure: snapshot.Secure,
NavigationKeys: snapshot.NavigationKeys,
- TextBounds: textBounds, TextSize: size, TextWeight: weight, TextColor: platformInk,
+ TextBounds: textBounds, TextSize: size, TextWeight: weight, TextColor: platformInk, TextAlignment: snapshot.Alignment,
HideNativeOverlays: selfPainted,
})
}
@@ -215,10 +223,9 @@ func inputPaintLine(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.Rect, value,
placeholderShown := snapshot.Value == "" && snapshot.DisplayValue == ""
style := textStyle
style.Color = color
- // The line starts where the label puts it: the right end of the field for text that
- // reads right to left. The caret, selection and composition are measured from there,
- // and follow the visual order of mixed text (a selection may be several stretches).
- origin := canvas.LabelOrigin(value, bounds, style, gfx.AlignStart)
+ // The caret, selection and composition share the glyphs' origin and follow
+ // the visual order of mixed text (a selection may be several stretches).
+ origin := canvas.LabelOrigin(value, bounds, style, snapshot.Alignment)
var ranges [4]gfx.Rect
selectionStart, selectionEnd := inputRange(value, snapshot.SelectionStart, snapshot.SelectionEnd)
var selections []gfx.Rect
@@ -255,7 +262,7 @@ func inputPaintLine(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.Rect, value,
// bounds, and those should describe the glyph line the field draws.
lineTop, lineHeight := inputLineBox(bounds, fontSize)
line := gfx.Rect{X: bounds.X, Y: lineTop, Width: bounds.Width, Height: lineHeight}
- canvas.Label(value, line, style)
+ canvas.LabelAligned(value, line, style, snapshot.Alignment)
for _, selection := range selections {
if selection.Width <= 0 {
continue
@@ -267,7 +274,7 @@ func inputPaintLine(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.Rect, value,
}
selected := textStyle
selected.Color = selectedColor
- canvas.Label(value, line, selected)
+ canvas.LabelAligned(value, line, selected, snapshot.Alignment)
canvas.Unclip()
}
canvas.Unclip()
diff --git a/cmd/viva/templates/scroll_area.go.txt b/cmd/viva/templates/scroll_area.go.txt
index d2561cc6..d91801cc 100644
--- a/cmd/viva/templates/scroll_area.go.txt
+++ b/cmd/viva/templates/scroll_area.go.txt
@@ -9,6 +9,16 @@ const (
scrollAreaMinThumb float32 = 18
)
+// ScrollBarStyle overrides the geometry of a scroll area's bars. Zero fields keep
+// Viva's own values, so an application changes only what it needs: IDEA's editor bar
+// is an eleven point track with a five point thumb and a shorter least thumb.
+type ScrollBarStyle struct {
+ Size float32 // the track width; 0 keeps 10
+ HoverSize float32 // the width of the hover overlay thumb; 0 keeps 6
+ Inset float32 // the gap between a bar and its track's edge; 0 keeps 1
+ MinThumb float32 // the least thumb length; 0 keeps 18
+}
+
// ScrollAreaGeometry is the complete deterministic layout of one externally
// controlled scroll area. Content is already translated by the clamped offset.
type ScrollAreaGeometry struct {
@@ -20,10 +30,13 @@ type ScrollAreaGeometry struct {
HorizontalTrack gfx.Rect
HorizontalThumb gfx.Rect
Corner gfx.Rect
- OffsetX float32
- OffsetY float32
- MaxOffsetX float32
- MaxOffsetY float32
+ // Inset is the gap the bars keep from their tracks' edges, so a caller turning a drag
+ // on a thumb into an offset uses the same value the area drew with.
+ Inset float32
+ OffsetX float32
+ OffsetY float32
+ MaxOffsetX float32
+ MaxOffsetY float32
}
// ScrollArea paints the visible scrollbars around a caller-painted clipped
@@ -49,7 +62,9 @@ type ScrollArea struct {
reserveBars bool
hoverBars bool
barsRevealed bool
+ bar ScrollBarStyle
barWidth float32
+ frozen float32
contentClipIsActive bool
}
@@ -67,7 +82,9 @@ func (area ScrollArea) Offset(x, y float32) ScrollArea {
}
// VerticalScrollbar assigns the stable action ID and accessibility strings for
-// the vertical thumb. ID zero keeps the visual geometry non-interactive.
+// the vertical thumb. ID zero keeps the visual geometry non-interactive. An ID
+// zero with an empty label omits the overlay while preserving its geometry, for
+// a caller that paints the vertical bar itself.
func (area ScrollArea) VerticalScrollbar(id gfx.ID, label, value string) ScrollArea {
area.verticalID = id
area.verticalLabel = label
@@ -125,6 +142,19 @@ func (area ScrollArea) ScrollbarSize(size float32) ScrollArea {
return area
}
+// Frozen declares that the leading px of the content stays pinned at the
+// viewport's leading edge while the rest scrolls, as the frozen columns of a
+// table stay put. The pinned width leaves MaxOffsetX the value an unfrozen
+// area reports — the offset never scrolls the pinned columns — while the
+// horizontal track, thumb and drag mapping shrink to the window past them and
+// span exactly what scrolls. A zero or negative px, one with nothing to scroll
+// around, or one at or past the viewport, freezes nothing. The vertical
+// geometry is the unfrozen one.
+func (area ScrollArea) Frozen(px float32) ScrollArea {
+ area.frozen = px
+ return area
+}
+
// Disabled preserves geometry and typed semantics while making both scrollbars inert.
func (area ScrollArea) Disabled() ScrollArea {
area.disabled = true
@@ -138,13 +168,44 @@ func (area ScrollArea) barSize() float32 {
return width
}
if area.hoverBars {
+ if area.bar.HoverSize > 0 {
+ return area.bar.HoverSize
+ }
return scrollAreaHoverBarSize
}
+ if area.bar.Size > 0 {
+ return area.bar.Size
+ }
return scrollAreaBarSize
}
+// barInset is the gap between a bar and its track's edge.
+func (area ScrollArea) barInset() float32 {
+ if area.bar.Inset > 0 {
+ return area.bar.Inset
+ }
+ return scrollAreaBarInset
+}
+
+// minThumb is the least length of a thumb.
+func (area ScrollArea) minThumb() float32 {
+ if area.bar.MinThumb > 0 {
+ return area.bar.MinThumb
+ }
+ return scrollAreaMinThumb
+}
+
+// Bars overrides the geometry of both bars; the zero style keeps Viva's own.
+func (area ScrollArea) Bars(style ScrollBarStyle) ScrollArea {
+ area.bar = style
+ return area
+}
+
// Geometry returns clamped viewport, translated content, track, thumb, and
-// corner geometry without painting or changing Canvas state.
+// corner geometry without painting or changing Canvas state. A frozen area
+// keeps the unfrozen content, offset and vertical geometry; only the
+// horizontal track, thumb and corner move to the scrolling window past the
+// pinned band, spanning exactly what scrolls.
func (area ScrollArea) Geometry() ScrollAreaGeometry {
bounds := scrollAreaNormalizeRect(area.bounds)
barSize := area.barSize()
@@ -161,6 +222,16 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
contentHeight = viewport.Height
}
+ // A pinned band needs a window left to scroll in — something to either
+ // side of it, and a width inside the viewport — and changes nothing about
+ // the translation: Content keeps the unfrozen origin, because the caller
+ // clips the pinned columns out of it and paints them itself at the
+ // viewport's edge. Only the horizontal bar belongs to the scrolling window
+ // past the band.
+ frozen := area.frozen
+ if frozen <= 0 || frozen >= viewport.Width || contentWidth <= viewport.Width {
+ frozen = 0
+ }
maximumX := scrollAreaMax(contentWidth-viewport.Width, 0)
maximumY := scrollAreaMax(contentHeight-viewport.Height, 0)
offsetX := scrollAreaClamp(area.offsetX, 0, maximumX)
@@ -168,19 +239,23 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
hasHorizontal := maximumX > 0 && viewport.Height > 0
hasVertical := maximumY > 0 && viewport.Width > 0
+ contentX := viewport.X - offsetX
geometry := ScrollAreaGeometry{
Bounds: bounds, Viewport: viewport,
Content: gfx.Rect{
- X: viewport.X - offsetX, Y: viewport.Y - offsetY,
+ X: contentX, Y: viewport.Y - offsetY,
Width: contentWidth, Height: contentHeight,
},
OffsetX: offsetX, OffsetY: offsetY,
MaxOffsetX: maximumX, MaxOffsetY: maximumY,
+ Inset: area.barInset(),
}
// The bars lie along the outer bounds. Where the viewport already excludes them
// (ReserveScrollbars) their extents are the viewport's; otherwise the corner is
- // taken out of whichever track shares it.
+ // taken out of whichever track shares it. With a pinned band the horizontal bar
+ // starts at the band's far edge: its thumb spans the scrolling window, and the
+ // track covers the content its offset can reach.
verticalExtent := bounds.Height
horizontalExtent := bounds.Width
if hasHorizontal && hasVertical {
@@ -197,14 +272,20 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
X: bounds.X + bounds.Width - barSize, Y: bounds.Y,
Width: barSize, Height: verticalExtent,
}
- geometry.VerticalThumb = scrollAreaThumb(geometry.VerticalTrack, true, viewport.Height, contentHeight, offsetY, maximumY)
+ geometry.VerticalThumb = scrollAreaThumb(geometry.VerticalTrack, true, viewport.Height, contentHeight, offsetY, maximumY, area.barInset(), area.minThumb())
}
if hasHorizontal {
+ trackX := bounds.X
+ horizontalViewport := viewport.Width
+ if frozen > 0 {
+ trackX = bounds.X + frozen
+ horizontalViewport = viewport.Width - frozen
+ }
geometry.HorizontalTrack = gfx.Rect{
- X: bounds.X, Y: bounds.Y + bounds.Height - barSize,
- Width: horizontalExtent, Height: barSize,
+ X: trackX, Y: bounds.Y + bounds.Height - barSize,
+ Width: scrollAreaMax(horizontalExtent-(trackX-bounds.X), 0), Height: barSize,
}
- geometry.HorizontalThumb = scrollAreaThumb(geometry.HorizontalTrack, false, viewport.Width, contentWidth, offsetX, maximumX)
+ geometry.HorizontalThumb = scrollAreaThumb(geometry.HorizontalTrack, false, horizontalViewport, contentWidth-frozen, offsetX, maximumX, area.barInset(), area.minThumb())
}
return geometry
}
@@ -245,11 +326,12 @@ func scrollAreaReservedViewport(bounds gfx.Rect, contentWidth, contentHeight, ba
// owned offset with it, without repeating the track insets. It is zero when
// the area does not scroll vertically.
func (geometry ScrollAreaGeometry) VerticalOffsetForThumb(thumbTop float32) float32 {
- travel := scrollAreaMax(geometry.VerticalTrack.Height-scrollAreaBarInset*2, 0) - geometry.VerticalThumb.Height
+ inset := geometry.inset()
+ travel := scrollAreaMax(geometry.VerticalTrack.Height-inset*2, 0) - geometry.VerticalThumb.Height
if travel <= 0 || geometry.MaxOffsetY <= 0 {
return 0
}
- ratio := (thumbTop - (geometry.VerticalTrack.Y + scrollAreaBarInset)) / travel
+ ratio := (thumbTop - (geometry.VerticalTrack.Y + inset)) / travel
return scrollAreaClamp(ratio*geometry.MaxOffsetY, 0, geometry.MaxOffsetY)
}
@@ -257,14 +339,24 @@ func (geometry ScrollAreaGeometry) VerticalOffsetForThumb(thumbTop float32) floa
// of the horizontal thumb at thumbLeft. It is zero when the area does not scroll
// horizontally.
func (geometry ScrollAreaGeometry) HorizontalOffsetForThumb(thumbLeft float32) float32 {
- travel := scrollAreaMax(geometry.HorizontalTrack.Width-scrollAreaBarInset*2, 0) - geometry.HorizontalThumb.Width
+ inset := geometry.inset()
+ travel := scrollAreaMax(geometry.HorizontalTrack.Width-inset*2, 0) - geometry.HorizontalThumb.Width
if travel <= 0 || geometry.MaxOffsetX <= 0 {
return 0
}
- ratio := (thumbLeft - (geometry.HorizontalTrack.X + scrollAreaBarInset)) / travel
+ ratio := (thumbLeft - (geometry.HorizontalTrack.X + inset)) / travel
return scrollAreaClamp(ratio*geometry.MaxOffsetX, 0, geometry.MaxOffsetX)
}
+// inset is the gap the bars keep from their tracks' edges: what the area drew with, or
+// Viva's own when the geometry came from an area with the default style.
+func (geometry ScrollAreaGeometry) inset() float32 {
+ if geometry.Inset > 0 {
+ return geometry.Inset
+ }
+ return scrollAreaBarInset
+}
+
// BeginContent pushes the viewport clip and returns translated content geometry.
// Call EndContent after painting the caller-owned content.
func (area *ScrollArea) BeginContent() ScrollAreaGeometry {
@@ -295,6 +387,9 @@ func (area *ScrollArea) EndContent() ScrollAreaGeometry {
}
func (area ScrollArea) paintScrollbar(track, thumb gfx.Rect, id gfx.ID, label, value string) {
+ if id.IsZero() && label == "" {
+ return
+ }
if area.hoverBars && !area.barsRevealed {
return
}
@@ -356,11 +451,11 @@ func (area ScrollArea) ScrollForKey(x, y float32, key gfx.Key, step float32) (fl
return scrollAreaClamp(x, 0, geometry.MaxOffsetX), scrollAreaClamp(y, 0, geometry.MaxOffsetY), true
}
-func scrollAreaThumb(track gfx.Rect, vertical bool, viewportExtent, contentExtent, offset, maximum float32) gfx.Rect {
+func scrollAreaThumb(track gfx.Rect, vertical bool, viewportExtent, contentExtent, offset, maximum, inset, minThumb float32) gfx.Rect {
inner := gfx.Rect{
- X: track.X + scrollAreaBarInset, Y: track.Y + scrollAreaBarInset,
- Width: scrollAreaMax(track.Width-scrollAreaBarInset*2, 0),
- Height: scrollAreaMax(track.Height-scrollAreaBarInset*2, 0),
+ X: track.X + inset, Y: track.Y + inset,
+ Width: scrollAreaMax(track.Width-inset*2, 0),
+ Height: scrollAreaMax(track.Height-inset*2, 0),
}
trackExtent := inner.Width
if vertical {
@@ -370,7 +465,7 @@ func scrollAreaThumb(track gfx.Rect, vertical bool, viewportExtent, contentExten
return gfx.Rect{}
}
thumbExtent := trackExtent * viewportExtent / contentExtent
- thumbExtent = scrollAreaClamp(thumbExtent, scrollAreaMin(scrollAreaMinThumb, trackExtent), trackExtent)
+ thumbExtent = scrollAreaClamp(thumbExtent, scrollAreaMin(minThumb, trackExtent), trackExtent)
position := float32(0)
if maximum > 0 {
position = (trackExtent - thumbExtent) * offset / maximum
diff --git a/cmd/viva/templates/tooltip.go.txt b/cmd/viva/templates/tooltip.go.txt
index edae11d2..32c1d4fd 100644
--- a/cmd/viva/templates/tooltip.go.txt
+++ b/cmd/viva/templates/tooltip.go.txt
@@ -91,6 +91,9 @@ type TooltipOptions struct {
SideOffset float32
AlignOffset float32
MaxWidth float32
+ // SideOf is the side HoverTooltip opens the tooltip of the hit id on, where
+ // Side has no say. Nil opens every one below its control.
+ SideOf func(id gfx.ID) TooltipSide
}
// Tooltip is one noninteractive Vega tooltip declaration.
@@ -122,21 +125,49 @@ func (tooltip Tooltip) Paint(label string) gfx.Rect {
return gfx.Rect{}
}
metrics := tooltip.theme.Metrics
+ style := tooltip.theme.Tooltip
+ padX, padY := float32(12), float32(6)
+ if style.PadX > 0 {
+ padX = style.PadX
+ }
+ if style.PadY > 0 {
+ padY = style.PadY
+ }
+ fontSize := metrics.FontSizeS
+ if style.FontSize > 0 {
+ fontSize = style.FontSize
+ }
+ radius := metrics.RadiusControl
+ if style.Radius > 0 {
+ radius = style.Radius
+ }
+ background, text := tooltip.theme.Foreground, tooltip.theme.Background
+ if style.Background != (gfx.Color{}) {
+ background = style.Background
+ }
+ if style.Text != (gfx.Color{}) {
+ text = style.Text
+ }
maxWidth := tooltip.options.MaxWidth
if maxWidth <= 0 {
maxWidth = 320
}
maxWidth = min(maxWidth, tooltip.viewport.Width-8)
- const padX, padY = float32(12), float32(6)
- var scratch overlayLines
- lines := overlayWrap(tooltip.canvas, &scratch, label, metrics.FontSizeS, gfx.WeightRegular, maxWidth-2*padX)
+ // A filename or another unbroken token has no natural short form: keep all
+ // of it even when a bounded descriptive tooltip would use an ellipsis. Long
+ // tokens already break at whole grapheme clusters in the text engine. The
+ // viewport is a bound on excessive content, not an interactive text limit.
+ // The lines live in a stack scratch: a tooltip that is painted every frame
+ // must not allocate for wrapping that has not changed.
+ var scratch [8]string
+ lines := tooltip.canvas.WrapLines(scratch[:0], label, fontSize, gfx.WeightRegular, maxWidth-2*padX, int(tooltip.viewport.Height))
// One line takes the width of its text; a wrapped label fills the whole
// maximum, as a `w-fit max-w-xs` box does.
textWidth := maxWidth - 2*padX
if len(lines) == 1 {
- textWidth = tooltip.canvas.TextWidthWeight(lines[0], metrics.FontSizeS, gfx.WeightRegular)
+ textWidth = tooltip.canvas.TextWidthWeight(lines[0], fontSize, gfx.WeightRegular)
}
- lineHeight := tooltipLineHeight(metrics)
+ lineHeight := fontSize + 5
size := gfx.Size{Width: textWidth + 2*padX, Height: float32(len(lines))*lineHeight + 2*padY}
offset := tooltip.options.SideOffset
if offset == 0 {
@@ -149,19 +180,24 @@ func (tooltip Tooltip) Paint(label string) gfx.Rect {
tooltip.canvas.PushLayer(tooltip.options.Layer)
tooltip.canvas.BeginPresence(overlayKey(tooltip.options.ID), overlaySurfacePart,
popupPresence(tooltip.theme, tooltipDuration, side), popupPivot(content, side, tooltip.anchor))
- tooltip.canvas.Rounded(content, metrics.RadiusControl, tooltip.theme.Foreground)
+ if style.BorderSize > 0 && style.Border != (gfx.Color{}) {
+ // The border is a ring behind the body, so the body keeps its own corners.
+ ring := gfx.Rect{
+ X: content.X - style.BorderSize, Y: content.Y - style.BorderSize,
+ Width: content.Width + style.BorderSize*2, Height: content.Height + style.BorderSize*2,
+ }
+ tooltip.canvas.Rounded(ring, radius+style.BorderSize, style.Border)
+ }
+ tooltip.canvas.Rounded(content, radius, background)
overlayParagraph(tooltip.canvas, lines, gfx.Rect{X: content.X + padX, Y: content.Y + padY, Width: content.Width - 2*padX}, lineHeight, gfx.TextStyle{
- Size: metrics.FontSizeS, Weight: gfx.WeightRegular, Color: tooltip.theme.Background,
+ Size: fontSize, Weight: gfx.WeightRegular, Color: text,
}, false)
- tooltip.paintArrow(content, tooltipSideOf(side))
+ tooltip.paintArrow(content, tooltipSideOf(side), radius, background)
tooltip.canvas.EndPresence()
tooltip.canvas.PopLayer()
return content
}
-// tooltipLineHeight is the 16px line of `text-xs`.
-func tooltipLineHeight(metrics gfx.Metrics) float32 { return metrics.FontSizeS + 5 }
-
func tooltipOverlaySide(side TooltipSide) OverlaySide {
switch side {
case TooltipRight:
@@ -205,10 +241,11 @@ const (
// renderers feather; the triangle has no shared tessellation edges.
const tooltipArrowEdges = uint8(1<<3 - 1)
-func (tooltip Tooltip) paintArrow(content gfx.Rect, side TooltipSide) {
+// paintArrow points the body at its trigger in fill, the body's own surface, keeping the
+// base clear of a corner of the given radius.
+func (tooltip Tooltip) paintArrow(content gfx.Rect, side TooltipSide, radius float32, fill gfx.Color) {
// Center on the trigger, but keep the base on the flat part of the body so
// it never straddles a rounded corner after the body was clamped on screen.
- radius := tooltip.theme.Metrics.RadiusControl
half := tooltipArrowBase / 2
var points [3]gfx.Point
switch side {
@@ -247,7 +284,7 @@ func (tooltip Tooltip) paintArrow(content gfx.Rect, side TooltipSide) {
}
tooltip.canvas.AppendTriangle(gfx.Triangle{
Points: points,
- Color: tooltip.theme.Foreground,
+ Color: fill,
EdgeMask: tooltipArrowEdges,
})
}
@@ -308,9 +345,10 @@ func (hover *HoverTooltip) Dismiss() bool {
func (hover *HoverTooltip) ShowOnFocus() { hover.focus = true }
// Paint draws the tooltip of the hovered hit once it has been hovered for
-// delay, and reports whether it did. The tooltip goes below the control, or
-// above it when the viewport has no room below (see PlaceOverlay). It stays hidden while a button
-// is pressed and for hits without a Hint.
+// delay, and reports whether it did. The tooltip goes below the control, or on
+// the side options.SideOf names, and on the opposite side when the viewport has
+// no room there (see PlaceOverlay). It stays hidden while a button is pressed and
+// for hits without a Hint.
func (hover *HoverTooltip) Paint(canvas *gfx.Canvas, theme gfx.Theme, viewport gfx.Rect, options TooltipOptions, delay time.Duration) bool {
hovered := canvas.Hovered()
if !canvas.Pressed().IsZero() {
@@ -364,6 +402,9 @@ func (hover *HoverTooltip) Paint(canvas *gfx.Canvas, theme gfx.Theme, viewport g
return false
}
options.Side = TooltipBottom
+ if options.SideOf != nil {
+ options.Side = options.SideOf(id)
+ }
options.ID = id
BeginTooltip(canvas, theme, viewport, hit.Bounds, options).Paint(hit.Hint)
hover.shown = id
diff --git a/cmd/viva/templates/tree.go.txt b/cmd/viva/templates/tree.go.txt
index 11358c5a..05fdab1b 100644
--- a/cmd/viva/templates/tree.go.txt
+++ b/cmd/viva/templates/tree.go.txt
@@ -2,16 +2,28 @@ package {{PACKAGE}}
import "github.com/vivaui/viva/gfx"
+// TreeDisclosure replaces the chevrons a tree draws for its branches: the icon of a
+// closed and of an open branch, their size, and the width of the column they occupy
+// before the row's content. Zero fields keep Viva's own chevrons (16 points in a 24
+// point column), so an application that ships IDEA's arrows can use them without
+// moving the rows.
+type TreeDisclosure struct {
+ Collapsed, Expanded gfx.Icon
+ Size float32
+ Slot float32
+}
+
// Tree paints a compact vertical hierarchy. Applications own the hierarchy,
// expansion, selection, and scrolling; the component owns indentation,
// disclosure geometry, focus/hover feedback, and typed hit semantics.
type Tree struct {
- canvas *gfx.Canvas
- theme gfx.Theme
- bounds gfx.Rect
- cursorY float32
- rowHeight float32
- indent float32
+ canvas *gfx.Canvas
+ theme gfx.Theme
+ bounds gfx.Rect
+ cursorY float32
+ rowHeight float32
+ indent float32
+ disclosure TreeDisclosure
}
// TreeItem describes one already-flattened visible tree row. Branches use a
@@ -65,6 +77,22 @@ func (tree Tree) Indent(width float32) Tree {
return tree
}
+// Disclosure replaces the arrows the tree draws for branches and the width of the
+// column they occupy. Zero fields keep the tree's own chevrons.
+func (tree Tree) Disclosure(style TreeDisclosure) Tree {
+ tree.disclosure = style
+ return tree
+}
+
+// disclosureSlot is the width of the column before a branch's content: the requested
+// slot, or the 24 points Viva's own chevrons take.
+func (tree *Tree) disclosureSlot() float32 {
+ if tree.disclosure.Slot > 0 {
+ return tree.disclosure.Slot
+ }
+ return 24
+}
+
// Item paints one visible row and returns its caller-owned content area. The
// component never walks children or allocates a backing model.
func (tree *Tree) Item(item TreeItem) TreeRow {
@@ -96,12 +124,12 @@ func (tree *Tree) Item(item TreeItem) TreeRow {
disclosure := gfx.Rect{X: leading, Y: row.Y + (row.Height-20)/2, Width: 20, Height: 20}
contentX := leading
if item.Branch {
- contentX += 24
+ contentX += tree.disclosureSlot()
color := tree.theme.MutedForeground
if item.Disabled {
color.Alpha *= 0.50
}
- paintTreeChevron(tree.canvas, disclosure, item.Expanded, color)
+ tree.paintDisclosure(disclosure, item.Expanded, color)
if !item.DisclosureID.IsZero() {
tree.canvas.HitWithOptions(item.DisclosureID, disclosure, item.Label, gfx.HitOptions{
Role: gfx.RoleButton, Disabled: item.Disabled, Expanded: item.Expanded, Expandable: true,
@@ -122,6 +150,25 @@ func (tree *Tree) Item(item TreeItem) TreeRow {
return TreeRow{Bounds: row, Content: content, Disclosure: disclosure}
}
+// paintDisclosure draws the branch arrow: the requested icon and size, or the tree's
+// own chevron when the application supplied none.
+func (tree *Tree) paintDisclosure(bounds gfx.Rect, expanded bool, color gfx.Color) {
+ icon, size := &chevronRightIcon, float32(16)
+ if expanded {
+ icon = &chevronDownIcon
+ }
+ if tree.disclosure.Size > 0 {
+ size = tree.disclosure.Size
+ }
+ if expanded && len(tree.disclosure.Expanded.Commands) > 0 {
+ icon = &tree.disclosure.Expanded
+ }
+ if !expanded && len(tree.disclosure.Collapsed.Commands) > 0 {
+ icon = &tree.disclosure.Collapsed
+ }
+ paintChevronSized(tree.canvas, icon, bounds, size, color)
+}
+
func paintTreeChevron(canvas *gfx.Canvas, bounds gfx.Rect, expanded bool, color gfx.Color) {
icon := &chevronRightIcon
if expanded {
diff --git a/components/code_editor.go b/components/code_editor.go
index 055eb78e..7819088c 100644
--- a/components/code_editor.go
+++ b/components/code_editor.go
@@ -35,15 +35,16 @@ type codeExtraCaret struct {
}
// CodeEditorGutterMarker decorates one zero-based logical line with a dot in the
-// gutter. It is purely visual: it is not hit-tested and is not exposed to
-// assistive technology, so state it carries must also be available elsewhere.
-// Markers are sorted by Line and unique; a wrapped line paints its marker only
-// once. They follow logical line numbers, not edits, so the caller updates the
-// slice when lines are inserted or removed. Unsorted or repeated lines are not
-// painted.
+// gutter. A nonzero ID exposes that line's marker lane as a button; the caller
+// owns the ID and handles clicks through the canvas. Markers are sorted by Line
+// and unique; a wrapped line paints and exposes its marker only once. They follow
+// logical line numbers, not edits, so the caller updates the slice when lines are
+// inserted or removed. Unsorted or repeated lines are not painted or hit-tested.
type CodeEditorGutterMarker struct {
Line int
+ ID gfx.ID
Color gfx.Color
+ Label string
}
// CodeEditorFoldRange hides logical lines after Header through LastLine,
@@ -493,7 +494,12 @@ func (editor *CodeEditor) Paint(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.
canvas.Label(snapshot.Placeholder, gfx.Rect{X: content.X, Y: content.Y + (lineHeight-cellHeight)/2, Width: content.Width, Height: cellHeight}, style)
}
currentLine := -1
- if snapshot.CurrentLine && selectionStart == selectionEnd && len(selectionRanges) == 0 && !placeholder {
+ // The caret's line is tinted even while the document is empty: editing
+ // surfaces (CodeMirror's highlightActiveLine, VS Code) paint a row highlight
+ // wherever the caret sits, including the single empty row of a fresh
+ // document. Suppressing it on the placeholder row would make an empty
+ // editor's first line the only line in the widget without a background.
+ if snapshot.CurrentLine && selectionStart == selectionEnd && len(selectionRanges) == 0 {
currentLine = int(editor.rows[caretRow].line)
}
markerIndex := 0
@@ -556,6 +562,38 @@ func (editor *CodeEditor) Paint(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.
}
canvas.HitWithOptions(snapshot.ID, bounds, snapshot.Label, options)
}
+ if markerLane > 0 && len(snapshot.GutterMarkers) != 0 {
+ markerIndex := 0
+ if firstRow < len(editor.rows) {
+ firstLine := int(editor.rows[firstRow].line)
+ markerIndex = sort.Search(len(snapshot.GutterMarkers), func(i int) bool { return snapshot.GutterMarkers[i].Line >= firstLine })
+ }
+ for index := firstRow; index < len(editor.rows); index++ {
+ y := content.Y + float32(index)*lineHeight - editor.scrollY
+ if y >= content.Y+content.Height {
+ break
+ }
+ if !editor.firstOfLine(index) {
+ continue
+ }
+ row := editor.rows[index]
+ for markerIndex < len(snapshot.GutterMarkers) && snapshot.GutterMarkers[markerIndex].Line < int(row.line) {
+ markerIndex++
+ }
+ if markerIndex >= len(snapshot.GutterMarkers) || snapshot.GutterMarkers[markerIndex].Line != int(row.line) {
+ continue
+ }
+ marker := snapshot.GutterMarkers[markerIndex]
+ markerIndex++
+ if !marker.ID.IsZero() {
+ label := marker.Label
+ if label == "" {
+ label = "Gutter marker"
+ }
+ canvas.HitWithOptions(marker.ID, gfx.Rect{X: markerX, Y: y, Width: markerLane, Height: lineHeight}, label, gfx.HitOptions{Role: gfx.RoleButton, Disabled: snapshot.Disabled})
+ }
+ }
+ }
if foldLane > 0 && len(snapshot.FoldMarkers) != 0 {
canvas.Clip(gfx.Rect{X: foldX, Y: content.Y, Width: foldLane, Height: content.Height})
markerIndex := 0
diff --git a/components/code_editor_test.go b/components/code_editor_test.go
index 5aac722a..54883009 100644
--- a/components/code_editor_test.go
+++ b/components/code_editor_test.go
@@ -175,6 +175,54 @@ func TestCodeEditorGutterMarkersIgnoreOutOfContractInput(t *testing.T) {
}
}
+func TestCodeEditorGutterMarkersExposeOnlyTheirVisibleLineLanes(t *testing.T) {
+ fixture := newCodeFixture("first\n\nthird")
+ fixture.snapshot.Wrap = true
+ fixture.snapshot.GutterMarkers = []CodeEditorGutterMarker{
+ {Line: 0, ID: gfx.Item(20), Color: gfx.RGB(20, 80, 160), Label: "Breakpoint"},
+ {Line: 2, Color: gfx.RGB(180, 40, 60)},
+ }
+ fixture.paint()
+ var hit gfx.Hit
+ found := false
+ for _, candidate := range fixture.canvas.Hits() {
+ if candidate.ID == gfx.Item(20) {
+ hit, found = candidate, true
+ }
+ if candidate.ID == gfx.Item(21) {
+ t.Fatal("a marker without an ID was hit-testable")
+ }
+ }
+ if !found || hit.Role != gfx.RoleButton || hit.Label != "Breakpoint" || hit.Bounds.Height != fixture.editor.lineHeight {
+ t.Fatalf("line marker hit = %+v, %v", hit, found)
+ }
+ blankY := hit.Bounds.Y + hit.Bounds.Height + 1
+ if point := (gfx.Point{X: hit.Bounds.X + hit.Bounds.Width/2, Y: blankY}); point.Y < fixture.editor.content.Y+2*fixture.editor.lineHeight {
+ for _, candidate := range fixture.canvas.Hits() {
+ if candidate.ID != gfx.Item(1) && point.X >= candidate.Bounds.X && point.X < candidate.Bounds.X+candidate.Bounds.Width && point.Y >= candidate.Bounds.Y && point.Y < candidate.Bounds.Y+candidate.Bounds.Height {
+ t.Fatalf("blank line had interactive hit %+v", candidate)
+ }
+ }
+ }
+
+ fixture.snapshot.Disabled = true
+ fixture.paint()
+ for _, candidate := range fixture.canvas.Hits() {
+ if candidate.ID == gfx.Item(20) && !candidate.Disabled {
+ t.Fatal("a disabled editor exposed an enabled gutter marker")
+ }
+ }
+}
+
+func TestCodeEditorGutterMarkerSteadyPaintDoesNotAllocate(t *testing.T) {
+ fixture := newCodeFixture("first\nsecond")
+ fixture.snapshot.GutterMarkers = []CodeEditorGutterMarker{{Line: 0, ID: gfx.Item(20), Color: gfx.RGB(20, 80, 160), Label: "Line marker"}}
+ fixture.paint()
+ if allocations := testing.AllocsPerRun(50, fixture.paint); allocations != 0 {
+ t.Fatalf("steady gutter-marker paint allocated %g times", allocations)
+ }
+}
+
func TestCodeEditorGutterMarkersFadeWhenDisabled(t *testing.T) {
fixture := newCodeFixture("one")
fixture.bounds.Height = 600
@@ -788,6 +836,28 @@ func TestCodeEditorCurrentLineTint(t *testing.T) {
}
}
+// An empty document is still one line, and the caret is on it. Code surfaces
+// (CodeMirror's highlightActiveLine, VS Code) tint that row exactly like any
+// other caret line, so an empty editor's first line carries the same
+// background as a populated one — the placeholder is text styling, not a
+// signal that there is no caret line.
+func TestCodeEditorCurrentLineTintOnEmptyDocument(t *testing.T) {
+ f := newCodeFixture("")
+ f.snapshot.CurrentLine = true
+ f.snapshot.Placeholder = "Type SQL"
+ f.paint()
+ tint := f.theme.Foreground
+ tint.Alpha *= 0.05
+ got := f.fills(tint)
+ if len(got) != 1 {
+ t.Fatalf("an empty document must tint its single caret row, got %+v", got)
+ }
+ _, _, lineHeight := f.cell()
+ if got[0].Y != f.editor.content.Y || got[0].Height != lineHeight {
+ t.Fatalf("tint = %+v, want Y=%v height=%v", got[0], f.editor.content.Y, lineHeight)
+ }
+}
+
func TestCodeEditorCompositionIsUnderlined(t *testing.T) {
f := newCodeFixture("ab你好c")
f.snapshot.CompositionStart, f.snapshot.CompositionEnd, f.snapshot.Caret = 2, 8, 8
diff --git a/components/context_menu_test.go b/components/context_menu_test.go
index b18b3f8c..6b203148 100644
--- a/components/context_menu_test.go
+++ b/components/context_menu_test.go
@@ -4,6 +4,7 @@ import (
"slices"
"testing"
+ "github.com/vivaui/viva/app"
"github.com/vivaui/viva/gfx"
)
@@ -197,3 +198,76 @@ func TestContextMenuItemWidthKeepsTheLabelWhole(t *testing.T) {
t.Fatalf("12px under the computed width the label still gets %v of %v: the width is not tight", got, need)
}
}
+
+func TestMenuContentKeepsTheFocusedRowFromTinting(t *testing.T) {
+ var tree gfx.Tree
+ feature := tree.Feature("menu-content")
+ scope := gfx.Item(64)
+ rowID := tree.ID(feature, "row")
+ contentID := tree.ID(feature, "content")
+ theme := gfx.DarkTheme()
+ var rowBounds, contentBounds gfx.Rect
+ driver, err := app.NewDriver(app.Config{
+ Width: 320, Height: 240, Tree: &tree,
+ Paint: func(c *gfx.Canvas, size gfx.Size) {
+ menu := BeginContextMenu(c, theme, gfx.Rect{X: 20, Y: 20, Width: 220, Height: 160}, ContextMenuOptions{Layer: 7, Scope: scope})
+ rowBounds = menu.AddItem(ContextMenuItem{ID: rowID, Label: "Row"})
+ menu.BeginContent()
+ contentBounds = menu.AddItem(ContextMenuItem{})
+ c.HitWithOptions(contentID, contentBounds, "Content", gfx.HitOptions{Role: gfx.RoleButton})
+ menu.EndContent()
+ menu.End()
+ },
+ }, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer driver.Close()
+ must := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ move := func(x, y float32) {
+ t.Helper()
+ must(driver.Move(x, y))
+ }
+ rowTinted := func() bool {
+ t.Helper()
+ for _, primitive := range driver.Snapshot().Primitives {
+ if primitive.Color == theme.Accent && primitive.Bounds == rowBounds {
+ return true
+ }
+ }
+ return false
+ }
+ rowCenter := func() (float32, float32) {
+ return rowBounds.X + rowBounds.Width/2, rowBounds.Y + rowBounds.Height/2
+ }
+ contentCenter := func() (float32, float32) {
+ return contentBounds.X + contentBounds.Width/2, contentBounds.Y + contentBounds.Height/2
+ }
+ // Hovering the row makes it the menu's current row.
+ x, y := rowCenter()
+ move(x, y)
+ move(x+1, y)
+ if !rowTinted() {
+ t.Fatal("hovering the row did not tint it: the test cannot see the state it checks")
+ }
+ // The pointer then rests on caller content inside the menu. The focused row
+ // must not light up again.
+ x, y = contentCenter()
+ move(x, y)
+ move(x+1, y)
+ if rowTinted() {
+ t.Fatal("the row is tinted while the pointer rests on the menu's content")
+ }
+ // Returning to the row lights it again.
+ x, y = rowCenter()
+ move(x, y)
+ move(x+1, y)
+ if !rowTinted() {
+ t.Fatal("the row is no longer tinted after the pointer returned to it")
+ }
+}
diff --git a/components/dropdown_menu.go b/components/dropdown_menu.go
index c6cc5390..4fd14679 100644
--- a/components/dropdown_menu.go
+++ b/components/dropdown_menu.go
@@ -950,6 +950,11 @@ func (menu *menuSurface) paintItem(bounds gfx.Rect, item menuItem) {
// keep working under it.
hovered := canvas.Hovered()
claim := canvas.HoverCrossed() && !hovered.IsZero()
+ // While the pointer is over caller content inside the menu (a colour row, a
+ // switch), no row is the menu's current row: keeping the last focused row
+ // tinted would fight the control under the pointer. Menu rows themselves
+ // declare RoleMenuItem.
+ inContent := !hovered.IsZero() && canvas.HoveredRole() != 0 && canvas.HoveredRole() != gfx.RoleMenuItem
if claim && hovered == item.ID {
// A disabled row cannot take focus, but the frame is still repainted so
// the row that kept focus is tinted again on the next paint.
@@ -960,7 +965,7 @@ func (menu *menuSurface) paintItem(bounds gfx.Rect, item menuItem) {
}
// Focused, not FocusVisible: a menu row the pointer focused is the current
// row even though no key press made its focus ring visible.
- active := enabled && (claim && hovered == item.ID || !claim && canvas.Focused() == item.ID)
+ active := enabled && !inContent && (claim && hovered == item.ID || !claim && canvas.Focused() == item.ID)
if item.Kind == menuSubmenu && item.Expanded {
active = true
}
diff --git a/components/input.go b/components/input.go
index 5c0a851b..53c12dcc 100644
--- a/components/input.go
+++ b/components/input.go
@@ -12,8 +12,13 @@ import (
// it a password field: the value is never exposed, DisplayValue is ignored,
// and the field paints one bullet per character.
type InputSnapshot struct {
- ID gfx.ID
- Label string
+ ID gfx.ID
+ Label string
+ // Alignment places the text within the content rectangle. Zero preserves
+ // the ordinary direction-dependent start alignment, which is left-to-right
+ // for Latin text. AlignRight suits a numeric field. The label, caret,
+ // selection, composition and platform editor all take the same alignment.
+ Alignment gfx.Alignment
Value string
DisplayValue string
Placeholder string
@@ -133,6 +138,9 @@ func PaintInputContent(canvas *gfx.Canvas, theme gfx.Theme, bounds, content gfx.
// is where the glyphs are painted: a content rectangle exactly one line tall is its
// own line box, and a taller one (a padded field, or the whole control) is not.
func paintInputContent(canvas *gfx.Canvas, theme gfx.Theme, bounds, content gfx.Rect, snapshot InputSnapshot, focused bool) {
+ if snapshot.Alignment == gfx.AlignLeft {
+ snapshot.Alignment = gfx.AlignStart
+ }
style := snapshot.Text.style(&theme)
// A mono field is painted by the component whatever the snapshot says of the platform: the
// platform's text service cannot draw its face, so its text and overlays are hidden. A
@@ -170,7 +178,7 @@ func paintInputContent(canvas *gfx.Canvas, theme gfx.Theme, bounds, content gfx.
Role: gfx.RoleTextField, Cursor: gfx.PointerCursorText, Value: snapshot.Value, Hint: snapshot.Hint,
Disabled: snapshot.Disabled, ReadOnly: snapshot.ReadOnly, Secure: snapshot.Secure,
NavigationKeys: snapshot.NavigationKeys,
- TextBounds: textBounds, TextSize: size, TextWeight: weight, TextColor: platformInk,
+ TextBounds: textBounds, TextSize: size, TextWeight: weight, TextColor: platformInk, TextAlignment: snapshot.Alignment,
HideNativeOverlays: selfPainted,
})
}
@@ -215,10 +223,9 @@ func inputPaintLine(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.Rect, value,
placeholderShown := snapshot.Value == "" && snapshot.DisplayValue == ""
style := textStyle
style.Color = color
- // The line starts where the label puts it: the right end of the field for text that
- // reads right to left. The caret, selection and composition are measured from there,
- // and follow the visual order of mixed text (a selection may be several stretches).
- origin := canvas.LabelOrigin(value, bounds, style, gfx.AlignStart)
+ // The caret, selection and composition share the glyphs' origin and follow
+ // the visual order of mixed text (a selection may be several stretches).
+ origin := canvas.LabelOrigin(value, bounds, style, snapshot.Alignment)
var ranges [4]gfx.Rect
selectionStart, selectionEnd := inputRange(value, snapshot.SelectionStart, snapshot.SelectionEnd)
var selections []gfx.Rect
@@ -255,7 +262,7 @@ func inputPaintLine(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.Rect, value,
// bounds, and those should describe the glyph line the field draws.
lineTop, lineHeight := inputLineBox(bounds, fontSize)
line := gfx.Rect{X: bounds.X, Y: lineTop, Width: bounds.Width, Height: lineHeight}
- canvas.Label(value, line, style)
+ canvas.LabelAligned(value, line, style, snapshot.Alignment)
for _, selection := range selections {
if selection.Width <= 0 {
continue
@@ -267,7 +274,7 @@ func inputPaintLine(canvas *gfx.Canvas, theme gfx.Theme, bounds gfx.Rect, value,
}
selected := textStyle
selected.Color = selectedColor
- canvas.Label(value, line, selected)
+ canvas.LabelAligned(value, line, selected, snapshot.Alignment)
canvas.Unclip()
}
canvas.Unclip()
diff --git a/components/input_text_style_test.go b/components/input_text_style_test.go
index 453e7a67..d1dc94df 100644
--- a/components/input_text_style_test.go
+++ b/components/input_text_style_test.go
@@ -169,6 +169,66 @@ func TestMonoInputIsPaintedByTheComponentAndTheNativeLayerIsHidden(t *testing.T)
}
}
+// Numeric fields opt into a right edge without leaving their selection, caret,
+// composition or platform editor at the ordinary start edge.
+func TestInputAlignmentDrivesTheLineAndItsEditingGeometry(t *testing.T) {
+ theme := gfx.LightTheme()
+ bounds := gfx.Rect{X: 10, Width: 200, Height: 32}
+ snapshot := InputSnapshot{ID: gfx.Item(1), Value: "1234", Alignment: gfx.AlignRight, HideEditorText: true,
+ Focused: true, ShowCaret: true, Caret: 3, SelectionStart: 1, SelectionEnd: 3}
+ canvas := weightedCanvas()
+ PaintInputContent(canvas, theme, bounds, bounds, snapshot)
+ var line gfx.Text
+ for _, text := range canvas.Texts() {
+ if text.Value == "1234" {
+ line = text
+ }
+ }
+ width := canvas.TextWidthStyle(line.Value, line.Style)
+ if line.Bounds.X != bounds.X+bounds.Width-width {
+ t.Fatalf("text origin = %v, want %v for right-aligned text", line.Bounds.X, bounds.X+bounds.Width-width)
+ }
+ var carets, selections []gfx.Rectangle
+ for _, rectangle := range canvas.Rectangles() {
+ switch {
+ case rectangle.Bounds.Width == 1:
+ carets = append(carets, rectangle)
+ case rectangle.Bounds.Width > 1:
+ selections = append(selections, rectangle)
+ }
+ }
+ if len(carets) != 1 {
+ t.Fatalf("carets = %+v, want one", carets)
+ }
+ if want := line.Bounds.X + canvas.TextCaretStyle(line.Value, line.Style, 3); carets[0].Bounds.X != want {
+ t.Errorf("caret at %v, want %v from the right origin", carets[0].Bounds.X, want)
+ }
+ if len(selections) != 1 {
+ t.Fatalf("the selection painted %+v, want one band", selections)
+ }
+ var spans [4]gfx.TextSpan
+ selection := canvas.TextSelectionStyle(spans[:0], line.Value, line.Style, 1, 3)
+ if len(selection) == 0 || selections[0].Bounds.X != line.Bounds.X+selection[0].X {
+ t.Errorf("selection at %+v, want the measured range %v from the right origin", selections[0].Bounds, selection)
+ }
+ text := hitText(canvas, &canvas.Hits()[0])
+ if text.Alignment != gfx.AlignRight {
+ t.Fatalf("the platform is told alignment %v, want right", text.Alignment)
+ }
+}
+
+func TestDefaultInputAlignmentStaysDirectionDependent(t *testing.T) {
+ canvas := weightedCanvas()
+ bounds := gfx.Rect{Width: 200, Height: 32}
+ PaintInputContent(canvas, gfx.LightTheme(), bounds, bounds, InputSnapshot{ID: gfx.Item(1), Value: "abc"})
+ if text := canvas.Texts()[0]; text.Bounds.X != bounds.X {
+ t.Fatalf("the ordinary field starts at %v, want %v", text.Bounds.X, bounds.X)
+ }
+ if text := hitText(canvas, &canvas.Hits()[0]); text.Alignment != gfx.AlignStart {
+ t.Errorf("the default promises the runtime alignment %v, want start", text.Alignment)
+ }
+}
+
// The caret of a self-painted field is drawn only while its blink phase has it lit.
func TestMonoInputCaretFollowsItsBlinkPhase(t *testing.T) {
theme := gfx.LightTheme()
diff --git a/components/scroll_area.go b/components/scroll_area.go
index b331dc85..06a28d5c 100644
--- a/components/scroll_area.go
+++ b/components/scroll_area.go
@@ -64,6 +64,7 @@ type ScrollArea struct {
barsRevealed bool
bar ScrollBarStyle
barWidth float32
+ frozen float32
contentClipIsActive bool
}
@@ -81,7 +82,9 @@ func (area ScrollArea) Offset(x, y float32) ScrollArea {
}
// VerticalScrollbar assigns the stable action ID and accessibility strings for
-// the vertical thumb. ID zero keeps the visual geometry non-interactive.
+// the vertical thumb. ID zero keeps the visual geometry non-interactive. An ID
+// zero with an empty label omits the overlay while preserving its geometry, for
+// a caller that paints the vertical bar itself.
func (area ScrollArea) VerticalScrollbar(id gfx.ID, label, value string) ScrollArea {
area.verticalID = id
area.verticalLabel = label
@@ -139,6 +142,19 @@ func (area ScrollArea) ScrollbarSize(size float32) ScrollArea {
return area
}
+// Frozen declares that the leading px of the content stays pinned at the
+// viewport's leading edge while the rest scrolls, as the frozen columns of a
+// table stay put. The pinned width leaves MaxOffsetX the value an unfrozen
+// area reports — the offset never scrolls the pinned columns — while the
+// horizontal track, thumb and drag mapping shrink to the window past them and
+// span exactly what scrolls. A zero or negative px, one with nothing to scroll
+// around, or one at or past the viewport, freezes nothing. The vertical
+// geometry is the unfrozen one.
+func (area ScrollArea) Frozen(px float32) ScrollArea {
+ area.frozen = px
+ return area
+}
+
// Disabled preserves geometry and typed semantics while making both scrollbars inert.
func (area ScrollArea) Disabled() ScrollArea {
area.disabled = true
@@ -186,7 +202,10 @@ func (area ScrollArea) Bars(style ScrollBarStyle) ScrollArea {
}
// Geometry returns clamped viewport, translated content, track, thumb, and
-// corner geometry without painting or changing Canvas state.
+// corner geometry without painting or changing Canvas state. A frozen area
+// keeps the unfrozen content, offset and vertical geometry; only the
+// horizontal track, thumb and corner move to the scrolling window past the
+// pinned band, spanning exactly what scrolls.
func (area ScrollArea) Geometry() ScrollAreaGeometry {
bounds := scrollAreaNormalizeRect(area.bounds)
barSize := area.barSize()
@@ -203,6 +222,16 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
contentHeight = viewport.Height
}
+ // A pinned band needs a window left to scroll in — something to either
+ // side of it, and a width inside the viewport — and changes nothing about
+ // the translation: Content keeps the unfrozen origin, because the caller
+ // clips the pinned columns out of it and paints them itself at the
+ // viewport's edge. Only the horizontal bar belongs to the scrolling window
+ // past the band.
+ frozen := area.frozen
+ if frozen <= 0 || frozen >= viewport.Width || contentWidth <= viewport.Width {
+ frozen = 0
+ }
maximumX := scrollAreaMax(contentWidth-viewport.Width, 0)
maximumY := scrollAreaMax(contentHeight-viewport.Height, 0)
offsetX := scrollAreaClamp(area.offsetX, 0, maximumX)
@@ -210,10 +239,11 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
hasHorizontal := maximumX > 0 && viewport.Height > 0
hasVertical := maximumY > 0 && viewport.Width > 0
+ contentX := viewport.X - offsetX
geometry := ScrollAreaGeometry{
Bounds: bounds, Viewport: viewport,
Content: gfx.Rect{
- X: viewport.X - offsetX, Y: viewport.Y - offsetY,
+ X: contentX, Y: viewport.Y - offsetY,
Width: contentWidth, Height: contentHeight,
},
OffsetX: offsetX, OffsetY: offsetY,
@@ -223,7 +253,9 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
// The bars lie along the outer bounds. Where the viewport already excludes them
// (ReserveScrollbars) their extents are the viewport's; otherwise the corner is
- // taken out of whichever track shares it.
+ // taken out of whichever track shares it. With a pinned band the horizontal bar
+ // starts at the band's far edge: its thumb spans the scrolling window, and the
+ // track covers the content its offset can reach.
verticalExtent := bounds.Height
horizontalExtent := bounds.Width
if hasHorizontal && hasVertical {
@@ -243,11 +275,17 @@ func (area ScrollArea) Geometry() ScrollAreaGeometry {
geometry.VerticalThumb = scrollAreaThumb(geometry.VerticalTrack, true, viewport.Height, contentHeight, offsetY, maximumY, area.barInset(), area.minThumb())
}
if hasHorizontal {
+ trackX := bounds.X
+ horizontalViewport := viewport.Width
+ if frozen > 0 {
+ trackX = bounds.X + frozen
+ horizontalViewport = viewport.Width - frozen
+ }
geometry.HorizontalTrack = gfx.Rect{
- X: bounds.X, Y: bounds.Y + bounds.Height - barSize,
- Width: horizontalExtent, Height: barSize,
+ X: trackX, Y: bounds.Y + bounds.Height - barSize,
+ Width: scrollAreaMax(horizontalExtent-(trackX-bounds.X), 0), Height: barSize,
}
- geometry.HorizontalThumb = scrollAreaThumb(geometry.HorizontalTrack, false, viewport.Width, contentWidth, offsetX, maximumX, area.barInset(), area.minThumb())
+ geometry.HorizontalThumb = scrollAreaThumb(geometry.HorizontalTrack, false, horizontalViewport, contentWidth-frozen, offsetX, maximumX, area.barInset(), area.minThumb())
}
return geometry
}
@@ -349,6 +387,9 @@ func (area *ScrollArea) EndContent() ScrollAreaGeometry {
}
func (area ScrollArea) paintScrollbar(track, thumb gfx.Rect, id gfx.ID, label, value string) {
+ if id.IsZero() && label == "" {
+ return
+ }
if area.hoverBars && !area.barsRevealed {
return
}
diff --git a/components/scroll_area_frozen_test.go b/components/scroll_area_frozen_test.go
new file mode 100644
index 00000000..2d35042a
--- /dev/null
+++ b/components/scroll_area_frozen_test.go
@@ -0,0 +1,92 @@
+package components
+
+import (
+ "testing"
+
+ "github.com/vivaui/viva/gfx"
+)
+
+// A frozen area scrolls its content around the pinned band: the offset's
+// maximum keeps the unfrozen value — the offset never moves what is pinned —
+// while the translated content moves by the pinned width and the offset
+// together, so the caller paints the scrolling columns of a frozen table into
+// the window the seam leaves.
+func TestFrozenScrollAreaScrollsAroundThePinnedBand(t *testing.T) {
+ area := BeginScrollArea(nil, gfx.LightTheme(), gfx.Rect{X: 10, Y: 20, Width: 200, Height: 100}, gfx.Size{Width: 500, Height: 400}).
+ Frozen(60).Offset(120, 0)
+ geometry := area.Geometry()
+ // 300 is the unfrozen 500-200: the offset reaches exactly what an
+ // unfrozen area's would, and the translated content keeps the unfrozen
+ // origin, because the caller paints the pinned columns itself over it.
+ if geometry.MaxOffsetX != 300 {
+ t.Fatalf("MaxOffsetX = %v, want the unfrozen 300", geometry.MaxOffsetX)
+ }
+ if geometry.Content.X != 10-120 {
+ t.Fatalf("the content starts at %v, want the unfrozen origin 10 - the offset 120", geometry.Content.X)
+ }
+ // The horizontal bar belongs to the scrolling window: its track starts at
+ // the pinned band's far edge — less the corner a vertical bar takes — and
+ // its thumb spans exactly what scrolls.
+ if geometry.HorizontalTrack.X != 10+60 || geometry.HorizontalTrack.Width != 190-60 {
+ t.Fatalf("the horizontal track is %+v, want it past the pinned 60 px of the 190 the corner leaves", geometry.HorizontalTrack)
+ }
+ scrolled := geometry.HorizontalTrack.Width
+ if thumb := geometry.HorizontalThumb; thumb.X < geometry.HorizontalTrack.X+1 || thumb.X+thumb.Width > geometry.HorizontalTrack.X+scrolled {
+ t.Fatalf("the horizontal thumb is %+v, want it inside the scrolling window of %v px alone", thumb, scrolled)
+ }
+ // The offset reaches every scrolled pixel of content while never moving
+ // the pinned ones: a drag to the thumb's far end is the whole content's.
+ area = area.Offset(geometry.MaxOffsetX, 0)
+ end := area.Geometry()
+ if end.Content.X+end.Content.Width != 10+200 {
+ t.Fatalf("at the end the content reaches %v, want the viewport's far edge", end.Content.X+end.Content.Width)
+ }
+}
+
+// The drag mapping stays the inverse of the thumb: a press at some offset maps
+// back to the same offset, and moving the thumb to the end of the scrolling
+// window scrolls to the end of the content.
+func TestFrozenScrollAreaDragMapsWhatTheThumbSpans(t *testing.T) {
+ area := BeginScrollArea(nil, gfx.LightTheme(), gfx.Rect{X: 10, Y: 20, Width: 200, Height: 100}, gfx.Size{Width: 500, Height: 400}).
+ Frozen(60).HorizontalScrollbar(gfx.Item(1), "Columns", "")
+ geometry := area.Geometry()
+ if got := geometry.HorizontalOffsetForThumb(geometry.HorizontalThumb.X); got != 0 {
+ t.Fatalf("the resting thumb maps back to %v, want 0", got)
+ }
+ thumbAt := func(offset float32) float32 {
+ return area.Offset(offset, 0).Geometry().HorizontalThumb.X
+ }
+ for _, offset := range []float32{60, 150, 300} {
+ if geometry := area.Offset(offset, 0).Geometry(); geometry.OffsetX != offset {
+ t.Fatalf("offset %v clamped to %v", offset, geometry.OffsetX)
+ }
+ if back := geometry.HorizontalOffsetForThumb(thumbAt(offset)); back-offset > 0.01 || offset-back > 0.01 {
+ t.Fatalf("thumb for %v maps back to %v", offset, back)
+ }
+ }
+}
+
+// A frozen area without a horizontal overflow, with nothing pinned, or with
+// the whole viewport pinned, is the unfrozen area it always was: same
+// geometry, byte for byte.
+func TestFrozenScrollAreaWithoutOverflowOrBandKeepsUnfrozenGeometry(t *testing.T) {
+ theme := gfx.LightTheme()
+ bounds := gfx.Rect{X: 10, Y: 20, Width: 200, Height: 100}
+ cases := []struct {
+ name string
+ frozen float32
+ content gfx.Size
+ }{
+ {"nothing pinned", 0, gfx.Size{Width: 500, Height: 400}},
+ {"no overflow", 60, gfx.Size{Width: 200, Height: 400}},
+ {"overflow shorter than the band", 300, gfx.Size{Width: 240, Height: 400}},
+ {"whole viewport pinned", 200, gfx.Size{Width: 500, Height: 400}},
+ }
+ for _, c := range cases {
+ plain := BeginScrollArea(nil, theme, bounds, c.content).Offset(120, 40)
+ frozen := BeginScrollArea(nil, theme, bounds, c.content).Frozen(c.frozen).Offset(120, 40)
+ if got, want := frozen.Geometry(), plain.Geometry(); got != want {
+ t.Fatalf("%s: frozen geometry is %+v, want the unfrozen %+v", c.name, got, want)
+ }
+ }
+}
diff --git a/components/tooltip.go b/components/tooltip.go
index 59005bb9..fefa290d 100644
--- a/components/tooltip.go
+++ b/components/tooltip.go
@@ -153,8 +153,14 @@ func (tooltip Tooltip) Paint(label string) gfx.Rect {
maxWidth = 320
}
maxWidth = min(maxWidth, tooltip.viewport.Width-8)
- var scratch overlayLines
- lines := overlayWrap(tooltip.canvas, &scratch, label, fontSize, gfx.WeightRegular, maxWidth-2*padX)
+ // A filename or another unbroken token has no natural short form: keep all
+ // of it even when a bounded descriptive tooltip would use an ellipsis. Long
+ // tokens already break at whole grapheme clusters in the text engine. The
+ // viewport is a bound on excessive content, not an interactive text limit.
+ // The lines live in a stack scratch: a tooltip that is painted every frame
+ // must not allocate for wrapping that has not changed.
+ var scratch [8]string
+ lines := tooltip.canvas.WrapLines(scratch[:0], label, fontSize, gfx.WeightRegular, maxWidth-2*padX, int(tooltip.viewport.Height))
// One line takes the width of its text; a wrapped label fills the whole
// maximum, as a `w-fit max-w-xs` box does.
textWidth := maxWidth - 2*padX
diff --git a/components/tooltip_test.go b/components/tooltip_test.go
index 408f1ecc..1f003f70 100644
--- a/components/tooltip_test.go
+++ b/components/tooltip_test.go
@@ -1,6 +1,7 @@
package components
import (
+ "strings"
"testing"
"time"
@@ -107,6 +108,38 @@ func TestTooltipWrapsALabelWiderThanItsMaximum(t *testing.T) {
}
}
+func TestTooltipKeepsAnUnbrokenLabelWholeWhenItWraps(t *testing.T) {
+ var canvas gfx.Canvas
+ label := strings.Repeat("long-file-name-part-", 12) + ".txt"
+ BeginTooltip(&canvas, gfx.DarkTheme(), gfx.Rect{Width: 600, Height: 400}, gfx.Rect{X: 260, Y: 180, Width: 80, Height: 32}, TooltipOptions{}).Paint(label)
+ var painted string
+ for _, text := range canvas.Texts() {
+ if strings.ContainsRune(text.Value, '…') {
+ t.Fatalf("the tooltip dropped part of the name: %q", text.Value)
+ }
+ if text.Style.Size != gfx.DarkTheme().Metrics.FontSizeS || text.Style.Weight != gfx.WeightRegular {
+ t.Fatalf("line is not measured with the tooltip's text style: %+v", text)
+ }
+ painted += text.Value
+ }
+ if painted != label || len(canvas.Texts()) < 2 {
+ t.Fatalf("painted = %q, want all of %q in multiple lines", painted, label)
+ }
+}
+
+func BenchmarkTooltipLongUnbrokenLabel(b *testing.B) {
+ var canvas gfx.Canvas
+ label := strings.Repeat("long-file-name-part-", 12) + ".txt"
+ theme := gfx.DarkTheme()
+ viewport := gfx.Rect{Width: 600, Height: 400}
+ anchor := gfx.Rect{X: 260, Y: 180, Width: 80, Height: 32}
+ b.ReportAllocs()
+ for b.Loop() {
+ canvas.Reset()
+ BeginTooltip(&canvas, theme, viewport, anchor, TooltipOptions{}).Paint(label)
+ }
+}
+
func TestTooltipOpensOnTheOtherSideWhenThereIsNoRoomAndItsArrowFollows(t *testing.T) {
var canvas gfx.Canvas
viewport, anchor := gfx.Rect{Width: 600, Height: 400}, gfx.Rect{X: 260, Y: 8, Width: 80, Height: 32}
diff --git a/docs/conformance.md b/docs/conformance.md
index ff1826b6..3b8661e7 100644
--- a/docs/conformance.md
+++ b/docs/conformance.md
@@ -94,7 +94,7 @@ mistakes an absence for an oversight. Items marked *measured* were tried or size
| A native application menu | all | The contract has a status-item menu only; macOS installs a Quit item. |
| Colour management, HDR, wide gamut | all | Every backend writes sRGB-ish values; nothing converts. Not measured. |
| Arm64 UI Automation | Windows | The floating-point thunk exists for amd64 only. |
-| AppImage, rpm | Linux | AppImage needs an external runtime binary; rpm could not be checked here. `viva package` makes tar.gz and .deb. |
+| AppImage, rpm | Linux | AppImage needs an external runtime binary; rpm could not be checked here. `viva package` makes tar.gz, .deb, and a PKGBUILD for Arch. |
| Native text events in `viva dev` | all | The worker protocol does not carry them; native Canvas examples do not run under `viva dev`. |
| A flat accessibility tree | Windows, macOS | Every node hangs from the window; Linux derives parents from geometry. |
| XIM input | Linux | Composition goes through IBus (IBus and Fcitx5's IBus frontend); XIM-only applications cannot compose. |
diff --git a/docs/releases/0.6.3.md b/docs/releases/0.6.3.md
new file mode 100644
index 00000000..7c7280d4
--- /dev/null
+++ b/docs/releases/0.6.3.md
@@ -0,0 +1,97 @@
+v0.6.3 是 v0.6.2 之后的 patch 版本:修复 Windows 旧系统上的单实例启动、macOS 输入法组合、表格滚动条与提示框绘制等问题,并新增固定列、表格横向滚动、虚线边框、可调列宽与网页视图回调等一批可选 API 与能力,已有 API 不变,升级不需要改调用点。macOS 是参考平台。
+
+v0.6.3 is a patch release after v0.6.2: it fixes single-instance startup on older Windows systems, input-method composition on macOS, table scrollbars and tooltip painting, and adds a set of opt-in APIs and capabilities, including frozen columns, horizontal table scrolling, dashed borders, resizable columns and web view callbacks. Existing APIs are unchanged, so upgrading needs no call-site edits. macOS is the reference platform.
+
+## 拿到它 · Getting it
+
+`go get github.com/vivaui/viva@v0.6.3`,需要 Go 1.26 或更新;系统依赖与 v0.6.2 相同。每个 Release 附带 `viva` 命令的预编译压缩包、`SHA256SUMS`,以及 Homebrew 与 Scoop 清单(`brew install vivaui/tap/viva`,或 `scoop bucket add vivaui https://github.com/vivaui/homebrew-tap` 后 `scoop install viva`);压缩包没有代码签名或公证,浏览器下载的 macOS 版会被 Gatekeeper 拦下,brew、curl 与 `go install` 不受影响。
+
+`go get github.com/vivaui/viva@v0.6.3` needs Go 1.26 or newer; the system requirements are those of v0.6.2. Every Release carries prebuilt archives of the `viva` command, `SHA256SUMS`, and Homebrew and Scoop manifests (`brew install vivaui/tap/viva`, or `scoop bucket add vivaui https://github.com/vivaui/homebrew-tap` then `scoop install viva`); the archives are not code-signed or notarized, so a browser download on macOS is stopped by Gatekeeper while brew, curl and `go install` are not affected.
+
+## 有什么 · What is in it
+
+- Windows 的单实例认领改用每用户命名管道,Windows 10 1803 之前与 Windows Server 2016 可以正常启动,认领失败时的错误会带上平台给出的原因
+- `Box.TextEnd()` 现在也把 `Input`、`TextField` 的文字放到行尾,聚焦时的光标、选区与平台编辑器保持同一边
+- 空的 `CodeEditor` 也绘制光标行的底色,与有内容的编辑器一致
+- `TableSpec.HideHoverScrollbars` 让应用自己绘制垂直滚动条时不再叠加表格的匿名悬停条
+- 提示框的长文本按可见区域换行,不再套用八行省略预算,未断开的文件名可以完整显示
+- `TableSpec.FrozenColumns` 把左侧若干列固定在原地,横向滚动时它们与表头一起保持可见
+- `SetClipboardHTML(html, alt)` 在一次写入中放入 HTML 与纯文本两种格式,浏览器里通过 `SetClipboardHTMLAsync` 返回 Promise
+- 自绘表格的横向滚动条落在为它保留的 gutter 里,不再盖住最下面一行
+- 虚线边框通过 `Box.Dashed()`、`Look.Dashed(bool)` 与 `DrawContext.DashedBorder` 使用
+- `InputSnapshot.Alignment` 让字段的文字、光标、选区与平台编辑器在聚焦时保持同一对齐
+- `gfx.PointerCursorGrabbing` 在 macOS、Wayland/X11、Web 与 Windows 上都有对应的抓取光标
+- `PathStyle.Dashed` 与 `Canvas.DashedRoundedOutline` 通过路径管线绘制虚线轮廓,闭合轮廓按整周期收口,热画布不分配
+- 宽表格可以横向滚动:固定的表头随列移动,`TableSpec.HorizontalScrollbar` 指定表体下方的滚动条,`TableState.Scroll` 报告两轴的偏移与视口,Shift+滚轮横向移动列
+- `TableState.RowID` 与 `TableState.Rows` 暴露上次绘制中每一行的身份与范围,应用可以按行的身份而不是行号处理指针
+- 框线与块元素字形按字体自身的下划线厚度绘制,opencode 的提示边框不再随显示缩放退化成细线
+- `Window.SetSize` 与 `ContentWindow.SetSize` 请求原生窗口内容尺寸,headless driver 同样支持,不支持的主机返回 `ErrUnsupported` (contributed by @Ezreal-byte) (PR [#172](https://github.com/vivaui/viva/pull/172))
+- 自定义标题栏的 macOS 交通灯可以通过 `TrafficLightScale` 缩放 (contributed by @imjakey) (PR [#254](https://github.com/vivaui/viva/pull/254))
+- `WebViewConfig.Proxy` 设为 `WebProxyDirect` 时该页面绕过系统代理 (contributed by @imjakey) (PR [#253](https://github.com/vivaui/viva/pull/253))
+- `Box.TooltipOn(side)` 让提示框在指定的一边打开,`TooltipSide` 与四个方向常量随包导出 (contributed by @xiaoyumuxi) (PR [#252](https://github.com/vivaui/viva/pull/252))
+- 菜单行只在指针不在菜单内其它控件上时绘制当前项底色,应用不必再为嵌入控件做绘制期的焦点绕行
+- `ColorSwatch.Flush()` 让色块填满边框内的圆
+- macOS 在输入法组合期间更新文本输入布局时不再丢弃已输入的拼音
+- 提示框箭头改成一个按设备像素羽化的三角形,100% 与 125% 缩放下不再出现粗糙台阶
+- Windows 的 `FileIcon` 返回系统图标,文本文件与未知类型不再共用同一个图标 (contributed by @Zer0ON1) (PR [#249](https://github.com/vivaui/viva/pull/249))
+- `components.BeginColumnResize` 提供可拖动的表格列宽,支持最小宽度与按内容贴合 (contributed by @Zer0ON1) (PR [#247](https://github.com/vivaui/viva/pull/247))
+- `WebViewConfig.Ephemeral` 让网页视图的数据留在临时存储里,不写入持久化目录 (contributed by @1lck) (PR [#244](https://github.com/vivaui/viva/pull/244))
+- `WebViewConfig.Navigate`、`NewWindow` 与 `Download` 让应用在导航、弹窗和下载发生前先做决定,`WebNavigation` 说明是哪个 frame 发起的 (contributed by @1lck) (PR [#243](https://github.com/vivaui/viva/pull/243))
+- 网页视图的顶部圆角由 `TopCornerRadius` 设置,光标由原生视图自己维护 (contributed by @imjakey) (PR [#248](https://github.com/vivaui/viva/pull/248))
+- `OpenDirectoryPath` 返回用户选择的一个现有目录,取消时返回 `ErrCanceled`
+- `CodeEditorSnapshot.GutterMarkers` 在行号旁的行内绘制并暴露可点击的标记区,`CodeEditorGutterMarker` 的 `ID` 由调用方处理点击
+- Linux 与 Windows 的构建固定 `CGO_ENABLED=0`,在 Ubuntu 上构建的 Linux 程序不再携带构建机的 glibc,可以在更旧的发行版上启动
+- 重新生成 scroll-area、tooltip 与 code-editor 的组件安装模板,组件生成器的检查重新通过
+
+- Windows claims single-instance ownership at a per-user named pipe, so Windows 10 before 1803 and Windows Server 2016 start normally, and a failed claim reports the platform's reason
+- `Box.TextEnd()` now places an `Input` or `TextField`'s text at the end of its line, including the caret, selection and platform editor while focused
+- An empty `CodeEditor` now tints the caret's row like a populated one
+- `TableSpec.HideHoverScrollbars` lets an application that paints its own vertical bar omit the table's anonymous hover overlay
+- Tooltips wrap a long label to the visible area instead of the eight-line ellipsizing budget, so an unbroken filename stays whole
+- `TableSpec.FrozenColumns` pins the leftmost columns while the table scrolls sideways, keeping them and the header visible
+- `SetClipboardHTML` writes an HTML flavour beside a plain-text one in a single copy, and `SetClipboardHTMLAsync` answers through the browser's Promise
+- A self-scrolling table's horizontal bar lands in its reserved gutter instead of covering the last visible row
+- Dashed borders are available as `Box.Dashed()`, `Look.Dashed(bool)` and `DrawContext.DashedBorder`
+- `InputSnapshot.Alignment` keeps a field's line, caret, selection and platform editor on the same alignment while focused
+- `gfx.PointerCursorGrabbing` gives macOS, Wayland/X11, the web and Windows a grabbing cursor
+- `PathStyle.Dashed` and `Canvas.DashedRoundedOutline` stroke dashed outlines through the path pipeline, closing whole dash periods and allocating nothing on a warm canvas
+- A wide table scrolls sideways under its fixed header, with `TableSpec.HorizontalScrollbar` naming the bar below the body, `TableState.Scroll` reporting both axes, and Shift+wheel moving the columns
+- `TableState.RowID` and `TableState.Rows` expose the identity and scope of the rows the last paint drew, so pointers can be handled by row identity instead of index
+- Box-drawing and block glyphs now use the font's own underline thickness, so opencode's prompt border no longer thins out with display scale
+- `Window.SetSize` and `ContentWindow.SetSize` request a native content size, including under the headless driver, and report `ErrUnsupported` on hosts without it (contributed by @Ezreal-byte) (PR [#172](https://github.com/vivaui/viva/pull/172))
+- macOS traffic lights in a custom titlebar scale through `TrafficLightScale` (contributed by @imjakey) (PR [#254](https://github.com/vivaui/viva/pull/254))
+- `WebViewConfig.Proxy` set to `WebProxyDirect` bypasses the system proxies for that page (contributed by @imjakey) (PR [#253](https://github.com/vivaui/viva/pull/253))
+- `Box.TooltipOn` opens a tooltip on a chosen side, with `TooltipSide` and its four constants exported beside it (contributed by @xiaoyumuxi) (PR [#252](https://github.com/vivaui/viva/pull/252))
+- A menu row draws its current-item tint only when the pointer is not over another control inside the menu, so applications no longer need a paint-time focus workaround
+- `ColorSwatch.Flush()` lets a swatch fill the circle inside its border
+- macOS keeps the input method's composition across text-input layout updates instead of discarding the pinyin being typed
+- The tooltip arrow is one triangle feathered over a device pixel, so it stays clean at 100% and 125% scaling
+- `FileIcon` returns Windows' own system icons, so a text file and an unknown file no longer look the same (contributed by @Zer0ON1) (PR [#249](https://github.com/vivaui/viva/pull/249))
+- `components.BeginColumnResize` adds draggable table column widths with minimums and content-fit (contributed by @Zer0ON1) (PR [#247](https://github.com/vivaui/viva/pull/247))
+- `WebViewConfig.Ephemeral` keeps a web view's data in an ephemeral store instead of the persistent one (contributed by @1lck) (PR [#244](https://github.com/vivaui/viva/pull/244))
+- `WebViewConfig.Navigate`, `NewWindow` and `Download` let the application decide before a navigation, popup or download happens, with `WebNavigation` naming the frame (contributed by @1lck) (PR [#243](https://github.com/vivaui/viva/pull/243))
+- A web view's top corners round through `TopCornerRadius`, and the native view owns its cursor (contributed by @imjakey) (PR [#248](https://github.com/vivaui/viva/pull/248))
+- `OpenDirectoryPath` returns one existing directory the user picked, or `ErrCanceled` when they cancel
+- `CodeEditorSnapshot.GutterMarkers` paints per-line markers in a lane beside the line numbers and exposes each marker's `ID` as a button the caller handles
+- Linux and Windows builds pin `CGO_ENABLED=0`, so a Linux program built on Ubuntu no longer carries the builder's glibc and starts on older distributions
+- The scroll-area, tooltip and code-editor component installation templates are regenerated, so the component generator checks pass again
+
+## 证据在哪 · Where the evidence is
+
+- 发布门禁在 macOS 上运行 `gofmt`、`go vet ./...`、`go test ./... -count=1`、`-tags vivahead` 的 darwin 与 headless vet,以及 linux/arm64、windows/amd64、windows/arm64 的交叉 vet 与 js/wasm 的 vet
+- 单实例修复在 macOS 与一台物理 Windows 11 机器上用两个进程验证了参数交接;Windows 上 `Claim` 加 `Close` 为 23 µs、18 次分配
+- 表格横向滚动、冻结列、虚线轮廓与列宽调整的回归测试带零分配检查(`BenchmarkWideTable`、`BenchmarkColumnResizePaint`、`TestDashedOutlineIsAllocationFreeOnAWarmCanvas`)
+
+- The release gate runs `gofmt`, `go vet ./...`, `go test ./... -count=1`, the `-tags vivahead` darwin and headless vet, and cross-vet for linux/arm64, windows/amd64 and windows/arm64 plus the js/wasm vet on macOS
+- The single-instance fix was checked with two processes on macOS and on a physical Windows 11 machine; `Claim` plus `Close` costs 23 µs and 18 allocations on Windows
+- The horizontal table scrolling, frozen columns, dashed outlines and column resize carry zero-allocation regression checks (`BenchmarkWideTable`, `BenchmarkColumnResizePaint`, `TestDashedOutlineIsAllocationFreeOnAWarmCanvas`)
+
+## 注意 · Caveats
+
+- 本版使用 patch 版本号,但包含新增 API;没有删除或改变已有导出 API,升级不需要迁移
+- Windows 上,改动前构建的副本用 Unix socket 认领、改动后构建的副本用命名管道,两者互不相认;升级时让旧进程全部退出后再启动新版本即可
+- Linux 与 Windows 的 `viva build` 固定 `CGO_ENABLED=0`,应用自带的 cgo 代码在这两个平台上需要改为纯 Go 或系统调用
+
+- This release uses a patch version number but adds APIs; no existing exported API is removed or changed, so no migration is needed
+- On Windows a copy built before this change claims at a Unix socket while one built after claims at a named pipe, so they do not meet; exit every old process before starting the new build
+- `viva build` pins `CGO_ENABLED=0` for Linux and Windows, so application code that uses cgo on those targets needs pure Go or system calls instead
diff --git a/docs/system-integrations.md b/docs/system-integrations.md
index 8892c8cd..5eec83a3 100644
--- a/docs/system-integrations.md
+++ b/docs/system-integrations.md
@@ -10,6 +10,7 @@ synchronous forms report `ErrAsyncRequired`, `ErrSecretStoreUnavailable` or
| Capability | macOS | Windows | Linux/X11 | WebAssembly browser |
| --- | --- | --- | --- | --- |
| Read/write text clipboard | `NSPasteboard` | Unicode Win32 clipboard | X11 `CLIPBOARD`, `UTF8_STRING` with `STRING` read fallback; text above 128 KiB is served and read in pieces (INCR); handed to the clipboard manager when the owning window closes, if the desktop has one | synchronous calls return `ErrAsyncRequired`; `ClipboardTextAsync` and `SetClipboardTextAsync` use the Clipboard API and require a user gesture |
+| Rich copy (`SetClipboardHTML`, html + plain alt) | `NSPasteboard`: `public.html` beside `public.utf8-plain-text` | registered `HTML Format` (CF_HTML, with its header of byte offsets) beside `CF_UNICODETEXT` | X11 `CLIPBOARD`: `text/html` target beside `UTF8_STRING`; the GTK shell serves the same pair through `gtk_clipboard_set_with_data` | synchronous calls return `ErrAsyncRequired`; `SetClipboardHTMLAsync` writes one `ClipboardItem` with both MIME flavours and needs a user gesture |
| Copied files (`ClipboardFiles`, returns `[]string`) | the pasteboard's file URLs, file reference URLs resolved to paths; promised files have no path and are not listed | the `CF_HDROP` list (UTF-16 form) | X11 `CLIPBOARD`: `x-special/gnome-copied-files` (Nautilus, Nemo, Caja, Thunar) or `text/uri-list` (Dolphin, most toolkits); the GTK shell asks GTK for `text/uri-list` | `ErrUnsupported` |
| Primary selection (`SetPrimarySelection`, `PrimarySelection`) | `ErrUnsupported` | `ErrUnsupported` | X11 `PRIMARY`: selected text, pasted with a middle click | `ErrUnsupported` |
| Cursor shape | AppKit cursor; wait unsupported, diagonal resize requires macOS 15 | Win32 system cursors; running window required | XCB cursor font; running window required | CSS cursor on `documentElement` |
@@ -136,7 +137,7 @@ Clipboard operations, file pickers, secret storage and font loading settle
through Promises. They are intentionally not started from the synchronous API:
returning before the Promise settles would lie about success, and waiting would
deadlock the WASM event loop. The typed asynchronous counterparts are
-`ClipboardTextAsync`, `SetClipboardTextAsync`, `RequestClipboardImage`,
+`ClipboardTextAsync`, `SetClipboardTextAsync`, `SetClipboardHTMLAsync`, `RequestClipboardImage`,
`OpenFilesAsync`, `SaveFileAsync`, `StoreSecretAsync`, `LoadSecretAsync`,
`DeleteSecretAsync`, `RegisterFontAsync` and `RequestNotificationPermission`.
Each calls its completion exactly once; native targets complete before the call
diff --git a/docs/updating.md b/docs/updating.md
index 10d10dd2..5daf4e51 100644
--- a/docs/updating.md
+++ b/docs/updating.md
@@ -2,10 +2,15 @@
Viva ships an application as a native program with no updater inside it. The
`github.com/vivaui/viva/update` package adds one when an application imports it,
-and nothing is linked into a program that does not. It performs **full updates**:
-the feed names a complete archive, the client downloads it, verifies it, and the
-platform installer replaces the running application. Delta updates are not
-implemented, and the feed format reserves a place for them without using it yet.
+and nothing is linked into a program that does not.
+
+An update is either **full** or **a delta**. A full update downloads the complete
+archive the feed lists. A delta downloads a signed patch made between the
+installed payload and the new one: the `.app` bundle on macOS, the executable on
+Linux. The client applies the patch into a staging directory, verifies every file
+it produced, and hands the result to the same installer a full update uses. A
+patch that does not fit the running version, or fails at any point, leaves the
+installation untouched; the application downloads the full archive instead.
The package splits the work in two:
@@ -49,7 +54,15 @@ not act on is refused with `update.ErrManifest`.
"format": "zip",
"url": "https://example.com/app/stable/Notes-1.4.2-darwin-arm64.zip",
"size": 18432000,
- "sha256": "9f2c…"
+ "sha256": "9f2c…",
+ "patches": [
+ {
+ "from_version": "1.4.1",
+ "url": "https://example.com/app/stable/Notes-1.4.2-darwin-arm64.zip.1.4.1.patch",
+ "size": 240000,
+ "sha256": "5b71…"
+ }
+ ]
},
{
"os": "windows",
@@ -152,6 +165,48 @@ app/beta/latest.json
A feed can be checked from any HTTP client; the signature is the only thing that
makes it authoritative.
+## Delta updates
+
+A patch is made at release time from the previous release archive:
+
+```sh
+viva feed -dir dist -base-url https://example.com/app/stable \
+ --patch-from darwin/arm64=1.4.1=old/Notes-1.4.1-darwin-arm64.zip \
+ --patch-from linux/amd64=1.4.1=old/Notes-1.4.1-linux-amd64.tar.gz
+```
+
+Each `--patch-from` is `os/arch=version=archive`, repeatable, for `darwin` and
+`linux` only. The previous archive is supplied by the release process; `viva
+feed` extracts both releases and writes `..patch` beside
+the archives, listing it on the asset in the feed. A patch is a gzip tar holding
+`patch.json` and one bsdiff per changed file; `patch.json` names the app, both
+versions, the OS, the architecture and every file with its mode, size and
+SHA-256. The feed's signature covers the patch's URL, size and digest, and the
+client checks every file it writes against the digest in `patch.json`.
+
+The application decides whether to use a patch. The order that keeps the
+fallback simple:
+
+```go
+if patch, ok := asset.PatchFrom(running); ok {
+ if path, err := client.DownloadPatch(ctx, asset, patch, dir, progress); err == nil {
+ if staged, err := update.ApplyPatch(path, config.App, running, release.Version); err == nil {
+ return update.Install(update.InstallOptions{
+ Payload: staged, Asset: asset, Version: release.Version, App: config.App,
+ }, quit)
+ }
+ }
+}
+// download and install the full archive
+```
+
+`ApplyPatch` refuses a patch whose application, versions, OS, architecture or
+payload kind do not match, copies the installed payload into a staging
+directory, and applies the patch there. `Install` then runs the same checks as a
+full update (the bundle identifier, version and `codesign` on macOS) and replaces
+the application. A failure in either step changes nothing: the application keeps
+running the old version and can download the whole archive.
+
## Updating in the application
Check somewhere that does not block the UI thread, and hand the result back
@@ -277,8 +332,9 @@ warning.
the macOS and Linux helpers give up without changing anything and without a
message; the application keeps running the old version and can offer the
update again.
-- **No delta updates.** A download is the whole archive. The feed schema leaves
- room for patches; no client reads them yet.
+- **No delta updates on Windows.** The NSIS installer owns the installation, so
+ there is no payload tree to patch; a Windows client always downloads the full
+ installer. A patch listed for Windows is refused with `ErrUnsupported`.
- **No update service.** The package checks a URL the application names. It does
not host, stage, roll out or force anything.
- **No automatic install.** The application decides when to check, whether to
@@ -296,16 +352,21 @@ warning.
## Testing an update path
-`go test ./update` covers the feed parser, signatures, version ordering,
-download checks and archive unpacking on every host. The parts that replace a
-real application need a machine and a person:
+`go test ./update ./internal/bsdiff ./tools/patchgen` covers the feed parser,
+signatures, version ordering, download checks, archive unpacking, patch
+application and patch generation on every host. The parts that replace a real
+application need a machine and a person:
1. Build version 1, package it, install it, run it.
-2. Build version 2, package it, run `viva feed`, host the files over HTTPS.
+2. Build version 2, package it, run `viva feed --patch-from /==`, host the files over HTTPS.
3. Start version 1, check, download, install, and confirm version 2 starts.
-4. Repeat with the network dropped mid-download, with a tampered archive, and
+4. Install version 1 again and repeat through the delta, then repeat the delta
+ with a changed installed payload, with a truncated patch, and with a patch
+ from another version: each must fall back to the full archive.
+5. Repeat with the network dropped mid-download, with a tampered archive, and
with a feed signed by another key.
-5. On macOS, confirm `codesign --verify --deep --strict` passes on the new
+6. On macOS, confirm `codesign --verify --deep --strict` passes on the new
bundle and that the app opens with the network off (the ticket is stapled).
-6. On Windows, confirm the installer is silent, per-user, and that the new
+ This also covers a bundle applied from a patch.
+7. On Windows, confirm the installer is silent, per-user, and that the new
program starts after the old one exits.
diff --git a/docs/web-views.md b/docs/web-views.md
index 81965e58..6bfb1ba6 100644
--- a/docs/web-views.md
+++ b/docs/web-views.md
@@ -83,10 +83,16 @@ scheme, a message in each direction and a menu that stands over the page.
included, goes through it, names are resolved by the proxy, and WebRTC sends no UDP around it, so the page
reaches only what the proxy reaches (a tunnel to a remote network, Tor, a test harness). On Windows each
proxy has its own WebView2 browser, in a folder of its own under the user data folder; on Linux an ephemeral
- WebKit context (WebKitGTK 2.16 or newer). Either ends with its last page. macOS cannot hold a page to a proxy,
- and `Open` fails with `ErrUnsupported` rather than open one that connects directly: WKWebView's
- `proxyConfigurations` (macOS 14) sends names to the proxy unresolved, but the system exempts loopback
- addresses from it, so `http://127.0.0.1:3000/` would reach the user's own machine.
+ WebKit context (WebKitGTK 2.16 or newer). Either ends with its last page. macOS cannot hold a page to a
+ SOCKS5 proxy, and `Open` fails with `ErrUnsupported` rather than open one that connects directly:
+ WKWebView's `proxyConfigurations` (macOS 14) sends names to the proxy unresolved, but the system exempts
+ loopback addresses from it, so `http://127.0.0.1:3000/` would reach the user's own machine.
+- `Proxy: app.WebProxyDirect` bypasses the system proxy for this page alone, which an embedded document or
+ workspace page needs when the machine's proxy rejects it. macOS needs 14 or newer and returns
+ `ErrUnsupported` on older versions; Windows passes `--no-proxy-server` to the page's own WebView2
+ environment; Linux sets WebKitGTK's no-proxy mode on the page's own context. This selects a route, not a
+ privacy guarantee. Direct pages also use a nonpersistent store on macOS and Linux even when `Ephemeral` is
+ false, so cookies and storage do not outlive them; Windows keeps its dedicated persistent WebView2 profile.
- `TopCornerRadius` rounds the page's two top corners in logical pixels, for a page that sits inside a rounded
window or toolbar; zero keeps it rectangular. macOS cuts the rounded outline out of the page and refuses the
pointer in the clipped corners, and Windows cuts the same outline into the child window's region. GTK has no
diff --git a/gfx/box_drawing.go b/gfx/box_drawing.go
index a433f295..7589475b 100644
--- a/gfx/box_drawing.go
+++ b/gfx/box_drawing.go
@@ -148,7 +148,7 @@ func (g *cellGlyph) boxDrawing(r rune) {
// instead of overdrawn.
func (g *cellGlyph) drawLines(lines [4]boxStyle) {
cellWidth, cellHeight := g.width, g.height
- light, heavy := g.rule, g.rule*2
+ light, heavy := g.rule, g.heavy
hLightTop := (cellHeight - light) / 2
hLightBottom := hLightTop + light
hHeavyTop := (cellHeight - heavy) / 2
@@ -273,14 +273,14 @@ func (g *cellGlyph) drawDash(dash boxDash) {
length, span := g.width, g.height
thick := g.rule
if dash.style == boxHeavy {
- thick *= 2
+ thick = g.heavy
}
if dash.vertical {
length, span = g.height, g.width
}
gap := g.rule
if dash.gapStyle == boxHeavy {
- gap *= 2
+ gap = g.heavy
}
if gap < dash.minGap {
gap = dash.minGap
diff --git a/gfx/canvas.go b/gfx/canvas.go
index 8f253353..4c5fa290 100644
--- a/gfx/canvas.go
+++ b/gfx/canvas.go
@@ -425,6 +425,9 @@ const (
PointerCursorResizeNWSE
PointerCursorResizeNESW
PointerCursorNotAllowed
+ // PointerCursorGrabbing is the closed hand shown while dragging something
+ // that can be grabbed.
+ PointerCursorGrabbing
// PointerCursorHidden removes the pointer entirely: for a surface that
// draws its own pointer, such as a remote desktop.
PointerCursorHidden
@@ -518,6 +521,9 @@ type HitOptions struct {
TextSize float32
TextWeight float32
TextColor Color
+ // TextAlignment is the line's placement within TextBounds. Zero preserves
+ // an ordinary input's left alignment; the component resolves its default.
+ TextAlignment Alignment
// HideNativeOverlays asks the platform text service to keep its caret and
// selection invisible while preserving native editing, IME and clipboard
// behavior. Components that paint their own caret and selection set this
@@ -569,6 +575,7 @@ type HitText struct {
Size float32
Weight float32
Color Color
+ Alignment Alignment
Placement TextPlacement
}
@@ -669,6 +676,7 @@ type Canvas struct {
// the next one (see Rectangle.Order).
order uint32
pathPoints []Point
+ dashPoints []Point // one dash's outline at a time, while pathPoints keeps the source
contourEnds []int32
contourClosed []bool
// iconPartCenters is where each contour of an icon in motion turns and grows,
@@ -717,6 +725,10 @@ type Canvas struct {
// fits remembers the lines FitLine cut; a different measurement empties it.
fits []fitEntry
hovered ID
+ // hoveredRole is the role of the hovered hit, when the runtime recorded one.
+ // Components that draw content inside a menu use it to tell their own rows
+ // from caller content under the pointer.
+ hoveredRole Role
// prevHovered is the hit the pointer was on at the end of the previous
// paint, which is what HoverCrossed compares against.
prevHovered ID
@@ -804,7 +816,7 @@ func (canvas *Canvas) CellSize(size float32) (width, height float32) {
// glyph that fills its cell, such as a powerline arrow, ends where the next cell's begins
// and no pixel column holds the glyphs of two cells drawn in two colors.
func (canvas *Canvas) MonoCellSize(size float32, face MonoFace) (width, height float32) {
- width, height, _ = canvas.monoCell(size, face)
+ width, height, _, _, _ = canvas.monoCell(size, face)
return width, height
}
@@ -816,25 +828,27 @@ func (canvas *Canvas) MonoCellSize(size float32, face MonoFace) (width, height f
// the face, and for a label of no height: the renderers then place the line by their own
// rule.
func (canvas *Canvas) MonoBaseline(size float32, face MonoFace, height float32) float32 {
- _, cell, baseline := canvas.monoCell(size, face)
+ _, cell, baseline, _, _ := canvas.monoCell(size, face)
if cell <= 0 || baseline <= 0 || height <= 0 {
return 0
}
return (height-cell)/2 + baseline
}
-// monoCell is MonoCellSize and the baseline of the cell, measured once per face, size,
-// scale and font generation. The baseline is 0 when no engine measured the face.
-func (canvas *Canvas) monoCell(size float32, face MonoFace) (width, height, baseline float32) {
+// monoCell is MonoCellSize, the baseline of the cell and the thicknesses of the face's own
+// light and heavy box drawing strokes, measured once per face, size, scale and font
+// generation. The baseline is 0 and the strokes the reference thickness when no engine
+// measured the face.
+func (canvas *Canvas) monoCell(size float32, face MonoFace) (width, height, baseline, rule, heavy float32) {
if size <= 0 {
- return 0, 0, 0
+ return 0, 0, 0, 0, 0
}
if !validMonoFace(face) {
face = MonoSystem
}
generation := canvas.FontGeneration()
- if width, height, baseline, ok := canvas.cellSizes.find(size, face, canvas.pixelScale, generation); ok {
- return width, height, baseline
+ if width, height, baseline, rule, heavy, ok := canvas.cellSizes.find(size, face, canvas.pixelScale, generation); ok {
+ return width, height, baseline, rule, heavy
}
width, height = size*textmetrics.MonoAdvance, size*textmetrics.MonoLine
switch {
@@ -845,25 +859,37 @@ func (canvas *Canvas) monoCell(size float32, face MonoFace) (width, height, base
case canvas.monoCells != nil:
if cell := canvas.monoCells.MonoCell(size, face); cell.Line() > 0 && cell.Advance > 0 {
width, height, baseline = cell.Advance, cell.CellHeight(), cell.CellBaseline()
+ rule, heavy = cell.Rule, cell.HeavyRule
}
}
+ if rule <= 0 {
+ // No engine measured the face, or its font has no box drawing glyph: the
+ // reference thickness stands in, in the font's own units so it follows the size.
+ rule = size * textmetrics.MonoRule
+ }
+ if heavy <= 0 {
+ // A face that draws a heavy line reports both; one that lacks the heavy glyph,
+ // or no engine at all, gets the weight a heavy stroke has against a light one.
+ heavy = rule * 2
+ }
+ heavy = max(heavy, rule)
// A font's own cell is whole already (MonoCellMetrics.CellHeight); the reference cell
// and a measurer's answer are rounded up, as they always were.
height = float32(int32(height + 0.999))
if canvas.pixelScale > 0 {
width = float32(math.Max(math.Floor(float64(width)*float64(canvas.pixelScale)+0.5), 1)) / canvas.pixelScale
}
- canvas.cellSizes.put(cellSizeMemo{size: size, width: width, height: height, baseline: baseline, face: face, scale: canvas.pixelScale, generation: generation})
- return width, height, baseline
+ canvas.cellSizes.put(cellSizeMemo{size: size, width: width, height: height, baseline: baseline, rule: rule, heavy: heavy, face: face, scale: canvas.pixelScale, generation: generation})
+ return width, height, baseline, rule, heavy
}
// cellSizeMemo is one remembered MonoCellSize answer.
type cellSizeMemo struct {
- size, width, height, baseline float32
- face MonoFace
- scale float32 // the canvas's pixel scale the width was made whole for
- generation uint32 // the font generation it was measured in
- filled bool
+ size, width, height, baseline, rule, heavy float32
+ face MonoFace
+ scale float32 // the canvas's pixel scale the width was made whole for
+ generation uint32 // the font generation it was measured in
+ filled bool
}
// cellSizeMemos remembers the last few MonoCellSize answers: a terminal asks every
@@ -875,14 +901,14 @@ type cellSizeMemos struct {
next int
}
-func (memos *cellSizeMemos) find(size float32, face MonoFace, scale float32, generation uint32) (width, height, baseline float32, ok bool) {
+func (memos *cellSizeMemos) find(size float32, face MonoFace, scale float32, generation uint32) (width, height, baseline, rule, heavy float32, ok bool) {
for i := range memos.entries {
memo := &memos.entries[i]
if memo.filled && memo.size == size && memo.face == face && memo.scale == scale && memo.generation == generation {
- return memo.width, memo.height, memo.baseline, true
+ return memo.width, memo.height, memo.baseline, memo.rule, memo.heavy, true
}
}
- return 0, 0, 0, false
+ return 0, 0, 0, 0, 0, false
}
func (memos *cellSizeMemos) put(memo cellSizeMemo) {
@@ -1598,10 +1624,10 @@ func (canvas *Canvas) HitWithOptions(id ID, bounds Rect, label string, options H
// stretch of hits owns a contiguous stretch of hitTexts (see replay.go).
var text int32
if options.TextBounds != (Rect{}) || options.TextSize != 0 || options.TextWeight != 0 ||
- options.TextColor != (Color{}) || options.Placement != nil {
+ options.TextColor != (Color{}) || options.TextAlignment != 0 || options.Placement != nil {
canvas.hitTexts = append(canvas.hitTexts, HitText{
Bounds: options.TextBounds, Size: options.TextSize, Weight: options.TextWeight,
- Color: options.TextColor, Placement: options.Placement,
+ Color: options.TextColor, Alignment: options.TextAlignment, Placement: options.Placement,
})
text = int32(len(canvas.hitTexts))
}
@@ -1925,6 +1951,7 @@ func (canvas *Canvas) Clips() []Clip {
// runtime-facing; application paint code should only read it.
func (canvas *Canvas) SetInteraction(hovered, pressed, focused ID, focusVisible bool) {
canvas.hovered = hovered
+ canvas.hoveredRole = 0
canvas.pressed = pressed
canvas.focused = focused
canvas.focusVisible = focusVisible
@@ -1935,6 +1962,18 @@ func (canvas *Canvas) Hovered() ID {
return canvas.hovered
}
+// HoveredRole returns the role of the hovered hit, or the zero role when the
+// runtime did not record one.
+func (canvas *Canvas) HoveredRole() Role {
+ return canvas.hoveredRole
+}
+
+// SetHoveredRole records the role of the hovered hit. It is runtime-facing;
+// application paint code should only read it.
+func (canvas *Canvas) SetHoveredRole(role Role) {
+ canvas.hoveredRole = role
+}
+
// HoverCrossed reports whether the pointer reached a different hit since the
// previous paint. Lists use it to move their current row onto the row the
// pointer entered, without letting a pointer that rests in place take the row
diff --git a/gfx/cell_glyphs.go b/gfx/cell_glyphs.go
index 18754c10..50b8d5a6 100644
--- a/gfx/cell_glyphs.go
+++ b/gfx/cell_glyphs.go
@@ -40,11 +40,12 @@ type CellGlyphShapes struct {
Triangles []Triangle
}
-// cellMetrics is the shape of one cell in whole device pixels: its size, the thickness a rule has
-// on it, and how far a shape may reach past it.
+// cellMetrics is the shape of one cell in whole device pixels: its size, the thicknesses a
+// light and a heavy stroke have on it, and how far a shape may reach past it.
type cellMetrics struct {
width, height int32
rule int32
+ heavy int32
padX, padY int32
}
@@ -59,8 +60,8 @@ type cellGlyph struct {
}
// cellLine is a text seen as cells: the scale back to logical pixels, and what every cell of the
-// line has in common — its top edge on the device pixel grid, its height, the rule its font size
-// gives it, and the layer and clip its shapes are drawn in. The width is not here: it is the
+// line has in common — its top edge on the device pixel grid, its height, the strokes its face
+// draws, and the layer and clip its shapes are drawn in. The width is not here: it is the
// distance to the next cell's snapped edge, so it belongs to the column.
type cellLine struct {
text Text
@@ -68,12 +69,16 @@ type cellLine struct {
top float32 // the line's top edge, logical pixels, on the device pixel grid
height int32 // device pixels
padY int32
- // rule is one rule's thickness, in device pixels.
+ // rule and heavy are the thicknesses, in device pixels, of a light and a heavy stroke.
//
- // ponytail: it is 5% of the font size, the underline a fixed-pitch face draws, because no
- // text engine reports its underline thickness yet; an engine that does can hand it over the
- // way MonoCell hands the cell, and only the line that computes it changes.
- rule int32
+ // They are the strokes the face draws its own box drawing lines with
+ // (MonoCellMetrics.Rule and HeavyRule) at the line's scale, rounded to the nearest whole
+ // device pixel and never thinner than one: a box drawing or block character drawn as
+ // geometry then weighs what the font's own glyph weighs, where an underline thickness or
+ // a share of the font size is only a guess at that weight. The reference thicknesses
+ // stand in when no engine measured the face.
+ rule int32
+ heavy int32
}
// rect fills the rectangle between two points of the cell, both in device pixels from its corner,
@@ -230,7 +235,7 @@ func (l cellLine) cellAt(column, cells int32) (Point, cellMetrics) {
to := round32((left + float64(column+cells)*float64(l.text.CellWidth)) * l.scale)
width := max(to-from, 1)
return Point{X: float32(float64(from) / l.scale), Y: l.top},
- cellMetrics{width: width, height: l.height, rule: l.rule, padX: width / 4, padY: l.padY}
+ cellMetrics{width: width, height: l.height, rule: l.rule, heavy: l.heavy, padX: width / 4, padY: l.padY}
}
// place appends shapes to dst at the cell's corner, scaled back to logical pixels, in the run's
@@ -280,10 +285,13 @@ func (canvas *Canvas) cellLine(text Text) cellLine {
scale := canvas.deviceScale()
top := round32(float64(text.Bounds.Y) * scale)
height := max(round32(float64(text.Bounds.Y+text.Bounds.Height)*scale)-top, 1)
+ _, _, _, rule, heavy := canvas.monoCell(text.Style.Size, text.Style.MonoFace)
+ light := max(round32(float64(rule)*scale), 1)
return cellLine{
text: text, scale: scale, top: float32(float64(top) / scale),
height: height, padY: height / 4,
- rule: max(round32(0.05*float64(text.Style.Size)*scale), 1),
+ rule: light,
+ heavy: max(round32(float64(heavy)*scale), light),
}
}
diff --git a/gfx/cell_glyphs_test.go b/gfx/cell_glyphs_test.go
index 6656b1ac..ce6fe6c8 100644
--- a/gfx/cell_glyphs_test.go
+++ b/gfx/cell_glyphs_test.go
@@ -269,6 +269,101 @@ func FuzzReplaceCellGlyphs(f *testing.F) {
})
}
+// A cell glyph's strokes are the face's own (MonoCellMetrics.Rule and HeavyRule) at the
+// line's scale, each rounded to the nearest device pixel and never thinner than one: a box
+// drawing character drawn as geometry then weighs what the font's own glyph weighs, light
+// or heavy, and a face whose heavy line is not twice its light one keeps both weights.
+func TestCellGlyphRulesFollowTheFacesOwnStrokes(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ rule float32 // logical pixels, as the engine reports them
+ heavy float32
+ scale float32
+ wantRule float32 // device pixels
+ wantHeavy float32
+ }{
+ {"thinner than a device pixel at 1x", 0.4, 0.8, 1, 1, 1},
+ {"a fraction rounded down", 0.65, 1.1, 2, 1, 2},
+ {"a fraction rounded up", 1.2, 2.4, 2, 2, 5},
+ // The face from the issue: a 16 px Cascadia Mono cell at 175 percent, whose heavy
+ // stem is 4.24 times its light one and not twice its underline.
+ {"a font whose heavy line is over twice its light one", 1.656, 3.312, 1.75, 3, 6},
+ } {
+ for _, glyph := range []struct {
+ value string
+ want float32
+ }{{"│", test.wantRule}, {"┃", test.wantHeavy}} {
+ var canvas Canvas
+ canvas.SetPixelScale(test.scale)
+ canvas.SetTextEngine(&monoCellEngine{MonoCellMetrics: MonoCellMetrics{
+ Advance: 0.6, Ascent: 0.8, Descent: 0.2, Rule: test.rule, HeavyRule: test.heavy,
+ }})
+ cellWidth, cellHeight := float32(10), float32(20)
+ canvas.LabelRuns(glyph.value, Rect{Width: cellWidth, Height: cellHeight}, TextStyle{Size: 10}, cellWidth,
+ []TextRun{{Column: 0, Bytes: int32(len(glyph.value)), Cells: 1, Color: Color{Red: 1, Alpha: 1}}})
+ texts := canvas.Texts()
+ shapes := canvas.CellGlyphGeometry(CellGlyphShapes{}, texts[len(texts)-1])
+ var strokes int
+ for _, shape := range shapes.Rectangles {
+ bounds := shape.Bounds
+ if bounds.Height < cellHeight/2 || bounds.Width >= cellWidth/2 {
+ continue // not a vertical stroke of the glyph
+ }
+ strokes++
+ if got := bounds.Width * test.scale; math.Abs(float64(got-glyph.want)) > 1e-3 {
+ t.Errorf("%s, %s at %gx: a vertical stroke is %g device pixels, want %g",
+ test.name, glyph.value, test.scale, got, glyph.want)
+ }
+ }
+ if strokes == 0 {
+ t.Errorf("%s: %s draws no vertical stroke", test.name, glyph.value)
+ }
+ }
+ }
+}
+
+// A face that does not report a stroke falls back to the reference weight, and one that
+// reports a light line but no heavy one draws the heavy line twice as wide.
+func TestCellGlyphRulesFallBackToTheReferenceWeight(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ metrics MonoCellMetrics
+ wantRule float32
+ wantHeavy float32
+ }{
+ {"no measured strokes", MonoCellMetrics{Advance: 0.6, Ascent: 0.8, Descent: 0.2}, 1, 2},
+ {"a light line only", MonoCellMetrics{Advance: 0.6, Ascent: 0.8, Descent: 0.2, Rule: 1}, 2, 4},
+ } {
+ for _, glyph := range []struct {
+ value string
+ want float32
+ }{{"│", test.wantRule}, {"┃", test.wantHeavy}} {
+ var canvas Canvas
+ canvas.SetPixelScale(2)
+ canvas.SetTextEngine(&monoCellEngine{MonoCellMetrics: test.metrics})
+ cellWidth, cellHeight := float32(10), float32(20)
+ canvas.LabelRuns(glyph.value, Rect{Width: cellWidth, Height: cellHeight}, TextStyle{Size: 10}, cellWidth,
+ []TextRun{{Column: 0, Bytes: int32(len(glyph.value)), Cells: 1, Color: Color{Red: 1, Alpha: 1}}})
+ texts := canvas.Texts()
+ shapes := canvas.CellGlyphGeometry(CellGlyphShapes{}, texts[len(texts)-1])
+ var strokes int
+ for _, shape := range shapes.Rectangles {
+ bounds := shape.Bounds
+ if bounds.Height < cellHeight/2 || bounds.Width >= cellWidth/2 {
+ continue
+ }
+ strokes++
+ if got := bounds.Width * 2; math.Abs(float64(got-glyph.want)) > 1e-3 {
+ t.Errorf("%s, %s: a vertical stroke is %g device pixels, want %g", test.name, glyph.value, got, glyph.want)
+ }
+ }
+ if strokes == 0 {
+ t.Errorf("%s: %s draws no vertical stroke", test.name, glyph.value)
+ }
+ }
+ }
+}
+
// runsOver covers value with one run per stretch between the byte offsets the cuts
// name, so a cut can fall anywhere, glyphs included.
func runsOver(value, cuts string) []TextRun {
diff --git a/gfx/dashed_outline.go b/gfx/dashed_outline.go
new file mode 100644
index 00000000..abe1e3f5
--- /dev/null
+++ b/gfx/dashed_outline.go
@@ -0,0 +1,29 @@
+package gfx
+
+// DashedRoundedOutline strokes a dashed line inside bounds without painting
+// the interior, as RoundedOutline does solid. On the rectangle's whole
+// perimeter the repetition is fitted so the pattern closes where it began: the
+// dashes are one repeating rhythm around the outline, six parts paint to four
+// parts gap, not a fixed pixel length whose remainder lands as a short stub.
+func (canvas *Canvas) DashedRoundedOutline(bounds Rect, radius, width float32, color Color) {
+ if width <= 0 || color.Alpha <= 0 || bounds.Width <= 0 || bounds.Height <= 0 {
+ return
+ }
+ if canvas.pixelScale > 0 {
+ bounds = canvas.snapBox(bounds, radius)
+ width = canvas.borderWidth(width)
+ }
+ if bounds.Width <= width*2 || bounds.Height <= width*2 {
+ // Too small to dash: the whole box is the line, as RoundedOutline has it.
+ canvas.Rounded(bounds, radius, color)
+ return
+ }
+ // The stroke's centerline runs half the width inside the outline, so the
+ // painted line still sits entirely within bounds.
+ centerline := Rect{X: bounds.X + width/2, Y: bounds.Y + width/2, Width: bounds.Width - width, Height: bounds.Height - width}
+ radius = min(max(radius-width/2, 0), min(centerline.Width, centerline.Height)/2)
+ canvas.pathPoints = appendRoundedRectContour(canvas.pathPoints[:0], centerline, radius)
+ canvas.contourEnds = append(canvas.contourEnds[:0], int32(len(canvas.pathPoints)))
+ canvas.contourClosed = append(canvas.contourClosed[:0], true)
+ canvas.dashStrokeContours(color.Alpha >= 0.999, width/2, CapButt, JoinMiter, color)
+}
diff --git a/gfx/dashed_outline_test.go b/gfx/dashed_outline_test.go
new file mode 100644
index 00000000..47ad61fe
--- /dev/null
+++ b/gfx/dashed_outline_test.go
@@ -0,0 +1,175 @@
+package gfx
+
+import (
+ "math"
+ "testing"
+)
+
+// pointCovered reports whether triangles paint the point, ignoring their
+// feathering: a solid border covers every point of its band, a dashed one
+// leaves gaps behind.
+func pointCovered(triangles []Triangle, point Point) bool {
+ for _, triangle := range triangles {
+ a, b, c := triangle.Points[0], triangle.Points[1], triangle.Points[2]
+ d1 := (point.X-b.X)*(a.Y-b.Y) - (a.X-b.X)*(point.Y-b.Y)
+ d2 := (point.X-c.X)*(b.Y-c.Y) - (b.X-c.X)*(point.Y-c.Y)
+ d3 := (point.X-a.X)*(c.Y-a.Y) - (c.X-a.X)*(point.Y-a.Y)
+ negative := d1 < 0 || d2 < 0 || d3 < 0
+ positive := d1 > 0 || d2 > 0 || d3 > 0
+ if !(negative && positive) {
+ return true
+ }
+ }
+ return false
+}
+
+// The dashed outline paints a band with holes in it: painted and unpainted
+// stretches alternate along the band, which distinguishes it from a solid
+// RoundedOutline (all painted) and from nothing (none painted).
+func TestDashedRoundedOutlineHasGapsAndPaint(t *testing.T) {
+ var canvas Canvas
+ ink := Color{Red: 1, Alpha: .5}
+ rect := Rect{X: 10, Y: 10, Width: 80, Height: 40}
+ canvas.DashedRoundedOutline(rect, 8, 2, ink)
+ triangles := canvas.Triangles()
+ if len(triangles) == 0 {
+ t.Fatal("the dashed border painted no geometry")
+ }
+ for _, triangle := range triangles {
+ if triangle.Color != ink {
+ t.Errorf("a translucent dash changed color to %+v", triangle.Color)
+ }
+ }
+ // Sample the band a pixel above the bottom outline: paint and gaps must
+ // alternate along it.
+ bottom := rect.Y + rect.Height - 1
+ painted, gaps := 0, 0
+ covered := pointCovered(triangles, Point{X: rect.X + 4, Y: bottom})
+ for x := rect.X + 4.5; x < rect.X+rect.Width-4; x += 0.5 {
+ next := pointCovered(triangles, Point{X: x, Y: bottom})
+ if next {
+ painted++
+ }
+ if next != covered {
+ gaps++
+ covered = next
+ }
+ }
+ if painted == 0 || gaps < 2 {
+ t.Fatalf("%d covered samples and %d coverage changes along the bottom band, want paint and gaps to alternate", painted, gaps)
+ }
+}
+
+// A rounded outline dashes through its corners: the four corner bands each
+// carry paint somewhere in them, not a blank where the dash phase happened to
+// end at a corner.
+func TestDashedRoundedOutlineCornersCarryPaint(t *testing.T) {
+ var canvas Canvas
+ canvas.DashedRoundedOutline(Rect{Width: 60, Height: 60}, 12, 2, Color{Alpha: 1})
+ triangles := canvas.Triangles()
+ // Each corner band is the quarter arc of the outline in that corner: a
+ // 12x12 box just inside it.
+ corners := [4][2]float32{{0, 0}, {48, 0}, {0, 48}, {48, 48}}
+ for _, corner := range corners {
+ var covered bool
+ for dx := float32(0); dx <= 12 && !covered; dx += 0.25 {
+ for dy := float32(0); dy <= 12 && !covered; dy += 0.25 {
+ covered = covered || pointCovered(triangles, Point{X: corner[0] + dx, Y: corner[1] + dy})
+ }
+ }
+ if !covered {
+ t.Fatalf("corner band %v has no paint", corner)
+ }
+ }
+}
+
+func TestDashedPixelsStayInsideThePaddedOutline(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ bounds Rect
+ radius float32
+ }{
+ {"square", Rect{Width: 40, Height: 20}, 0},
+ {"rounded", Rect{Width: 40, Height: 20}, 8},
+ {"pill", Rect{Width: 60, Height: 16}, 16},
+ } {
+ var canvas Canvas
+ canvas.DashedRoundedOutline(test.bounds, test.radius, 2, Color{Alpha: 1})
+ if len(canvas.Triangles()) == 0 {
+ t.Fatalf("%s: nothing painted", test.name)
+ }
+ for _, triangle := range canvas.Triangles() {
+ for _, point := range triangle.Points {
+ if point.X < test.bounds.X-0.5 || point.Y < test.bounds.Y-0.5 ||
+ point.X > test.bounds.X+test.bounds.Width+0.5 || point.Y > test.bounds.Y+test.bounds.Height+0.5 {
+ t.Fatalf("%s: stroke geometry at %+v is outside the element %+v", test.name, point, test.bounds)
+ }
+ }
+ }
+ }
+}
+
+// PathStyle.Dashed drives the same walk: a stroked closed line breaks into
+// dashes along its arc length, each painted through the stroke pipeline (so a
+// translucent stroke stays seam-free and keeps its color).
+func TestDrawPathDashedStrokeIsShorterThanSolid(t *testing.T) {
+ path := NewPath(40, 20, MoveTo(1, 1), LineTo(39, 1), LineTo(39, 19), LineTo(1, 19), ClosePath())
+ var solid, dashed Canvas
+ solid.DrawPath(path, Rect{Width: 40, Height: 20}, PathStyle{Stroke: Color{Alpha: .5}, StrokeWidth: 2, Join: JoinMiter})
+ dashed.DrawPath(path, Rect{Width: 40, Height: 20}, PathStyle{Stroke: Color{Alpha: .5}, StrokeWidth: 2, Join: JoinMiter, Dashed: true})
+ solidArea, dashedArea := triangleCoverArea(solid.Triangles()), triangleCoverArea(dashed.Triangles())
+ if len(dashed.Triangles()) == 0 || dashedArea <= 0 {
+ t.Fatal("the dashed stroke painted nothing")
+ }
+ if dashedArea >= solidArea {
+ t.Fatalf("dashed stroke area %v, want less than the solid stroke's %v", dashedArea, solidArea)
+ }
+ for _, triangle := range dashed.Triangles() {
+ if triangle.Color != (Color{Alpha: .5}) {
+ t.Fatalf("a translucent dash changed color to %+v", triangle.Color)
+ }
+ }
+ // The fitted pattern paints about the 6:4 share of the solid band; the
+ // tolerance covers the corner joins of the closed square.
+ if math.Abs(float64(dashedArea)-float64(solidArea)*0.6) > float64(solidArea)*0.25 {
+ t.Fatalf("dashed stroke area %v, solid %v: not the 6:4 rhythm", dashedArea, solidArea)
+ }
+}
+
+// A closed outline fits whole pattern periods to its perimeter: no stub dash is
+// left at the starting corner and no gap is painted over. Whatever the radius,
+// sampling the whole top edge band finds some painted samples and some gaps,
+// and the outline is never empty or solid.
+func TestDashedOutlineSplitsIntoAlternatingPaintAndGaps(t *testing.T) {
+ rect := Rect{X: 5, Y: 5, Width: 100, Height: 58}
+ for _, radius := range []float32{0, 6, 14} {
+ var canvas Canvas
+ canvas.DashedRoundedOutline(rect, radius, 2, Color{Alpha: 1})
+ triangles := canvas.Triangles()
+ if len(triangles) == 0 {
+ t.Fatalf("radius %v: nothing painted", radius)
+ }
+ covered := 0
+ total := 0
+ for x := rect.X + 0.5; x < rect.X+rect.Width; x += 0.5 {
+ total++
+ if pointCovered(triangles, Point{X: x, Y: rect.Y + 1}) {
+ covered++
+ }
+ }
+ if covered == 0 || covered == total {
+ t.Fatalf("radius %v: %d of %d samples covered, want an alternation", radius, covered, total)
+ }
+ }
+}
+
+func TestDashedOutlineIsAllocationFreeOnAWarmCanvas(t *testing.T) {
+ var canvas Canvas
+ paint := func() {
+ canvas.DashedRoundedOutline(Rect{X: 10, Y: 10, Width: 80, Height: 40}, 8, 2, Color{Alpha: 1})
+ }
+ paint()
+ if allocs := testing.AllocsPerRun(200, paint); allocs != 0 {
+ t.Fatalf("warm dashed outline allocates %v per paint, want 0", allocs)
+ }
+}
diff --git a/gfx/path.go b/gfx/path.go
index 104111ef..f96dfc8b 100644
--- a/gfx/path.go
+++ b/gfx/path.go
@@ -98,9 +98,12 @@ type PathStyle struct {
FillGradient *LinearGradient
Stroke Color
StrokeWidth float32
- FillRule PathFillRule
- Cap StrokeCap
- Join StrokeJoin
+ // Dashed breaks the stroke into equal dashes and gaps, continuing around
+ // corners and closed contours without closing a short gap.
+ Dashed bool
+ FillRule PathFillRule
+ Cap StrokeCap
+ Join StrokeJoin
}
// Path is immutable vector geometry in a fixed view box. NewPath copies the
@@ -242,25 +245,124 @@ func (canvas *Canvas) drawPath(path Path, bounds Rect, style PathStyle) {
if style.Stroke.Alpha > 0 && style.StrokeWidth > 0 {
half := style.StrokeWidth * 0.5
opaque := style.Stroke.Alpha >= 0.999
- start := int32(0)
- for index, end := range canvas.contourEnds {
- points := canvas.pathPoints[start:end]
- if len(points) >= 2 {
- if opaque {
- // Opaque strokes take the overlapping decomposition:
- // segment quads plus round join discs. Overlap is
- // invisible at full alpha and the geometry stays
- // robust when contour features shrink below the
- // stroke width, where offset outlines self-intersect.
- canvas.strokeContourOverlapping(points, canvas.contourClosed[index], half, style.Cap, style.Join, style.Stroke)
+ if style.Dashed {
+ canvas.dashStrokeContours(opaque, half, style.Cap, style.Join, style.Stroke)
+ } else {
+ start := int32(0)
+ for index, end := range canvas.contourEnds {
+ points := canvas.pathPoints[start:end]
+ canvas.strokeFlattenedContour(points, canvas.contourClosed[index], opaque, half, style.Cap, style.Join, style.Stroke)
+ start = end
+ }
+ }
+ }
+}
+
+// strokeFlattenedContour gives one flattened contour the cap and join it was asked for.
+func (canvas *Canvas) strokeFlattenedContour(points []Point, closed, opaque bool, half float32, cap StrokeCap, join StrokeJoin, color Color) {
+ if len(points) >= 2 {
+ if opaque {
+ // Opaque strokes take the overlapping decomposition:
+ // segment quads plus round join discs. Overlap is
+ // invisible at full alpha and the geometry stays
+ // robust when contour features shrink below the
+ // stroke width, where offset outlines self-intersect.
+ canvas.strokeContourOverlapping(points, closed, half, cap, join, color)
+ } else {
+ canvas.strokeContour(points, closed, half, cap, join, color)
+ }
+ } else if len(points) == 1 && cap == CapRound {
+ canvas.appendDisc(points[0], half, color)
+ }
+}
+
+// dashStrokeContours splits flattened contours into equal dashes and gaps,
+// keeping the 6:4 dash-to-gap ratio of the dashed box-drawing glyphs. On a
+// closed contour the repetition is fitted to the perimeter — the same number of
+// periods however long the outline is, each a whole pattern cycle — so the
+// pattern closes exactly at its starting corner instead of leaving a partial
+// dash. Arc-length phase continues across corners and curves; the outline of
+// every dash is cut at the phase boundary itself, so a translucent stroke
+// stays seam-free.
+func (canvas *Canvas) dashStrokeContours(opaque bool, half float32, cap StrokeCap, join StrokeJoin, color Color) {
+ const (
+ dashShare = 6
+ gapShare = 4
+ )
+ // dashUnitCut is the arc length one full period is aimed at, in stroke
+ // widths: ten times the width, as a dashed UI border reads at any size.
+ aimedPeriod := half * 2 * 10
+ sourceStart := int32(0)
+ for contour, end := range canvas.contourEnds {
+ source := canvas.pathPoints[sourceStart:end]
+ sourceStart = end
+ if len(source) < 2 {
+ continue
+ }
+ closed := canvas.contourClosed[contour]
+ last := len(source)
+ if !closed {
+ last--
+ }
+ var length float32
+ for index := 0; index < last; index++ {
+ length += distance(source[index], source[(index+1)%len(source)])
+ }
+ if length <= 0 {
+ continue
+ }
+ dash, gap := aimedPeriod*dashShare/10, aimedPeriod*gapShare/10
+ if closed {
+ // A whole number of periods closes the contour: the pattern's phase
+ // returns to its start exactly as the point does.
+ periods := max(int(length/aimedPeriod+0.5), 1)
+ dash, gap = length/float32(periods)*dashShare/10, length/float32(periods)*gapShare/10
+ }
+ canvas.dashPoints = canvas.dashPoints[:0]
+ canvas.dashPoints = append(canvas.dashPoints, source[0])
+ on, remaining := true, dash
+ index, at := 0, float32(0)
+ for index < last {
+ from, to := source[index], source[(index+1)%len(source)]
+ segmentLength := distance(from, to)
+ if segmentLength <= 0 {
+ index++
+ continue
+ }
+ for remaining < segmentLength-at {
+ cut := from
+ along := at + remaining
+ if along > 0 {
+ fraction := min(along/segmentLength, 1)
+ cut = Point{X: from.X + (to.X-from.X)*fraction, Y: from.Y + (to.Y-from.Y)*fraction}
+ }
+ if on {
+ canvas.dashPoints = append(canvas.dashPoints, cut)
+ canvas.strokeFlattenedContour(canvas.dashPoints, false, opaque, half, cap, join, color)
+ canvas.dashPoints = canvas.dashPoints[:0]
} else {
- canvas.strokeContour(points, canvas.contourClosed[index], half, style.Cap, style.Join, style.Stroke)
+ canvas.dashPoints = append(canvas.dashPoints, cut)
+ }
+ on, remaining = !on, gap
+ if on {
+ remaining = dash
}
- } else if len(points) == 1 && style.Cap == CapRound {
- canvas.appendDisc(points[0], half, style.Stroke)
+ at = along
}
- start = end
+ remaining -= segmentLength - at
+ at = 0
+ if on {
+ canvas.dashPoints = append(canvas.dashPoints, to)
+ }
+ index++
+ }
+ if on && len(canvas.dashPoints) >= 2 {
+ // The contour ends inside a dash. A closed one whose whole periods
+ // land on source[0] never reaches here; an open one, or a rounding
+ // remainder, keeps the dash open rather than bridging it.
+ canvas.strokeFlattenedContour(canvas.dashPoints, false, opaque, half, cap, join, color)
}
+ canvas.dashPoints = canvas.dashPoints[:0]
}
}
diff --git a/gfx/text_engine.go b/gfx/text_engine.go
index ed18bedc..194eac83 100644
--- a/gfx/text_engine.go
+++ b/gfx/text_engine.go
@@ -84,6 +84,16 @@ type MonoCellMetrics struct {
Ascent float32 // baseline to the top of the line box
Descent float32 // baseline to the bottom of the line box
LineGap float32 // the space the font asks between two line boxes; zero where the engine derives Descent from the font's line height (Windows and Linux), which folds the gap into it
+ // Rule and HeavyRule are the thicknesses, in logical pixels, of the strokes the
+ // face draws with a light (U+2502) and a heavy (U+2503) box drawing line: the
+ // weights a cell glyph drawn as geometry takes, so that it weighs what the font's
+ // own glyph would weigh. They are the glyphs' ink widths, not a share of the font
+ // size and not the underline thickness, which a face may draw at a different weight
+ // than its box lines. Zero when the engine does not report it, or its face lacks
+ // the glyph; the canvas then uses the reference thickness
+ // (internal/textmetrics.MonoRule).
+ Rule float32
+ HeavyRule float32
}
// Line is the height of the font's line box: its ascent, its descent and the gap it asks
diff --git a/go.mod b/go.mod
index 7d4b079f..1ed861c0 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,12 @@ module github.com/vivaui/viva
go 1.26.0
require (
+ github.com/Microsoft/go-winio v0.6.4
github.com/ebitengine/purego v0.11.1
+ github.com/gabstv/go-bsdiff v1.0.5
github.com/pelletier/go-toml/v2 v2.4.3
+ golang.org/x/sys v0.50.0
golang.org/x/text v0.44.0
)
+
+require github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 // indirect
diff --git a/go.sum b/go.sum
index d7aef6e2..43d130af 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,14 @@
+github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
+github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
+github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 h1:eX+pdPPlD279OWgdx7f6KqIRSONuK7egk+jDx7OM3Ac=
+github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76/go.mod h1:KjxHHirfLaw19iGT70HvVjHQsL1vq1SRQB4yOsAfy2s=
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
+github.com/gabstv/go-bsdiff v1.0.5 h1:g29MC/38Eaig+iAobW10/CiFvPtin8U3Jj4yNLcNG9k=
+github.com/gabstv/go-bsdiff v1.0.5/go.mod h1:/Zz6GK+/f/TMylRtVaW3uwZlb0FZITILfA0q12XKGwg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
+golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
diff --git a/headless/application.go b/headless/application.go
index 3c149d52..915cadec 100644
--- a/headless/application.go
+++ b/headless/application.go
@@ -20,13 +20,15 @@ import (
type applicationWindow struct {
fullscreen atomic.Bool
fullscreenRequest atomic.Int32
- invalidator *app.Invalidator
- id uint64
- title string
- panel bool
- hidden bool
- session *Session
- closed func()
+ // sizeRequest is the content size SetSize queued for the loop, or nil.
+ sizeRequest atomic.Pointer[[2]int32]
+ invalidator *app.Invalidator
+ id uint64
+ title string
+ panel bool
+ hidden bool
+ session *Session
+ closed func()
// closeRequest is the application's OnCloseRequest, if it set one.
closeRequest func(allow func())
}
@@ -93,6 +95,11 @@ func (application *applicationSession) OpenWindow(config app.Config, canvas *gfx
panic(err)
}
}
+ if size := entry.sizeRequest.Swap(nil); size != nil {
+ if err := entry.session.Resize(int(size[0]), int(size[1])); err != nil {
+ panic(err)
+ }
+ }
if onWake != nil {
onWake()
}
@@ -133,6 +140,21 @@ func (window *headlessWindow) Frame() (gfx.Rect, bool) {
}
func (window *headlessWindow) Show() { window.application.setHidden(window.id, false) }
+// SetSize queues a content-size request for the driver's loop, which reports
+// the granted size through OnResize.
+func (window *headlessWindow) SetSize(width, height int) error {
+ window.application.mu.Lock()
+ entry := window.application.windows[window.id]
+ window.application.mu.Unlock()
+ if entry == nil {
+ return fmt.Errorf("headless: window is closed")
+ }
+ request := [2]int32{int32(width), int32(height)}
+ entry.sizeRequest.Store(&request)
+ entry.invalidator.Invalidate()
+ return nil
+}
+
func (window *headlessWindow) SetFullscreen(fullscreen bool) error {
window.application.mu.Lock()
entry := window.application.windows[window.id]
diff --git a/headless/doc.go b/headless/doc.go
index 3b0452e9..992b9334 100644
--- a/headless/doc.go
+++ b/headless/doc.go
@@ -32,6 +32,27 @@
// session.ClickLabel("id:home/auto")
// session.Expect("text", "Connected", "within", "5000")
//
+// The clock belongs to the test: Now returns it, Advance moves it, and Frame
+// paints one frame without moving it. Transitioning reports whether the last
+// frame asked for the next one, so a test can prove that an animation ended:
+//
+// session.Advance(200 * time.Millisecond)
+// session.Expect("text", "Saved")
+// if session.Transitioning() {
+// t.Error("the transition is still running")
+// }
+//
+// Access returns the last frame's accessibility tree in reading order: what
+// assistive technology is told about every control, with its role, bounds,
+// name, value, states and range. AccessByRole and AccessByName find one node,
+// as a screen reader or a test would:
+//
+// node, ok := session.AccessByRole(gfx.RoleCheckbox, "Auto update")
+// if !ok || !node.Checked { ... }
+//
+// A session has no notification center: Notifications returns the ones the
+// application sent, in order, instead of showing them.
+//
// Record plays the animation clock and writes the frames whose pixels changed,
// so a transition can be reviewed as a sequence rather than a still:
//
diff --git a/headless/extras_test.go b/headless/extras_test.go
new file mode 100644
index 00000000..187f119f
--- /dev/null
+++ b/headless/extras_test.go
@@ -0,0 +1,91 @@
+//go:build darwin || windows || linux
+
+package headless
+
+import (
+ "testing"
+ "time"
+
+ "github.com/vivaui/viva/app"
+ "github.com/vivaui/viva/gfx"
+)
+
+func TestSessionClockMovesOnlyWhenAsked(t *testing.T) {
+ session, target := openTestApp(t)
+ if now := session.Now(); now != 0 {
+ t.Fatalf("clock starts at %v", now)
+ }
+ if session.Transitioning() {
+ t.Fatal("a still frame reports a transition")
+ }
+ if err := session.Advance(100 * time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ if now := session.Now(); now != 100*time.Millisecond {
+ t.Fatalf("clock = %v after advancing 100ms", now)
+ }
+ // A frame can be painted without moving the clock, for state changed from
+ // outside the application.
+ target.clicks = 3
+ if err := session.Frame(); err != nil {
+ t.Fatal(err)
+ }
+ if now := session.Now(); now != 100*time.Millisecond {
+ t.Fatalf("Frame moved the clock to %v", now)
+ }
+ if !session.HasText("Clicks: 3") {
+ t.Fatalf("frame did not show the change: %q", session.Texts())
+ }
+}
+
+func TestSessionAccessReadsTheAccessibilityTree(t *testing.T) {
+ session := layoutSession(t, func(canvas *gfx.Canvas) {
+ canvas.HitWithOptions(gfx.Item(1), gfx.Rect{X: 16, Y: 16, Width: 120, Height: 32}, "Auto update", gfx.HitOptions{
+ Role: gfx.RoleCheckbox, Checked: true, Disabled: true,
+ Description: "Applies updates at night", Level: 2, PosInSet: 2, SetSize: 3,
+ Live: gfx.LivePolite,
+ })
+ canvas.HitWithOptions(gfx.Item(2), gfx.Rect{X: 16, Y: 64, Width: 200, Height: 24}, "Volume", gfx.HitOptions{
+ Role: gfx.RoleSlider, Range: gfx.ValueRange{Now: 0.4, Min: 0, Max: 1, Set: true},
+ })
+ })
+ nodes := session.Access()
+ if len(nodes) != 2 {
+ t.Fatalf("accessibility tree = %+v", nodes)
+ }
+ check, ok := session.AccessByRole(gfx.RoleCheckbox, "Auto update")
+ if !ok {
+ t.Fatalf("no checkbox: %+v", nodes)
+ }
+ if !check.Checked || !check.Disabled || check.Description != "Applies updates at night" ||
+ check.Level != 2 || check.PosInSet != 2 || check.SetSize != 3 || check.Live != gfx.LivePolite {
+ t.Errorf("checkbox = %+v", check)
+ }
+ if check.ID != gfx.Item(1) || check.Bounds.Width != 120 {
+ t.Errorf("checkbox identity or bounds = %+v", check)
+ }
+ slider, ok := session.AccessByName("Volume")
+ if !ok || slider.Role != gfx.RoleSlider || !slider.Range.Set || slider.Range.Now != 0.4 {
+ t.Errorf("slider = %+v, %v", slider, ok)
+ }
+ if _, ok := session.AccessByRole(gfx.RoleButton, ""); ok {
+ t.Error("AccessByRole found a button that is not there")
+ }
+}
+
+func TestSessionRecordsNotifications(t *testing.T) {
+ session, _ := openTestApp(t)
+ if err := app.SendNotification(app.Notification{Title: "Build", Body: "done"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := app.SendNotification(app.Notification{Title: "Upload", Body: "queued"}); err != nil {
+ t.Fatal(err)
+ }
+ got := session.Notifications()
+ if len(got) != 2 || got[0].Title != "Build" || got[1].Body != "queued" {
+ t.Fatalf("notifications = %+v", got)
+ }
+ if err := app.SendNotification(app.Notification{}); err == nil {
+ t.Error("an empty notification was accepted")
+ }
+}
diff --git a/headless/session.go b/headless/session.go
index a12cc91a..c4326d82 100644
--- a/headless/session.go
+++ b/headless/session.go
@@ -187,8 +187,63 @@ func (session *Session) Type(value string) error {
func (session *Session) Compose(value string) error { return session.driver.Compose(value) }
// Wait advances the animation clock.
+// Wait moves the virtual clock forward by duration, running the frames due
+// meanwhile; it is Advance under its older name.
func (session *Session) Wait(duration time.Duration) error { return session.driver.Advance(duration) }
+// Now returns the session's virtual clock, which starts at zero and moves only
+// with Advance and Wait.
+func (session *Session) Now() time.Duration { return session.driver.Now() }
+
+// Advance moves the virtual clock forward by duration, running the frames due
+// meanwhile and applying work other goroutines posted. Nothing moves unless
+// called: an animation sits where the clock left it.
+func (session *Session) Advance(duration time.Duration) error {
+ return session.driver.Advance(duration)
+}
+
+// Transitioning reports whether the last frame asked for the next one: an
+// animation or transition is in flight. A frame that only asked for a later
+// frame, as a spinner does, does not count.
+func (session *Session) Transitioning() bool { return session.driver.Transitioning() }
+
+// Frame processes work other goroutines posted and paints one frame now,
+// without moving the clock. Use it after changing what a view shows from
+// outside the application, without input.
+func (session *Session) Frame() error { return session.driver.Paint() }
+
+// Access returns the accessibility tree of the last frame, in reading order:
+// what assistive technology is told about every control, with its role, bounds,
+// name, value, states and range.
+func (session *Session) Access() []app.AccessNode { return session.driver.Accessibility() }
+
+// AccessByRole returns the first node in the last frame with role and the
+// accessible name; an empty name matches any node with that role. It is the
+// query a test uses to assert what assistive technology is told.
+func (session *Session) AccessByRole(role gfx.Role, name string) (app.AccessNode, bool) {
+ for _, node := range session.Access() {
+ if node.Role == role && (name == "" || node.Label == name) {
+ return node, true
+ }
+ }
+ return app.AccessNode{}, false
+}
+
+// AccessByName returns the first node in the last frame with the accessible
+// name, whatever its role.
+func (session *Session) AccessByName(name string) (app.AccessNode, bool) {
+ for _, node := range session.Access() {
+ if node.Label == name {
+ return node, true
+ }
+ }
+ return app.AccessNode{}, false
+}
+
+// Notifications returns the notifications the application sent in this run, in
+// order. A session has no notification center: sending one records it.
+func (session *Session) Notifications() []app.Notification { return session.driver.Notifications() }
+
// maxRest bounds how long Rest plays animations.
const maxRest = 5 * time.Second
diff --git a/headless/ui_gaps_test.go b/headless/ui_gaps_test.go
new file mode 100644
index 00000000..a7dfa18e
--- /dev/null
+++ b/headless/ui_gaps_test.go
@@ -0,0 +1,97 @@
+//go:build darwin || windows || linux
+
+package headless
+
+import (
+ "testing"
+
+ "github.com/vivaui/viva/app"
+ "github.com/vivaui/viva/components"
+ "github.com/vivaui/viva/gfx"
+ "github.com/vivaui/viva/text"
+)
+
+// A hit that asks for the grabbing cursor shows it while the pointer is over
+// it, end to end: HitOptions.Cursor through the painter into the driver the
+// same gesture carries to a real window's backend.
+func TestHeadlessGrabbingHitIsThePointer(t *testing.T) {
+ session, err := Open(app.Config{Title: "cursor", Width: 80, Height: 60, Paint: func(canvas *gfx.Canvas, size gfx.Size) {
+ canvas.HitWithOptions(gfx.Item(1), gfx.Rect{Width: 60, Height: 40}, "drag", gfx.HitOptions{Cursor: gfx.PointerCursorGrabbing})
+ }}, nil, Options{ReferenceText: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+ if err := session.Move(30, 20); err != nil {
+ t.Fatal(err)
+ }
+ if cursor := session.Snapshot().Cursor; cursor != app.CursorGrabbing {
+ t.Fatalf("headless cursor = %v, want grabbing", cursor)
+ }
+}
+
+// An InputSnapshot that aligns its text to the right carries that through the
+// whole stack: the static line is painted right-aligned, the hit declares it,
+// and the native editor the focused field is given resolves to AlignRight and
+// keeps the line box the component declared, so the text does not move when
+// the field gains focus.
+func TestInputSnapshotRightAlignmentReachesTheHeadlessEditor(t *testing.T) {
+ editor := text.NewEditor("1234")
+ bounds := gfx.Rect{X: 20, Y: 20, Width: 220, Height: 32}
+ hittest := func() (hits []gfx.Hit) { return }
+ _ = hittest
+ config := app.Config{
+ Title: "aligned number", Width: 260, Height: 80,
+ Paint: func(canvas *gfx.Canvas, size gfx.Size) {
+ components.PaintInput(canvas, gfx.LightTheme(), bounds, components.InputSnapshot{ID: gfx.Item(1), Label: "Amount", Value: editor.String(), Alignment: gfx.AlignRight})
+ },
+ TextInput: func(context app.TextInputContext) *app.TextInput {
+ if context.ID != gfx.Item(1) {
+ t.Fatalf("text input is for %v, want the field", context.ID)
+ }
+ if context.Alignment != gfx.AlignRight {
+ t.Fatalf("the declaration reached the provider as %v, want right", context.Alignment)
+ }
+ return &app.TextInput{Editor: editor}
+ },
+ }
+ session, err := Open(config, nil, Options{ReferenceText: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+
+ // Static frame: the line is right-aligned in the content box, and says so.
+ snapshot := session.Snapshot()
+ var textBounds gfx.Rect
+ for i, hit := range snapshot.Hits {
+ if hit.ID == gfx.Item(1) {
+ if declared, ok := snapshot.HitTextAt(i); ok {
+ textBounds = declared.Bounds
+ if declared.Alignment != gfx.AlignRight {
+ t.Fatalf("the declared alignment is %v, want right", declared.Alignment)
+ }
+ }
+ }
+ }
+ if textBounds == (gfx.Rect{}) {
+ t.Fatal("the field declared no text geometry")
+ }
+
+ if err := session.Click(bounds.X+bounds.Width/2, bounds.Y+bounds.Height/2); err != nil {
+ t.Fatal(err)
+ }
+ snapshot = session.Snapshot()
+ if snapshot.Editor == nil {
+ t.Fatal("the click did not focus the field")
+ }
+ if snapshot.Editor.Alignment != gfx.AlignRight {
+ t.Fatalf("the native editor alignment is %v, want right", snapshot.Editor.Alignment)
+ }
+ // The editor sits where the component said the text is: the line box inside
+ // the content rectangle, so the glyphs platform rendering draws are exactly
+ // where the painted ones were.
+ if snapshot.Editor.Bounds != textBounds {
+ t.Fatalf("editor bounds %+v, want the declared text box %+v", snapshot.Editor.Bounds, textBounds)
+ }
+}
diff --git a/headless/window_size_test.go b/headless/window_size_test.go
new file mode 100644
index 00000000..08746f4e
--- /dev/null
+++ b/headless/window_size_test.go
@@ -0,0 +1,52 @@
+//go:build darwin || windows || linux
+
+package headless
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/vivaui/viva/app"
+ "github.com/vivaui/viva/gfx"
+)
+
+func TestApplicationSizeRequestRunsOnDriver(t *testing.T) {
+ host := newApplicationSession(Options{ReferenceText: true})
+ var window *app.Window
+ var sizes []gfx.Size
+ app.UseApplicationRunner(func(start func(app.Host) error) error { return start(host) })
+ defer app.UseApplicationRunner(nil)
+ err := app.RunApplication(func(application *app.Application) error {
+ var err error
+ window, err = application.OpenWindow(app.Config{Title: "size", Width: 500, Height: 300,
+ OnResize: func(size gfx.Size) { sizes = append(sizes, size) },
+ }, nil)
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer host.closeAll()
+ entry := host.windows[1]
+ var requests sync.WaitGroup
+ for range 8 {
+ requests.Go(func() {
+ if err := window.SetSize(420, 240); err != nil {
+ t.Error(err)
+ }
+ })
+ }
+ requests.Wait()
+ if len(sizes) != 0 {
+ t.Fatal("background request ran OnResize off the driver thread")
+ }
+ if err := entry.session.driver.Pump(); err != nil {
+ t.Fatal(err)
+ }
+ if len(sizes) == 0 || sizes[len(sizes)-1].Width != 420 || sizes[len(sizes)-1].Height != 240 {
+ t.Fatalf("OnResize = %v, want 420x240", sizes)
+ }
+ if frame, ok := window.Frame(); !ok || frame.Width != 420 || frame.Height != 240 {
+ t.Fatalf("Frame = %v, %v", frame, ok)
+ }
+}
diff --git a/internal/bsdiff/patch.go b/internal/bsdiff/patch.go
new file mode 100644
index 00000000..63086111
--- /dev/null
+++ b/internal/bsdiff/patch.go
@@ -0,0 +1,124 @@
+// Package bsdiff applies bsdiff 4 patches. It reads the format
+// github.com/gabstv/go-bsdiff (and Colin Percival's original bsdiff) writes:
+// a "BSDIFF40" header and three bzip2 streams (control, diff and extra), with
+// the standard library's bzip2 reader.
+//
+// The update package applies patches on the user's machine, so this file is
+// deliberately the applier only: generation needs a suffix array and belongs
+// in the release command (cmd/viva), which uses go-bsdiff for it.
+package bsdiff
+
+import (
+ "bytes"
+ "compress/bzip2"
+ "errors"
+ "fmt"
+ "io"
+)
+
+// ErrPatch reports a patch that is malformed or does not fit the old data.
+var ErrPatch = errors.New("bsdiff: invalid patch")
+
+// MaxOutput caps the size a patch may produce, so a corrupt or hostile header
+// cannot make the caller allocate an arbitrary amount. Callers that know the
+// size check it themselves too; this is the backstop.
+const MaxOutput = 512 << 20
+
+// Patch applies patch to old and returns the new data.
+func Patch(old, patch []byte) ([]byte, error) {
+ if len(patch) < 32 || string(patch[:8]) != "BSDIFF40" {
+ return nil, fmt.Errorf("%w: not a BSDIFF40 patch", ErrPatch)
+ }
+ ctrlLen, err := offTin(patch[8:16])
+ if err != nil {
+ return nil, err
+ }
+ diffLen, err := offTin(patch[16:24])
+ if err != nil {
+ return nil, err
+ }
+ newSize, err := offTin(patch[24:32])
+ if err != nil {
+ return nil, err
+ }
+ if ctrlLen < 0 || diffLen < 0 || newSize < 0 {
+ return nil, fmt.Errorf("%w: negative length", ErrPatch)
+ }
+ if newSize > MaxOutput {
+ return nil, fmt.Errorf("%w: output %d exceeds %d bytes", ErrPatch, newSize, MaxOutput)
+ }
+ endCtrl := 32 + int(ctrlLen)
+ endDiff := endCtrl + int(diffLen)
+ if int64(endDiff) > int64(len(patch)) || endCtrl < 32 || endDiff < endCtrl {
+ return nil, fmt.Errorf("%w: truncated streams", ErrPatch)
+ }
+ ctrl := bzip2.NewReader(bytes.NewReader(patch[32:endCtrl]))
+ diff := bzip2.NewReader(bytes.NewReader(patch[endCtrl:endDiff]))
+ extra := bzip2.NewReader(bytes.NewReader(patch[endDiff:]))
+
+ out := make([]byte, newSize)
+ var oldPos, newPos int64
+ var header [24]byte
+ for newPos < newSize {
+ if _, err := io.ReadFull(ctrl, header[:]); err != nil {
+ return nil, fmt.Errorf("%w: control stream: %v", ErrPatch, err)
+ }
+ x, err := offTin(header[0:8])
+ if err != nil {
+ return nil, err
+ }
+ y, err := offTin(header[8:16])
+ if err != nil {
+ return nil, err
+ }
+ z, err := offTin(header[16:24])
+ if err != nil {
+ return nil, err
+ }
+ if x < 0 || y < 0 || x > newSize-newPos || y > newSize-newPos-x {
+ return nil, fmt.Errorf("%w: control triple out of range", ErrPatch)
+ }
+ if x == 0 && y == 0 && z == 0 {
+ return nil, fmt.Errorf("%w: control triple makes no progress", ErrPatch)
+ }
+ // The diff stream is added to the old data, byte by byte. Old
+ // positions outside the old file read as zero.
+ scratch := make([]byte, x)
+ if _, err := io.ReadFull(diff, scratch); err != nil {
+ return nil, fmt.Errorf("%w: diff stream: %v", ErrPatch, err)
+ }
+ for i := int64(0); i < x; i++ {
+ var base byte
+ if at := oldPos + i; at >= 0 && at < int64(len(old)) {
+ base = old[at]
+ }
+ out[newPos+i] = base + scratch[i]
+ }
+ newPos += x
+ oldPos += x
+ if y > 0 {
+ if _, err := io.ReadFull(extra, out[newPos:newPos+y]); err != nil {
+ return nil, fmt.Errorf("%w: extra stream: %v", ErrPatch, err)
+ }
+ newPos += y
+ }
+ oldPos += z
+ }
+ return out, nil
+}
+
+// offTin reads a signed 64-bit value the way bsdiff writes it: seven
+// little-endian bytes, the sign in the high bit of the eighth.
+func offTin(b []byte) (int64, error) {
+ if len(b) < 8 {
+ return 0, fmt.Errorf("%w: short integer", ErrPatch)
+ }
+ value := int64(b[7] & 0x7f)
+ for i := 6; i >= 0; i-- {
+ value = value<<8 | int64(b[i])
+ }
+ if b[7]&0x80 != 0 {
+ value = -value
+ }
+ return value, nil
+}
diff --git a/internal/bsdiff/patch_test.go b/internal/bsdiff/patch_test.go
new file mode 100644
index 00000000..ee89b41d
--- /dev/null
+++ b/internal/bsdiff/patch_test.go
@@ -0,0 +1,87 @@
+package bsdiff
+
+import (
+ "bytes"
+ "errors"
+ "math/rand"
+ "testing"
+
+ "github.com/gabstv/go-bsdiff/pkg/bsdiff"
+)
+
+func TestPatchRoundTrip(t *testing.T) {
+ cases := map[string][2][]byte{
+ "identical": {bytes.Repeat([]byte("same"), 1000), bytes.Repeat([]byte("same"), 1000)},
+ "empty old": {nil, []byte("brand new content")},
+ "both empty": {nil, nil},
+ "insertion": {bytes.Repeat([]byte("abcdefgh"), 5000), append(bytes.Repeat([]byte("abcdefgh"), 2500), append([]byte("middle"), bytes.Repeat([]byte("abcdefgh"), 2500)...)...)},
+ "deletion": {append(bytes.Repeat([]byte("abcdefgh"), 2500), append([]byte("gone"), bytes.Repeat([]byte("abcdefgh"), 2500)...)...), bytes.Repeat([]byte("abcdefgh"), 5000)},
+ }
+ for name, data := range cases {
+ patch, err := bsdiff.Bytes(data[0], data[1])
+ if err != nil {
+ t.Fatalf("%s: diff: %v", name, err)
+ }
+ got, err := Patch(data[0], patch)
+ if err != nil {
+ t.Fatalf("%s: patch: %v", name, err)
+ }
+ if !bytes.Equal(got, data[1]) {
+ t.Errorf("%s: patched data differs from the new data", name)
+ }
+ }
+}
+
+func TestPatchRandomData(t *testing.T) {
+ rng := rand.New(rand.NewSource(1))
+ old := make([]byte, 64<<10)
+ rng.Read(old)
+ fresh := make([]byte, len(old))
+ copy(fresh, old)
+ copy(fresh[1000:], []byte("a changed stretch of bytes"))
+ patch, err := bsdiff.Bytes(old, fresh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := Patch(old, patch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, fresh) {
+ t.Fatal("patched data differs from the new data")
+ }
+}
+
+func TestPatchRefusesBadInput(t *testing.T) {
+ old := []byte("hello")
+ patch, err := bsdiff.Bytes(old, []byte("hello world"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tests := map[string]struct {
+ old, patch []byte
+ }{
+ "not a patch": {old, []byte("not a bsdiff patch at all")},
+ "truncated": {old, patch[:len(patch)/2]},
+ "truncated head": {old, patch[:20]},
+ }
+ for name, test := range tests {
+ if _, err := Patch(test.old, test.patch); err == nil {
+ t.Errorf("%s: patch accepted", name)
+ }
+ }
+}
+
+func TestPatchRefusesAHostileSize(t *testing.T) {
+ patch := make([]byte, 32)
+ copy(patch, "BSDIFF40")
+ // newSize is the third off_t: seven little-endian bytes and a sign.
+ patch[24+0] = 0xff
+ patch[24+1] = 0xff
+ patch[24+2] = 0xff
+ patch[24+3] = 0xff
+ patch[24+4] = 0x7f
+ if _, err := Patch(nil, patch); !errors.Is(err, ErrPatch) {
+ t.Fatalf("err = %v, want ErrPatch", err)
+ }
+}
diff --git a/internal/devreload/supervisor.go b/internal/devreload/supervisor.go
index 62f84930..c51460a2 100644
--- a/internal/devreload/supervisor.go
+++ b/internal/devreload/supervisor.go
@@ -38,8 +38,9 @@ func (value *supervisor) hostConfig() app.Config {
Title: value.window.Title, Width: value.window.Width, Height: value.window.Height,
MinWidth: value.window.MinWidth, MinHeight: value.window.MinHeight,
WindowMode: app.WindowMode(value.window.WindowMode), TitlebarHeight: float32(value.window.TitlebarHeight),
- ClearColor: app.Color{Red: value.window.ClearRed, Green: value.window.ClearGreen, Blue: value.window.ClearBlue, Alpha: value.window.ClearAlpha},
- Paint: value.paint, OnPointer: value.pointer,
+ TrafficLightScale: value.window.TrafficLightScale,
+ ClearColor: app.Color{Red: value.window.ClearRed, Green: value.window.ClearGreen, Blue: value.window.ClearBlue, Alpha: value.window.ClearAlpha},
+ Paint: value.paint, OnPointer: value.pointer,
OnKey: value.key, OnShortcut: value.shortcut, OnScroll: value.scroll,
OnCloseRequest: value.requestClose, Invalidator: &value.invalidator,
}
@@ -303,7 +304,7 @@ func (value *supervisor) clearErrorLocked() { value.lastError = "" }
func compatibleWindow(current, next reloadwire.WindowConfig) error {
if current.Title != next.Title || current.Width != next.Width || current.Height != next.Height ||
- current.WindowMode != next.WindowMode || current.TitlebarHeight != next.TitlebarHeight || current.ClearRed != next.ClearRed ||
+ current.WindowMode != next.WindowMode || current.TitlebarHeight != next.TitlebarHeight || current.TrafficLightScale != next.TrafficLightScale || current.ClearRed != next.ClearRed ||
current.ClearGreen != next.ClearGreen || current.ClearBlue != next.ClearBlue || current.ClearAlpha != next.ClearAlpha {
return fmt.Errorf("candidate changed immutable window configuration; restart viva dev")
}
diff --git a/internal/input/input.go b/internal/input/input.go
index e3995c90..4252c69f 100644
--- a/internal/input/input.go
+++ b/internal/input/input.go
@@ -177,6 +177,16 @@ func (router *Router) HitTest(x float32, y float32) (Target, bool) {
return router.regions[index].Target, true
}
+// HitTestRegion returns the front-most enabled region containing the point,
+// including the focus scope it was declared in.
+func (router *Router) HitTestRegion(x float32, y float32) (Region, bool) {
+ index, ok := router.hitTestIndex(x, y)
+ if !ok {
+ return Region{}, false
+ }
+ return router.regions[index], true
+}
+
// PressKind is what a press at a point does in a custom-titlebar window.
type PressKind uint8
diff --git a/internal/instance/claim_unix.go b/internal/instance/claim_unix.go
new file mode 100644
index 00000000..5b1b0bab
--- /dev/null
+++ b/internal/instance/claim_unix.go
@@ -0,0 +1,115 @@
+//go:build darwin || linux
+
+package instance
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+// maxSocketPath is below the 104 bytes a Unix socket path may have on macOS.
+const maxSocketPath = 100
+
+// claimDirectory is the directory a claim lives in. It is a variable so tests
+// can use a directory of their own; the program never changes it.
+var claimDirectory = defaultClaimDirectory
+
+// claimAddress is where the id's socket lives: in a directory only this user can
+// enter, short enough for the 104-byte limit of a Unix socket path.
+func claimAddress(id string) (string, error) {
+ dir, err := claimDirectory()
+ if err != nil {
+ return "", err
+ }
+ return socketPathIn(dir, id)
+}
+
+// defaultClaimDirectory is the per-user directory claims live in. It is derived
+// from the user, not from the environment: a copy started from a desktop and one
+// started from a terminal must find each other, and their TMPDIR and
+// XDG_RUNTIME_DIR can differ (an SSH session, a service or a shell profile can
+// leave the first unset or set it to another place). If the claim followed the
+// environment, both copies would bind a socket and both would believe they are
+// the one running copy, writing the same saved data from two processes.
+func defaultClaimDirectory() (string, error) {
+ if runtime.GOOS == "linux" {
+ // The session runtime directory, derived from the user rather than read
+ // from XDG_RUNTIME_DIR, so a launch without the variable agrees with the
+ // desktop's own.
+ session := fmt.Sprintf("/run/user/%d", os.Getuid())
+ if info, err := os.Stat(session); err == nil && info.IsDir() && ownedByUser(info) {
+ return filepath.Join(session, "viva"), nil
+ }
+ return filepath.Join("/tmp", fmt.Sprintf("viva-%d", os.Getuid())), nil
+ }
+ // ~/Library/Application Support on macOS. The cache directory can be purged
+ // under disk pressure, which would remove the socket of a running copy and
+ // let a second one claim the application.
+ base, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(base, "viva"), nil
+}
+
+// socketPathIn is claimAddress for a claim directory. The split lets a test
+// reach the length fallback without a machine whose home directory is that deep.
+func socketPathIn(dir, id string) (string, error) {
+ name := claimName(id) + ".sock"
+ if len(dir)+len(name)+1 > maxSocketPath {
+ // A very long home directory: /tmp is always short, and every copy
+ // derives the same fallback from the user, not from the environment.
+ dir = filepath.Join("/tmp", fmt.Sprintf("viva-%d", os.Getuid()))
+ }
+ socket := filepath.Join(dir, name)
+ if len(socket) > maxSocketPath {
+ return "", fmt.Errorf("instance: the claim path %s is %d bytes, over the %d-byte limit; the user's home directory is too deep", socket, len(socket), maxSocketPath)
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", err
+ }
+ info, err := os.Lstat(dir)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() {
+ return "", fmt.Errorf("instance: %s is not a directory", dir)
+ }
+ if info.Mode().Perm()&0o077 != 0 {
+ if err := os.Chmod(dir, 0o700); err != nil {
+ return "", fmt.Errorf("instance: %s is open to other users: %w", dir, err)
+ }
+ }
+ if !ownedByUser(info) {
+ return "", fmt.Errorf("instance: %s belongs to another user", dir)
+ }
+ return socket, nil
+}
+
+// listen takes the address. It fails while another copy's socket is there, which
+// is what makes the first process the one that runs.
+func listen(address string) (net.Listener, error) {
+ listener, err := net.Listen("unix", address)
+ if err != nil {
+ return nil, err
+ }
+ os.Chmod(address, 0o600)
+ return listener, nil
+}
+
+// send hands args to the copy listening on the socket. A socket that nobody
+// listens on is the stale file of a process that died, and the caller replaces
+// it after errNobody.
+func send(address string, args []string) error {
+ conn, err := net.DialTimeout("unix", address, ioDeadline)
+ if err != nil {
+ return fmt.Errorf("%w: %v", errNobody, err)
+ }
+ return sendArgs(conn, args)
+}
+
+// discard removes a socket nobody listens on.
+func discard(address string) { os.Remove(address) }
diff --git a/internal/instance/claim_windows.go b/internal/instance/claim_windows.go
new file mode 100644
index 00000000..fd7b70a2
--- /dev/null
+++ b/internal/instance/claim_windows.go
@@ -0,0 +1,60 @@
+//go:build windows
+
+package instance
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+
+ "github.com/Microsoft/go-winio"
+ "golang.org/x/sys/windows"
+)
+
+// pipePrefix is where the named pipes of the machine live.
+const pipePrefix = `\\.\pipe\`
+
+// claimAddress is the pipe the claim lives at. The pipe namespace is
+// machine-wide, not per user like the Unix claim directory, so the name carries
+// a digest of the user's SID: two users running the same application must not
+// find each other's pipe in the way. The pipe itself rejects remote clients and
+// admits only the user who made it, so the name is the other half of keeping
+// copies apart. Unlike a socket file, the pipe needs no directory, no permission
+// check and no clean-up: it exists exactly as long as its process does.
+func claimAddress(id string) (string, error) {
+ user, err := windows.GetCurrentProcessToken().GetTokenUser()
+ if err != nil {
+ return "", fmt.Errorf("instance: the user of this process: %w", err)
+ }
+ sum := sha256.Sum256([]byte(user.User.Sid.String()))
+ return pipePrefix + "viva-" + hex.EncodeToString(sum[:6]) + "-" + claimName(id), nil
+}
+
+// listen takes the pipe name. It fails while another copy's pipe has it, which
+// is what makes the first process the one that runs.
+func listen(address string) (net.Listener, error) {
+ return winio.ListenPipe(address, nil)
+}
+
+// send hands args to the copy listening on the pipe. A pipe that is not there
+// fails with ERROR_FILE_NOT_FOUND, which means no copy is running; any other
+// failure means a copy exists and could not be reached, so its claim is not
+// taken over.
+func send(address string, args []string) error {
+ timeout := ioDeadline
+ conn, err := winio.DialPipe(address, &timeout)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("%w: %v", errNobody, err)
+ }
+ return fmt.Errorf("instance: the running copy cannot be reached: %w", err)
+ }
+ return sendArgs(conn, args)
+}
+
+// discard has nothing to remove: a named pipe goes away with the process that
+// made it, so a claim left by a crash is not there for the next launch to find.
+func discard(string) {}
diff --git a/internal/instance/instance.go b/internal/instance/instance.go
index 242650f9..d8b29187 100644
--- a/internal/instance/instance.go
+++ b/internal/instance/instance.go
@@ -1,12 +1,21 @@
+//go:build darwin || windows || linux
+
// Package instance makes an application run once per user. The first process
-// listens on a local socket; a later one connects, hands over its command line
-// and exits. No file lock, polling or timer is involved: the first process
+// listens at the claim address; a later one connects, hands over its command
+// line and exits. No file lock, polling or timer is involved: the first process
// holds one blocked accept, and nothing else runs until a second process starts.
//
-// The socket lives in a per-user directory the platform keeps across launches,
-// never in the process's temporary directory: TMPDIR and XDG_RUNTIME_DIR are
-// set by whichever shell or session started the process, and two copies that
-// disagree on them would not meet.
+// The address belongs to the user, not to the environment: a copy started from
+// a desktop and one started from a terminal must find each other, and their
+// TMPDIR and XDG_RUNTIME_DIR can differ (an SSH session, a service or a shell
+// profile can leave the first unset or set it to another place). If the claim
+// followed the environment, both copies would listen and both would believe they
+// are the one running copy, writing the same saved data from two processes.
+//
+// The platform files say where the address is. macOS and Linux claim at a Unix
+// socket in a per-user directory. Windows claims at a per-user named pipe:
+// AF_UNIX exists only from Windows 10 1803 and Windows Server 2019, while the
+// named pipe is there on every Windows a Go program runs on.
package instance
import (
@@ -18,9 +27,6 @@ import (
"fmt"
"io"
"net"
- "os"
- "path/filepath"
- "runtime"
"strings"
"sync"
"time"
@@ -30,14 +36,12 @@ const (
maxMessage = 1 << 20
maxArgs = 4096
ioDeadline = 3 * time.Second
- // maxSocketPath is below the 104 bytes a Unix socket path may have on macOS.
- maxSocketPath = 100
)
// Instance is the first process's claim on the application.
type Instance struct {
listener net.Listener
- path string
+ address string
once sync.Once
done chan struct{}
}
@@ -50,141 +54,61 @@ func Claim(id string, args []string, handler func(args []string)) (instance *Ins
if id == "" {
return nil, false, errors.New("instance: an application id is required")
}
- path, err := socketPath(id)
+ address, err := claimAddress(id)
if err != nil {
return nil, false, err
}
+ var listenErr error
for attempt := 0; attempt < 3; attempt++ {
- listener, listenErr := net.Listen("unix", path)
- if listenErr == nil {
- os.Chmod(path, 0o600)
- instance = &Instance{listener: listener, path: path, done: make(chan struct{})}
+ listener, attemptErr := listen(address)
+ if attemptErr == nil {
+ instance = &Instance{listener: listener, address: address, done: make(chan struct{})}
go instance.serve(handler)
return instance, true, nil
}
- sendErr := send(path, args)
+ listenErr = attemptErr
+ sendErr := send(address, args)
if sendErr == nil {
return nil, false, nil
}
if !errors.Is(sendErr, errNobody) {
return nil, false, sendErr
}
- // Nobody answers: the socket is left from a process that died.
- os.Remove(path)
+ // Nobody answers: the address is left from a process that died.
+ discard(address)
}
- return nil, false, fmt.Errorf("instance: cannot claim %s: %w", path, errNobody)
+ return nil, false, claimFailure(address, listenErr)
+}
+
+// claimFailure is the error of a claim that could be neither handed to a running
+// copy nor made by this process. Both halves are named: the platform's own
+// reason for refusing the address is what a report needs, and leaving it out
+// made a platform without that address look like an application that was not
+// running.
+func claimFailure(address string, listenErr error) error {
+ return fmt.Errorf("instance: cannot claim %s: %w, and this process cannot listen there: %v", address, errNobody, listenErr)
}
-// Close stops listening and removes the socket. It is idempotent.
+// Close stops listening and removes the claim. It is idempotent.
func (instance *Instance) Close() {
if instance == nil {
return
}
instance.once.Do(func() {
instance.listener.Close()
- os.Remove(instance.path)
+ discard(instance.address)
<-instance.done
})
}
var errNobody = errors.New("instance: no process is listening")
-// claimDirectory is the directory a claim lives in. It is a variable so tests
-// can use a directory of their own; the program never changes it.
-var claimDirectory = defaultClaimDirectory
-
-// defaultClaimDirectory is the per-user directory claims live in. It is derived
-// from the user, not from the environment: a copy started from a desktop and one
-// started from a terminal must find each other, and their TMPDIR and
-// XDG_RUNTIME_DIR can differ (an SSH session, a service or a shell profile can
-// leave the first unset or set it to another place). If the claim followed the
-// environment, both copies would bind a socket and both would believe they are
-// the one running copy, writing the same saved data from two processes.
-func defaultClaimDirectory() (string, error) {
- switch runtime.GOOS {
- case "linux":
- // The session runtime directory, derived from the user rather than read
- // from XDG_RUNTIME_DIR, so a launch without the variable agrees with the
- // desktop's own.
- session := fmt.Sprintf("/run/user/%d", os.Getuid())
- if info, err := os.Stat(session); err == nil && info.IsDir() && ownedByUser(info) {
- return filepath.Join(session, "viva"), nil
- }
- return filepath.Join("/tmp", fmt.Sprintf("viva-%d", os.Getuid())), nil
- case "windows":
- // %LocalAppData%, which is the user's own and is not roamed between
- // computers; a socket must stay on this machine.
- base, err := os.UserCacheDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "viva"), nil
- default:
- // ~/Library/Application Support on macOS. The cache directory can be
- // purged under disk pressure, which would remove the socket of a
- // running copy and let a second one claim the application.
- base, err := os.UserConfigDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(base, "viva"), nil
- }
-}
-
-// socketPath is where the id's socket lives: in a directory only this user can
-// enter, short enough for the 104-byte limit of a Unix socket path.
-func socketPath(id string) (string, error) {
- dir, err := claimDirectory()
- if err != nil {
- return "", err
- }
- return socketPathIn(dir, id)
-}
-
-// socketPathIn is socketPath for a claim directory. The split lets a test reach
-// the length fallback without a machine whose home directory is that deep.
-func socketPathIn(dir, id string) (string, error) {
+// claimName is the part of the address that names the application. A digest
+// keeps the address inside the length limit of both a socket path and a pipe
+// name whatever the id is.
+func claimName(id string) string {
sum := sha256.Sum256([]byte(id))
- name := "i-" + hex.EncodeToString(sum[:6]) + ".sock"
- if len(dir)+len(name)+1 > maxSocketPath {
- if runtime.GOOS == "windows" {
- // Windows AF_UNIX paths share the sockaddr limit, and there is no
- // short system directory to fall back to; the 8.3 short form of the
- // existing parent usually fits. The claim directory itself need not
- // exist yet, so only its parent is shortened.
- if short, ok := shortPath(filepath.Dir(dir)); ok {
- dir = filepath.Join(short, filepath.Base(dir))
- }
- } else {
- // A very long home directory: /tmp is always short, and every copy
- // derives the same fallback from the user, not from the environment.
- dir = filepath.Join("/tmp", fmt.Sprintf("viva-%d", os.Getuid()))
- }
- }
- if socket := filepath.Join(dir, name); len(socket) > maxSocketPath {
- return "", fmt.Errorf("instance: the claim path %s is %d bytes, over the %d-byte limit; the user's home directory is too deep", socket, len(socket), maxSocketPath)
- }
- if err := os.MkdirAll(dir, 0o700); err != nil {
- return "", err
- }
- info, err := os.Lstat(dir)
- if err != nil {
- return "", err
- }
- if !info.IsDir() {
- return "", fmt.Errorf("instance: %s is not a directory", dir)
- }
- if runtime.GOOS != "windows" {
- if info.Mode().Perm()&0o077 != 0 {
- if err := os.Chmod(dir, 0o700); err != nil {
- return "", fmt.Errorf("instance: %s is open to other users: %w", dir, err)
- }
- }
- if !ownedByUser(info) {
- return "", fmt.Errorf("instance: %s belongs to another user", dir)
- }
- }
- return filepath.Join(dir, name), nil
+ return "i-" + hex.EncodeToString(sum[:6])
}
func (instance *Instance) serve(handler func([]string)) {
@@ -203,12 +127,9 @@ func (instance *Instance) serve(handler func([]string)) {
}
}
-// send hands args to the process listening on path.
-func send(path string, args []string) error {
- conn, err := net.DialTimeout("unix", path, ioDeadline)
- if err != nil {
- return fmt.Errorf("%w: %v", errNobody, err)
- }
+// sendArgs hands args to a connected running process and waits for its
+// acknowledgement. The connection is closed either way.
+func sendArgs(conn net.Conn, args []string) error {
defer conn.Close()
conn.SetDeadline(time.Now().Add(ioDeadline))
if err := writeArgs(conn, args); err != nil {
diff --git a/internal/instance/instance_test.go b/internal/instance/instance_test.go
index 6464e559..24be62c3 100644
--- a/internal/instance/instance_test.go
+++ b/internal/instance/instance_test.go
@@ -1,11 +1,10 @@
+//go:build darwin || windows || linux
+
package instance
import (
- "fmt"
- "os"
- "path/filepath"
+ "errors"
"reflect"
- "runtime"
"strings"
"testing"
"time"
@@ -55,13 +54,15 @@ func TestDifferentIDsDoNotMeetAndEmptyArgumentsWork(t *testing.T) {
}
}
-func TestAStaleSocketIsReplaced(t *testing.T) {
+func TestAClosedClaimIsReclaimed(t *testing.T) {
isolate(t)
first, primary, _ := Claim("dev.test.stale", nil, func([]string) {})
if !primary {
t.Fatal("not primary")
}
- // A process that died leaves its socket file: close the listener only.
+ // A process that died leaves its listener behind: close that only. On Windows
+ // the pipe goes with it and nothing is left; on Unix the socket file stays
+ // until the next claim replaces it.
first.listener.Close()
<-first.done
second, primary, err := Claim("dev.test.stale", nil, func([]string) {})
@@ -83,102 +84,30 @@ func TestOversizeAndMalformedMessagesAreRefused(t *testing.T) {
}
}
-// isolate gives a test its own claim directory.
-func isolate(t *testing.T) {
- t.Helper()
- dir, err := os.MkdirTemp("", "gi")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { os.RemoveAll(dir) })
- previous := claimDirectory
- claimDirectory = func() (string, error) { return dir, nil }
- t.Cleanup(func() { claimDirectory = previous })
-}
-
-// A copy started from a desktop and one started from a terminal can carry
-// different temporary directories. The claim must not move with the environment:
-// if it did, each copy would bind its own socket, both would become primary, and
-// both would hold the same saved data.
-func TestTheClaimDirectoryDoesNotFollowTheEnvironment(t *testing.T) {
- before, err := defaultClaimDirectory()
- if err != nil {
- t.Fatal(err)
- }
- if !filepath.IsAbs(before) {
- t.Fatalf("the claim directory %q is not absolute", before)
- }
- other := filepath.Join(t.TempDir(), strings.Repeat("deep-directory-", 6))
- t.Setenv("XDG_RUNTIME_DIR", other)
- t.Setenv("TMPDIR", other)
- t.Setenv("TEMP", other)
- t.Setenv("TMP", other)
- after, err := defaultClaimDirectory()
- if err != nil {
- t.Fatal(err)
- }
- if before != after {
- t.Fatalf("the claim directory moved from %q to %q with the environment", before, after)
- }
-}
-
-// A claim directory too deep for a socket path must still claim: the path falls
-// back to a short one instead of failing to bind, and both launches agree on it.
-func TestALongClaimDirectoryKeepsTheSocketPathShort(t *testing.T) {
- base := t.TempDir()
- deep := filepath.Join(base, strings.Repeat("deep-directory-", 6), strings.Repeat("x", 40))
- if err := os.MkdirAll(deep, 0o700); err != nil {
- t.Fatal(err)
- }
- if len(deep) <= maxSocketPath {
- t.Fatalf("the test needs a directory over %d bytes, got %d", maxSocketPath, len(deep))
- }
-
- path, err := socketPathIn(deep, "dev.test.deep")
- if err != nil {
- if runtime.GOOS == "windows" {
- // The fallback asks the volume for its 8.3 names, and a volume may
- // have them switched off; there is no portable shorter path to try.
- t.Skipf("the volume offers no short path: %v", err)
+// The claim failed twice over, and the platform's reason for the half this
+// process tried must be in the error: without it an address the system does not
+// have looked exactly like an application that was not running.
+func TestClaimFailureNamesTheListenerError(t *testing.T) {
+ cause := errors.New("socket: address family not supported")
+ err := claimFailure(`\\.\pipe\viva-test`, cause)
+ if !errors.Is(err, errNobody) {
+ t.Fatalf("the failure does not report that nobody listens: %v", err)
+ }
+ for _, want := range []string{"cannot claim", `\\.\pipe\viva-test`, cause.Error()} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("the failure %q does not name %q", err, want)
}
- t.Fatalf("a deep claim directory leaves no socket path: %v", err)
- }
- if len(path) > maxSocketPath {
- t.Fatalf("socket path %q is %d bytes, over the %d-byte limit", path, len(path), maxSocketPath)
}
- if runtime.GOOS != "windows" {
- short := filepath.Join("/tmp", fmt.Sprintf("viva-%d", os.Getuid()))
- if !strings.HasPrefix(path, short+string(os.PathSeparator)) {
- t.Fatalf("a deep directory did not fall back to %s: %q", short, path)
- }
- }
- if again, err := socketPathIn(deep, "dev.test.deep"); err != nil || again != path {
- t.Fatalf("the socket path is not stable: %q then %q (%v)", path, again, err)
- }
-
- // Claim goes through the same fallback: the directory the platform would
- // give is replaced by the deep one, so both launches below must still agree.
- previous := claimDirectory
- claimDirectory = func() (string, error) { return deep, nil }
- t.Cleanup(func() { claimDirectory = previous })
+}
- got := make(chan []string, 1)
- first, primary, err := Claim("dev.test.deep", nil, func(args []string) { got <- args })
- if err != nil || !primary {
- t.Fatalf("claim with a deep directory: primary %v, err %v", primary, err)
- }
- defer first.Close()
- if second, primary, err := Claim("dev.test.deep", []string{"handoff"}, func([]string) {
- t.Error("the second process must not serve")
- }); err != nil || primary || second != nil {
- t.Fatalf("second claim: primary %v, err %v", primary, err)
- }
- select {
- case args := <-got:
- if !reflect.DeepEqual(args, []string{"handoff"}) {
- t.Fatalf("handler got %q", args)
+func BenchmarkClaimAndClose(b *testing.B) {
+ isolate(b)
+ b.ReportAllocs()
+ for b.Loop() {
+ claim, primary, err := Claim("dev.test.bench", nil, func([]string) {})
+ if err != nil || !primary {
+ b.Fatalf("claim: primary %v, err %v", primary, err)
}
- case <-time.After(3 * time.Second):
- t.Fatal("the arguments never arrived")
+ claim.Close()
}
}
diff --git a/internal/instance/instance_unix_test.go b/internal/instance/instance_unix_test.go
new file mode 100644
index 00000000..21df3fc2
--- /dev/null
+++ b/internal/instance/instance_unix_test.go
@@ -0,0 +1,101 @@
+//go:build darwin || linux
+
+package instance
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// isolate gives a test its own claim directory.
+func isolate(t testing.TB) {
+ t.Helper()
+ dir := t.TempDir()
+ previous := claimDirectory
+ claimDirectory = func() (string, error) { return dir, nil }
+ t.Cleanup(func() { claimDirectory = previous })
+}
+
+// A copy started from a desktop and one started from a terminal can carry
+// different temporary directories. The claim must not move with the environment:
+// if it did, each copy would bind its own socket, both would become primary, and
+// both would hold the same saved data.
+func TestTheClaimDirectoryDoesNotFollowTheEnvironment(t *testing.T) {
+ before, err := defaultClaimDirectory()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !filepath.IsAbs(before) {
+ t.Fatalf("the claim directory %q is not absolute", before)
+ }
+ other := filepath.Join(t.TempDir(), strings.Repeat("deep-directory-", 6))
+ t.Setenv("XDG_RUNTIME_DIR", other)
+ t.Setenv("TMPDIR", other)
+ t.Setenv("TEMP", other)
+ t.Setenv("TMP", other)
+ after, err := defaultClaimDirectory()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before != after {
+ t.Fatalf("the claim directory moved from %q to %q with the environment", before, after)
+ }
+}
+
+// A claim directory too deep for a socket path must still claim: the path falls
+// back to a short one instead of failing to bind, and both launches agree on it.
+func TestALongClaimDirectoryKeepsTheSocketPathShort(t *testing.T) {
+ base := t.TempDir()
+ deep := filepath.Join(base, strings.Repeat("deep-directory-", 6), strings.Repeat("x", 40))
+ if err := os.MkdirAll(deep, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if len(deep) <= maxSocketPath {
+ t.Fatalf("the test needs a directory over %d bytes, got %d", maxSocketPath, len(deep))
+ }
+
+ path, err := socketPathIn(deep, "dev.test.deep")
+ if err != nil {
+ t.Fatalf("a deep claim directory leaves no socket path: %v", err)
+ }
+ if len(path) > maxSocketPath {
+ t.Fatalf("socket path %q is %d bytes, over the %d-byte limit", path, len(path), maxSocketPath)
+ }
+ short := filepath.Join("/tmp", fmt.Sprintf("viva-%d", os.Getuid()))
+ if !strings.HasPrefix(path, short+string(os.PathSeparator)) {
+ t.Fatalf("a deep directory did not fall back to %s: %q", short, path)
+ }
+ if again, err := socketPathIn(deep, "dev.test.deep"); err != nil || again != path {
+ t.Fatalf("the socket path is not stable: %q then %q (%v)", path, again, err)
+ }
+
+ // Claim goes through the same fallback: the directory the platform would
+ // give is replaced by the deep one, so both launches below must still agree.
+ previous := claimDirectory
+ claimDirectory = func() (string, error) { return deep, nil }
+ t.Cleanup(func() { claimDirectory = previous })
+
+ got := make(chan []string, 1)
+ first, primary, err := Claim("dev.test.deep", nil, func(args []string) { got <- args })
+ if err != nil || !primary {
+ t.Fatalf("claim with a deep directory: primary %v, err %v", primary, err)
+ }
+ defer first.Close()
+ if second, primary, err := Claim("dev.test.deep", []string{"handoff"}, func([]string) {
+ t.Error("the second process must not serve")
+ }); err != nil || primary || second != nil {
+ t.Fatalf("second claim: primary %v, err %v", primary, err)
+ }
+ select {
+ case args := <-got:
+ if len(args) != 1 || args[0] != "handoff" {
+ t.Fatalf("handler got %q", args)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("the arguments never arrived")
+ }
+}
diff --git a/internal/instance/instance_windows_test.go b/internal/instance/instance_windows_test.go
new file mode 100644
index 00000000..0d92d675
--- /dev/null
+++ b/internal/instance/instance_windows_test.go
@@ -0,0 +1,28 @@
+//go:build windows
+
+package instance
+
+import "testing"
+
+// isolate has nothing to hand out on Windows: a claim is a named pipe, not a
+// file in a directory, so the tests share the machine's pipe namespace. Each
+// test names an application of its own, which is what keeps them apart.
+func isolate(t testing.TB) { t.Helper() }
+
+// The address must be a pipe and must not move between calls: every copy of the
+// application computes it on its own and has to arrive at the same one.
+func TestTheClaimAddressIsAStablePipe(t *testing.T) {
+ address, err := claimAddress("dev.test.pipe")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(address) <= len(pipePrefix) || address[:len(pipePrefix)] != pipePrefix {
+ t.Fatalf("the claim address %q is not a named pipe", address)
+ }
+ if again, err := claimAddress("dev.test.pipe"); err != nil || again != address {
+ t.Fatalf("the claim address is not stable: %q then %q (%v)", address, again, err)
+ }
+ if other, _ := claimAddress("dev.test.other"); other == address {
+ t.Fatalf("two applications share the claim address %q", address)
+ }
+}
diff --git a/internal/instance/owner_unix.go b/internal/instance/owner_unix.go
index 44970fe0..4a1c268c 100644
--- a/internal/instance/owner_unix.go
+++ b/internal/instance/owner_unix.go
@@ -1,4 +1,4 @@
-//go:build !windows
+//go:build darwin || linux
package instance
diff --git a/internal/instance/owner_windows.go b/internal/instance/owner_windows.go
deleted file mode 100644
index d4712d89..00000000
--- a/internal/instance/owner_windows.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package instance
-
-import "os"
-
-// A Windows temporary directory belongs to the user already.
-func ownedByUser(os.FileInfo) bool { return true }
diff --git a/internal/instance/shortpath_other.go b/internal/instance/shortpath_other.go
deleted file mode 100644
index 6fcad3d4..00000000
--- a/internal/instance/shortpath_other.go
+++ /dev/null
@@ -1,6 +0,0 @@
-//go:build !windows
-
-package instance
-
-// shortPath is a Windows-only fallback; other systems report no short form.
-func shortPath(string) (string, bool) { return "", false }
diff --git a/internal/instance/shortpath_windows.go b/internal/instance/shortpath_windows.go
deleted file mode 100644
index 747629d4..00000000
--- a/internal/instance/shortpath_windows.go
+++ /dev/null
@@ -1,27 +0,0 @@
-//go:build windows
-
-package instance
-
-import "syscall"
-
-// shortPath returns the 8.3 short form of an existing absolute path. Volumes
-// with short names disabled, and paths already short, return ok false.
-func shortPath(path string) (string, bool) {
- ptr, err := syscall.UTF16PtrFromString(path)
- if err != nil {
- return "", false
- }
- size, err := syscall.GetShortPathName(ptr, nil, 0)
- if err != nil || size == 0 {
- return "", false
- }
- buf := make([]uint16, size)
- if n, err := syscall.GetShortPathName(ptr, &buf[0], size); err != nil || n == 0 {
- return "", false
- }
- short := syscall.UTF16ToString(buf)
- if short == "" || short == path {
- return "", false
- }
- return short, true
-}
diff --git a/internal/interaction/pointer.go b/internal/interaction/pointer.go
index 33642956..3c5687ca 100644
--- a/internal/interaction/pointer.go
+++ b/internal/interaction/pointer.go
@@ -8,6 +8,7 @@ import "github.com/vivaui/viva/gfx"
// target and the application did not consume it.
type Pointer struct {
hovered gfx.ID
+ hoveredRole gfx.Role
pressed gfx.ID
target gfx.ID
button gfx.PointerButton
@@ -17,9 +18,15 @@ type Pointer struct {
// SetHovered records the hit under the pointer, or zero when there is none.
func (p *Pointer) SetHovered(target gfx.ID) { p.hovered = target }
+// SetHoveredRole records the role of the hovered hit.
+func (p *Pointer) SetHoveredRole(role gfx.Role) { p.hoveredRole = role }
+
// Hovered returns the hit under the pointer.
func (p *Pointer) Hovered() gfx.ID { return p.hovered }
+// HoveredRole returns the role of the hovered hit.
+func (p *Pointer) HoveredRole() gfx.Role { return p.hoveredRole }
+
// Pressed returns the target of the current press, or zero.
func (p *Pointer) Pressed() gfx.ID { return p.pressed }
diff --git a/internal/platform/contract.go b/internal/platform/contract.go
index 7a63c16a..02ee5a0e 100644
--- a/internal/platform/contract.go
+++ b/internal/platform/contract.go
@@ -44,8 +44,9 @@ type Config struct {
WindowMode WindowMode
// TitlebarHeight is the height in logical pixels of the strip a custom
// titlebar leaves for the application. Zero means DefaultTitlebarHeight.
- TitlebarHeight float32
- ClearColor Color
+ TitlebarHeight float32
+ TrafficLightScale float32
+ ClearColor Color
WindowMaterials
Rectangles []render.Rectangle
TextLabels []TextLabel
@@ -188,6 +189,14 @@ type WindowMover interface {
MoveTo(origin gfx.Point)
}
+// WindowSizer is implemented by windows that can change their content size
+// without the user dragging a border. Sizes are logical pixels and the window
+// manager may clamp them to the display or the window's minimum size. It may be
+// called from any goroutine and posts the request to the native event loop.
+type WindowSizer interface {
+ SetSize(width, height int) error
+}
+
// Application creates windows that share one native application event loop.
// OpenWindow, AddStatusItem, SetDockBadge and SetProgress are called on that
// event-loop thread.
@@ -236,6 +245,10 @@ var ErrFullscreenUnsupported = errors.New("platform: fullscreen is unsupported")
// above others.
var ErrAlwaysOnTopUnsupported = errors.New("platform: always-on-top is unsupported")
+// ErrWindowSizeUnsupported means this window kind cannot be resized by the
+// application.
+var ErrWindowSizeUnsupported = errors.New("platform: programmatic window size is unsupported")
+
// ErrStatusAreaUnavailable is returned by AddStatusItem when the desktop has no
// status area to put an icon in, as a Linux session without a StatusNotifier
// host. The application keeps running; it only has no icon.
diff --git a/internal/platform/darwin/clipboard_html_darwin.go b/internal/platform/darwin/clipboard_html_darwin.go
new file mode 100644
index 00000000..77d19217
--- /dev/null
+++ b/internal/platform/darwin/clipboard_html_darwin.go
@@ -0,0 +1,69 @@
+//go:build darwin
+
+package darwin
+
+/*
+#include
+#include "system_darwin.h"
+*/
+import "C"
+
+import (
+ "fmt"
+ "unsafe"
+)
+
+// SetClipboardHTML puts html on the general pasteboard as public.html beside
+// alt as public.utf8-plain-text, the pair a rich copy leaves. The flavours are
+// the pasteboard's own types, so a reader takes whichever it knows.
+func SetClipboardHTML(html, alt string) error { return putPasteboardHTML(nil, html, alt) }
+
+func putPasteboardHTML(name *C.char, html, alt string) error {
+ nativeHTML, err := systemCString("clipboard html", html)
+ if err != nil {
+ return err
+ }
+ defer C.free(unsafe.Pointer(nativeHTML))
+ nativeAlt, err := systemCString("clipboard alt", alt)
+ if err != nil {
+ return err
+ }
+ defer C.free(unsafe.Pointer(nativeAlt))
+ if C.viva_system_pasteboard_put_html(name, nativeHTML, nativeAlt) != 0 {
+ if name == nil {
+ return fmt.Errorf("darwin: put html on the clipboard failed")
+ }
+ return fmt.Errorf("darwin: put html on pasteboard %q failed", C.GoString(name))
+ }
+ return nil
+}
+
+func putPrivatePasteboardHTML(name, html, alt string) error {
+ cName := C.CString(name)
+ defer C.free(unsafe.Pointer(cName))
+ return putPasteboardHTML(cName, html, alt)
+}
+
+func privatePasteboardHTML(name string) (string, error) {
+ cName := C.CString(name)
+ defer C.free(unsafe.Pointer(cName))
+ var value *C.char
+ var nativeError *C.char
+ if C.viva_system_pasteboard_html(cName, &value, &nativeError) != 0 {
+ return "", systemError("read pasteboard html", nativeError)
+ }
+ defer C.viva_system_free(unsafe.Pointer(value))
+ return C.GoString(value), nil
+}
+
+func privatePasteboardText(name string) (string, error) {
+ cName := C.CString(name)
+ defer C.free(unsafe.Pointer(cName))
+ var value *C.char
+ var nativeError *C.char
+ if C.viva_system_pasteboard_text(cName, &value, &nativeError) != 0 {
+ return "", systemError("read pasteboard text", nativeError)
+ }
+ defer C.viva_system_free(unsafe.Pointer(value))
+ return C.GoString(value), nil
+}
diff --git a/internal/platform/darwin/clipboard_html_darwin_test.go b/internal/platform/darwin/clipboard_html_darwin_test.go
new file mode 100644
index 00000000..5ea73e26
--- /dev/null
+++ b/internal/platform/darwin/clipboard_html_darwin_test.go
@@ -0,0 +1,38 @@
+//go:build darwin
+
+package darwin
+
+import (
+ "testing"
+)
+
+// TestPasteboardHTMLOffersBothFlavours writes the pair on a private pasteboard
+// and reads each flavour back the way an application that asks for it would:
+// an html reader takes public.html, a plain reader the text beside it.
+func TestPasteboardHTMLOffersBothFlavours(t *testing.T) {
+ name := privateName(t)
+ const html = `加粗 & text`
+ const alt = "加粗 & text"
+ if got, err := privatePasteboardHTML(name); err != nil || got != "" {
+ t.Fatalf("a fresh pasteboard: html = %q, %v", got, err)
+ }
+ if err := putPrivatePasteboardHTML(name, html, alt); err != nil {
+ t.Fatal(err)
+ }
+ got, err := privatePasteboardHTML(name)
+ if err != nil || got != html {
+ t.Fatalf("pasteboard html = %q, %v, want %q", got, err, html)
+ }
+ text, err := privatePasteboardText(name)
+ if err != nil || text != alt {
+ t.Fatalf("pasteboard text beside the html = %q, %v, want %q", text, err, alt)
+ }
+
+ // A copy of another kind replaces the pair, as every write clears the board.
+ if err := putPrivatePasteboardFiles(name, []string{"/tmp/report.txt"}); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := privatePasteboardHTML(name); err != nil || got != "" {
+ t.Fatalf("after files replaced the pair: html = %q, %v", got, err)
+ }
+}
diff --git a/internal/platform/darwin/native.h b/internal/platform/darwin/native.h
index f8709ba1..89787c10 100644
--- a/internal/platform/darwin/native.h
+++ b/internal/platform/darwin/native.h
@@ -334,6 +334,7 @@ typedef struct {
int dynamic_text_input;
int window_mode;
float titlebar_height;
+ float traffic_light_scale;
int close_intercept_enabled;
uintptr_t callback_handle;
// Where a panel opens: the screen rectangle of its status item, in the
@@ -390,6 +391,8 @@ void *viva_application_open_window(viva_window_config config);
int viva_application_window_frame(void *window, double *out);
/* Puts the window's content area at x, y in top-left screen coordinates. */
void viva_application_move_window(void *window, double x, double y);
+/* Posts a content resize to the AppKit event loop. */
+void viva_application_set_window_size(void *window, double width, double height);
int viva_application_set_fullscreen(void *window, int fullscreen);
int viva_application_set_title(void *window, const char *title);
int viva_application_fullscreen(void *window);
@@ -554,8 +557,9 @@ int viva_text_line_rasterize(
);
// viva_measure_mono reports the system fixed-pitch font's cell at a size: the advance of
-// one character and the ascent, descent and leading of its line box.
-int viva_measure_mono(double font_size, int mono_face, double *advance, double *ascent, double *descent, double *leading);
+// one character, the ascent, descent and leading of its line box, and the thickness of
+// the strokes it draws its light and heavy box drawing lines with.
+int viva_measure_mono(double font_size, int mono_face, double *advance, double *ascent, double *descent, double *leading, double *rule, double *heavy);
// viva_register_mono_family teaches what a mono_face number (2 and up) stands for: the
// installed family it draws in first (empty for a stack over a built-in face), whether
// that built-in face is the classic one, and the families that draw the characters the
diff --git a/internal/platform/darwin/native.m b/internal/platform/darwin/native.m
index 6adadde1..ef951622 100644
--- a/internal/platform/darwin/native.m
+++ b/internal/platform/darwin/native.m
@@ -138,18 +138,20 @@ static CGFloat viva_traffic_light_center(NSWindow *window) {
* inside it and clickable, and each button is set that far below the top.
* Idempotent. AppKit puts the buttons back at the system's height on every
* layout, so callers place them again whenever a button or the container
- * reports a frame change (see keepTrafficLightsCentered). A strip no taller
- * than the system's, and a full screen window, keep the system's layout. */
-static void viva_place_traffic_lights(NSWindow *window, CGFloat stripHeight) {
- if (window == nil || stripHeight <= VIVA_DEFAULT_TITLEBAR_HEIGHT ||
- (window.styleMask & NSWindowStyleMaskFullScreen) != 0) {
+ * reports a frame change (see keepTrafficLightsCentered). A scale in (0, 1)
+ * shrinks the buttons around their original centers whether or not the strip
+ * is taller; a strip no taller than the system's keeps the system's positions,
+ * and a full screen window keeps the system's layout. */
+static void viva_place_traffic_lights(NSWindow *window, CGFloat stripHeight, CGFloat scale) {
+ if (window == nil || (window.styleMask & NSWindowStyleMaskFullScreen) != 0) {
return;
}
+ BOOL taller = stripHeight > VIVA_DEFAULT_TITLEBAR_HEIGHT;
NSView *container = viva_titlebar_container(window);
if (container == nil || container.superview == nil) {
return;
}
- if (fabs(NSHeight(container.frame) - stripHeight) > 0.25) {
+ if (taller && fabs(NSHeight(container.frame) - stripHeight) > 0.25) {
NSRect frame = container.frame;
frame.size.height = stripHeight;
frame.origin.y = NSHeight(container.superview.bounds) - stripHeight;
@@ -160,6 +162,26 @@ static void viva_place_traffic_lights(NSWindow *window, CGFloat stripHeight) {
if (button == nil || button.superview == nil) {
continue;
}
+ // Keep the original drawing coordinates while shrinking the view frame.
+ // Using bounds makes repeated AppKit layout notifications idempotent.
+ if (isfinite(scale) && scale > 0 && scale < 1) {
+ NSSize originalSize = button.bounds.size;
+ NSSize targetSize = NSMakeSize(originalSize.width * scale, originalSize.height * scale);
+ if (fabs(NSWidth(button.frame) - targetSize.width) > 0.01 ||
+ fabs(NSHeight(button.frame) - targetSize.height) > 0.01) {
+ NSRect frame = button.frame;
+ frame.origin.x += (frame.size.width - targetSize.width) / 2.0;
+ // Keep the vertical center too, so the shrink holds in a strip of the
+ // system's height where the placement below does not run.
+ frame.origin.y += (frame.size.height - targetSize.height) / 2.0;
+ frame.size = targetSize;
+ button.frame = frame;
+ button.bounds = NSMakeRect(0, 0, originalSize.width, originalSize.height);
+ }
+ }
+ if (!taller) {
+ continue;
+ }
CGFloat y = NSHeight(button.superview.bounds) - stripHeight / 2.0 - NSHeight(button.frame) / 2.0;
if (fabs(button.frame.origin.y - y) > 0.25) {
NSPoint origin = button.frame.origin;
@@ -6094,6 +6116,8 @@ @interface VivaAppDelegate : NSObject
BOOL _scrollEventsEnabled;
int _windowMode;
CGFloat _titlebarHeight;
+ CGFloat _trafficLightScale;
+ BOOL _placingTrafficLights;
NSMutableArray *_titlebarWatched; /* views whose frame changes re-center the traffic lights */
BOOL _fullscreenTarget;
BOOL _fullscreenTransition;
@@ -6108,6 +6132,7 @@ @interface VivaAppDelegate : NSObject
}
- (void)updateMinimumSize;
+- (void)setVivaContentSize:(NSSize)size;
- (instancetype)initWithConfig:(viva_window_config)config
device:(id)device
@@ -6206,6 +6231,7 @@ - (instancetype)initWithConfig:(viva_window_config)config
_scrollEventsEnabled = scrollEventsEnabled;
_windowMode = windowMode;
_titlebarHeight = config.titlebar_height > 0 ? config.titlebar_height : VIVA_DEFAULT_TITLEBAR_HEIGHT;
+ _trafficLightScale = config.traffic_light_scale;
_closeInterceptEnabled = closeInterceptEnabled;
_callbackHandle = callbackHandle;
_hasAnchor = config.has_anchor != 0;
@@ -6554,8 +6580,22 @@ - (void)windowWillClose:(NSNotification *)notification {
* or the container, so the placement is redone in that very pass and the
* buttons are never drawn at the system's height. Entering and leaving full
* screen replaces the container, so the watched views are collected again. */
+// Frame notifications may fire synchronously between setting frame and bounds.
+// Suppress reentry until the scaled drawing coordinates are fully restored.
+- (void)placeTrafficLights {
+ if (_placingTrafficLights) {
+ return;
+ }
+ _placingTrafficLights = YES;
+ @try {
+ viva_place_traffic_lights(_window, _titlebarHeight, _trafficLightScale);
+ } @finally {
+ _placingTrafficLights = NO;
+ }
+}
+
- (void)keepTrafficLightsCentered {
- viva_place_traffic_lights(_window, _titlebarHeight);
+ [self placeTrafficLights];
if (_titlebarWatched == nil) {
_titlebarWatched = [[NSMutableArray alloc] init];
}
@@ -6582,7 +6622,7 @@ - (void)keepTrafficLightsCentered {
- (void)titlebarChanged:(NSNotification *)notification {
(void)notification;
- viva_place_traffic_lights(_window, _titlebarHeight);
+ [self placeTrafficLights];
}
/* AppKit reports some of the buttons' moves a turn of the run loop late, so the
@@ -6590,7 +6630,7 @@ - (void)titlebarChanged:(NSNotification *)notification {
- (void)windowDidResize:(NSNotification *)notification {
(void)notification;
if (_windowMode == VIVA_WINDOW_CUSTOM_TITLEBAR) {
- viva_place_traffic_lights(_window, _titlebarHeight);
+ [self placeTrafficLights];
}
[self reportFrameChange];
}
@@ -6688,7 +6728,7 @@ - (void)setWindowTitle:(NSString *)title {
* button back at the system's height without reporting the move as a
* frame change, so keepTrafficLightsCentered never hears of it. */
if (_windowMode == VIVA_WINDOW_CUSTOM_TITLEBAR) {
- viva_place_traffic_lights(_window, _titlebarHeight);
+ viva_place_traffic_lights(_window, _titlebarHeight, _trafficLightScale);
}
}
}
@@ -6869,6 +6909,32 @@ - (void)moveContentTo:(NSPoint)origin {
[_window setFrame:[_window frameRectForContentRect:content] display:NO];
}
+/* Changes the content area while keeping its current top-left origin. */
+- (void)setVivaContentSize:(NSSize)size {
+ if (_window == nil || !_running || _window.miniaturized || _window.zoomed ||
+ (_window.styleMask & NSWindowStyleMaskFullScreen) != 0) {
+ return;
+ }
+ NSRect current = [_window contentRectForFrameRect:_window.frame];
+ NSSize minimum = _window.contentMinSize;
+ size.width = MAX(size.width, minimum.width);
+ size.height = MAX(size.height, minimum.height);
+ NSScreen *screen = _window.screen ?: NSScreen.mainScreen ?: NSScreen.screens.firstObject;
+ if (screen != nil) {
+ NSRect available = [_window contentRectForFrameRect:screen.visibleFrame];
+ size.width = MIN(size.width, available.size.width);
+ size.height = MIN(size.height, available.size.height);
+ }
+ NSRect wanted = NSMakeRect(current.origin.x, NSMaxY(current) - size.height, size.width, size.height);
+ NSRect frame = [_window frameRectForContentRect:wanted];
+ if (screen != nil) {
+ NSRect work = screen.visibleFrame;
+ frame.origin.x = MAX(NSMinX(work), MIN(frame.origin.x, NSMaxX(work) - frame.size.width));
+ frame.origin.y = MAX(NSMinY(work), MIN(frame.origin.y, NSMaxY(work) - frame.size.height));
+ }
+ [_window setFrame:frame display:YES];
+}
+
/* The content area in top-left screen coordinates; NO before the window exists. */
- (BOOL)contentFrame:(double *)out {
if (_window == nil || !_running) {
@@ -7410,6 +7476,13 @@ void viva_application_move_window(void *window, double x, double y) {
}
}
+void viva_application_set_window_size(void *window, double width, double height) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ VivaAppDelegate *delegate = viva_find_window(window);
+ [delegate setVivaContentSize:NSMakeSize(width, height)];
+ });
+}
+
void viva_application_hide_window(void *window) {
viva_post_window_event(VIVA_HIDE_EVENT, window);
}
@@ -7766,8 +7839,8 @@ int viva_measure_text(
}
}
-int viva_measure_mono(double font_size, int mono_face, double *advance, double *ascent, double *descent, double *leading) {
- if (advance == NULL || ascent == NULL || descent == NULL || leading == NULL || font_size <= 0) {
+int viva_measure_mono(double font_size, int mono_face, double *advance, double *ascent, double *descent, double *leading, double *rule, double *heavy) {
+ if (advance == NULL || ascent == NULL || descent == NULL || leading == NULL || rule == NULL || heavy == NULL || font_size <= 0) {
return 1;
}
@autoreleasepool {
@@ -7789,6 +7862,23 @@ int viva_measure_mono(double font_size, int mono_face, double *advance, double *
*ascent = CTFontGetAscent(font);
*descent = CTFontGetDescent(font);
*leading = CTFontGetLeading(font);
+ // The weight of the face's own box drawing lines: the ink width of a light and a
+ // heavy stem, which the face may draw at a different weight than its underline.
+ // The lookup answers false when either is missing, and still fills the other, so
+ // each glyph is checked on its own.
+ *rule = 0;
+ *heavy = 0;
+ UniChar boxCharacters[2] = {0x2502, 0x2503}; /* │ ┃ */
+ CGGlyph boxGlyphs[2] = {0, 0};
+ CTFontGetGlyphsForCharacters(font, boxCharacters, boxGlyphs, 2);
+ CGRect bounds[2] = {CGRectZero, CGRectZero};
+ CTFontGetBoundingRectsForGlyphs(font, kCTFontOrientationHorizontal, boxGlyphs, bounds, 2);
+ if (boxGlyphs[0] != 0) {
+ *rule = bounds[0].size.width;
+ }
+ if (boxGlyphs[1] != 0) {
+ *heavy = bounds[1].size.width;
+ }
CFRelease(font);
return *advance > 0 ? 0 : 4;
}
diff --git a/internal/platform/darwin/platform.go b/internal/platform/darwin/platform.go
index 9c7b5be4..39377779 100644
--- a/internal/platform/darwin/platform.go
+++ b/internal/platform/darwin/platform.go
@@ -456,6 +456,7 @@ func (application *nativeApplication) OpenWindow(config Config) (platform.Window
dynamic_text_input: C.int(boolInt(config.DynamicTextInput)),
window_mode: C.int(int(config.WindowMode)),
titlebar_height: C.float(config.TitlebarStrip()),
+ traffic_light_scale: C.float(config.TrafficLightScale),
close_intercept_enabled: C.int(boolInt(config.OnCloseRequest != nil)),
callback_handle: C.uintptr_t(state.callbackHandle),
has_anchor: C.int(boolInt(config.HasAnchor)),
diff --git a/internal/platform/darwin/system_darwin.go b/internal/platform/darwin/system_darwin.go
index b769c689..3f04e1b6 100644
--- a/internal/platform/darwin/system_darwin.go
+++ b/internal/platform/darwin/system_darwin.go
@@ -201,7 +201,7 @@ func vivaSystemAppearanceChanged() {
// lastCursorShape is the highest shape number the native side knows. A number
// beyond it is refused here, without a trip to the main thread, which the native
// side would also refuse with the same words.
-const lastCursorShape = 10
+const lastCursorShape = 11
func SetCursorShape(shape uint8) error {
if shape > lastCursorShape {
diff --git a/internal/platform/darwin/system_darwin.h b/internal/platform/darwin/system_darwin.h
index 301d86d4..b55e5755 100644
--- a/internal/platform/darwin/system_darwin.h
+++ b/internal/platform/darwin/system_darwin.h
@@ -26,6 +26,14 @@ int viva_system_clipboard_files(const char *pasteboard, size_t max, viva_system_
// Puts file URLs on a named pasteboard, as Finder does after a copy. names is
// the text a file manager also leaves there (the file names, one per line).
int viva_system_pasteboard_put_files(const char *pasteboard, const char *const *paths, size_t count, const char *names);
+// Writes html as public.html beside alt as public.utf8-plain-text on a named
+// pasteboard; NULL is the general one (main thread).
+int viva_system_pasteboard_put_html(const char *pasteboard, const char *html, const char *alt);
+// Reads a named pasteboard's public.html as UTF-8 into *value (free with
+// viva_system_free); "" when it holds none.
+int viva_system_pasteboard_html(const char *pasteboard, char **value, char **error_message);
+// Reads a named pasteboard's plain text the same way.
+int viva_system_pasteboard_text(const char *pasteboard, char **value, char **error_message);
// Shows a modal alert. It must run on the main thread; elsewhere it returns -1.
int viva_system_show_fatal_error(const char *title, const char *message);
int viva_system_set_cursor(uint8_t shape, char **error_message);
diff --git a/internal/platform/darwin/system_darwin.m b/internal/platform/darwin/system_darwin.m
index a4410480..04a68567 100644
--- a/internal/platform/darwin/system_darwin.m
+++ b/internal/platform/darwin/system_darwin.m
@@ -208,6 +208,147 @@ int viva_system_set_clipboard_text(const char *text, char **error_message) {
return [NSPasteboard pasteboardWithName:viva_string(name)];
}
+// viva_system_pasteboard_put_html writes html as public.html beside alt as
+// public.utf8-plain-text, the pair a rich copy leaves: a reader takes whichever
+// flavour it knows, and a plain reader still pastes.
+int viva_system_pasteboard_put_html(const char *pasteboard, const char *html, const char *alt) {
+ __block char *failure = NULL;
+ dispatch_block_t operation = ^{
+ @autoreleasepool {
+ NSString *rich = viva_string(html);
+ NSString *plain = viva_string(alt);
+ if (rich == nil || plain == nil) {
+ failure = viva_copy_utf8(@"clipboard html is not valid UTF-8");
+ return;
+ }
+ NSPasteboard *board = viva_pasteboard_named(pasteboard);
+ NSData *data = [rich dataUsingEncoding:NSUTF8StringEncoding];
+ [board clearContents];
+ if (![board setData:data forType:NSPasteboardTypeHTML]) {
+ failure = viva_copy_utf8(@"AppKit refused clipboard html");
+ return;
+ }
+ [board setString:plain forType:NSPasteboardTypeString];
+ }
+ };
+ if (pasteboard == NULL) {
+ viva_on_main_sync(operation);
+ } else {
+ operation();
+ }
+ if (failure != NULL) {
+ free(failure);
+ return -1;
+ }
+ return 0;
+}
+
+// viva_system_pasteboard_html reads a named pasteboard's public.html as UTF-8,
+// "" when it holds none. Status: 0 html in *value, -1 failure with *error_message.
+int viva_system_pasteboard_html(const char *pasteboard, char **value, char **error_message) {
+ if (value == NULL) {
+ viva_set_error(error_message, @"clipboard output is missing");
+ return -1;
+ }
+ *value = NULL;
+ __block int status = 0;
+ __block char *result = NULL;
+ __block char *failure = NULL;
+ dispatch_block_t operation = ^{
+ @autoreleasepool {
+ NSPasteboard *board = viva_pasteboard_named(pasteboard);
+ NSData *data = [board dataForType:NSPasteboardTypeHTML];
+ if (data == nil) {
+ return;
+ }
+ if (data.length > viva_max_clipboard_bytes) {
+ failure = viva_copy_utf8(@"clipboard html exceeds 64 MiB");
+ status = -1;
+ return;
+ }
+ NSString *html = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+ if (html == nil) {
+ failure = viva_copy_utf8(@"clipboard html is not valid UTF-8");
+ status = -1;
+ return;
+ }
+ result = viva_copy_utf8(html);
+ if (result == NULL) {
+ failure = viva_copy_utf8(@"failed to allocate clipboard html");
+ status = -1;
+ }
+ }
+ };
+ if (pasteboard == NULL) {
+ viva_on_main_sync(operation);
+ } else {
+ operation();
+ }
+ if (status != 0) {
+ if (error_message != NULL) {
+ *error_message = failure;
+ } else {
+ free(failure);
+ }
+ return status;
+ }
+ if (result == NULL) {
+ result = strdup("");
+ }
+ *value = result;
+ return 0;
+}
+
+// viva_system_pasteboard_text is viva_system_clipboard_text for a named
+// pasteboard, the plain flavour of the pair put_html writes.
+int viva_system_pasteboard_text(const char *pasteboard, char **value, char **error_message) {
+ if (value == NULL) {
+ viva_set_error(error_message, @"clipboard output is missing");
+ return -1;
+ }
+ *value = NULL;
+ __block int status = 0;
+ __block char *result = NULL;
+ __block char *failure = NULL;
+ dispatch_block_t operation = ^{
+ @autoreleasepool {
+ NSPasteboard *board = viva_pasteboard_named(pasteboard);
+ NSString *text = [board stringForType:NSPasteboardTypeString];
+ if (text == nil) {
+ return;
+ }
+ if ([text lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > viva_max_clipboard_bytes) {
+ failure = viva_copy_utf8(@"clipboard text exceeds 64 MiB");
+ status = -1;
+ return;
+ }
+ result = viva_copy_utf8(text);
+ if (result == NULL) {
+ failure = viva_copy_utf8(@"failed to allocate clipboard text");
+ status = -1;
+ }
+ }
+ };
+ if (pasteboard == NULL) {
+ viva_on_main_sync(operation);
+ } else {
+ operation();
+ }
+ if (status != 0) {
+ if (error_message != NULL) {
+ *error_message = failure;
+ } else {
+ free(failure);
+ }
+ return status;
+ }
+ if (result == NULL) {
+ result = strdup("");
+ }
+ *value = result;
+ return 0;
+}
+
static NSArray *viva_image_types(void) {
return @[NSPasteboardTypePNG, NSPasteboardTypeTIFF];
}
@@ -455,7 +596,9 @@ int viva_system_pasteboard_put_files(const char *pasteboard, const char *const *
return nil;
case 9:
return NSCursor.operationNotAllowedCursor;
- case 10: {
+ case 10:
+ return NSCursor.closedHandCursor;
+ case 11: {
// A fully transparent cursor: a surface that draws its own pointer
// (a remote desktop picture) uses it to keep the system pointer
// out of the way. Its masks touch no pixel of the screen.
diff --git a/internal/platform/darwin/testdata/traffic_lights.m b/internal/platform/darwin/testdata/traffic_lights.m
index 0c6d5a4e..c16ff694 100644
--- a/internal/platform/darwin/testdata/traffic_lights.m
+++ b/internal/platform/darwin/testdata/traffic_lights.m
@@ -88,22 +88,50 @@ int main(void) {
NSWindow *window = customTitlebarWindow(800, 600);
CGFloat system = viva_traffic_light_center(window);
assert(system > 0);
- viva_place_traffic_lights(window, VIVA_DEFAULT_TITLEBAR_HEIGHT);
+ viva_place_traffic_lights(window, VIVA_DEFAULT_TITLEBAR_HEIGHT, 0);
assertCenter(window, system, "in a strip of the system's height");
- viva_place_traffic_lights(window, 20);
+ viva_place_traffic_lights(window, 20, 0);
assertCenter(window, system, "in a strip shorter than the system's");
// A taller strip centers them in it, and placing again changes nothing.
- viva_place_traffic_lights(window, 36);
+ viva_place_traffic_lights(window, 36, 0);
assertCenter(window, 18, "in a 36 point strip");
assertAligned(window, "in a 36 point strip");
assert(fabs(NSHeight(viva_titlebar_container(window).frame) - 36) < 0.01);
NSRect container = viva_titlebar_container(window).frame;
- viva_place_traffic_lights(window, 36);
+ viva_place_traffic_lights(window, 36, 0);
assert(NSEqualRects(container, viva_titlebar_container(window).frame));
- viva_place_traffic_lights(window, 44);
+ viva_place_traffic_lights(window, 44, 0);
assertCenter(window, 22, "in a 44 point strip");
+ // Shrinking preserves centers and drawing bounds, and never compounds.
+ NSButton *close = [window standardWindowButton:NSWindowCloseButton];
+ NSSize originalSize = close.bounds.size;
+ CGFloat originalCenterX = NSMidX(close.frame);
+ const CGFloat compactScale = 0.9;
+ viva_place_traffic_lights(window, 44, compactScale);
+ assert(fabs(NSWidth(close.frame) - originalSize.width * compactScale) < 0.01);
+ assert(fabs(NSMidX(close.frame) - originalCenterX) < 0.01);
+ assert(NSEqualSizes(close.bounds.size, originalSize));
+ viva_place_traffic_lights(window, 44, compactScale);
+ assert(fabs(NSWidth(close.frame) - originalSize.width * compactScale) < 0.01);
+ assertCenter(window, 22, "after shrinking the native buttons");
+
+ // A strip of the system's height needs no placement, but the scale
+ // still shrinks the buttons around their original centers.
+ window = customTitlebarWindow(800, 600);
+ NSButton *systemClose = [window standardWindowButton:NSWindowCloseButton];
+ NSSize systemSize = systemClose.bounds.size;
+ CGFloat systemCenterX = NSMidX(systemClose.frame);
+ CGFloat systemCenterY = NSMidY(systemClose.frame);
+ viva_place_traffic_lights(window, VIVA_DEFAULT_TITLEBAR_HEIGHT, compactScale);
+ assert(fabs(NSWidth(systemClose.frame) - systemSize.width * compactScale) < 0.01);
+ assert(fabs(NSMidX(systemClose.frame) - systemCenterX) < 0.01);
+ assert(fabs(NSMidY(systemClose.frame) - systemCenterY) < 0.01);
+ assert(NSEqualSizes(systemClose.bounds.size, systemSize));
+ viva_place_traffic_lights(window, VIVA_DEFAULT_TITLEBAR_HEIGHT, compactScale);
+ assert(fabs(NSWidth(systemClose.frame) - systemSize.width * compactScale) < 0.01);
+
// AppKit puts the buttons back whenever it lays the titlebar out, a
// resize being one; the delegate puts them back in the same pass.
window = customTitlebarWindow(800, 600);
@@ -114,6 +142,14 @@ int main(void) {
window.delegate = delegate;
[delegate performSelector:@selector(keepTrafficLightsCentered)];
assertCenter(window, 18, "once the delegate keeps them centered");
+ // Changing scale after observers are installed must not reenter while
+ // frame and drawing bounds are temporarily out of sync.
+ [delegate setValue:[NSNumber numberWithDouble:compactScale] forKey:@"_trafficLightScale"];
+ [delegate performSelector:@selector(keepTrafficLightsCentered)];
+ assertCenter(window, 18, "after scaling with active frame observers");
+ NSButton *scaledClose = [window standardWindowButton:NSWindowCloseButton];
+ assert(fabs(NSWidth(scaledClose.frame) / NSWidth(scaledClose.bounds) - compactScale) < 0.01);
+
[window setFrame:NSMakeRect(0, 85, 1000, 700) display:NO];
assertCenter(window, 18, "after the window grew");
assertAligned(window, "after the window grew");
@@ -129,6 +165,7 @@ int main(void) {
assertAligned(window, "after the title changed");
[delegate setWindowTitle:@"project - file"];
assertAligned(window, "after the title changed again");
+ assert(fabs(NSWidth(scaledClose.frame) / NSWidth(scaledClose.bounds) - compactScale) < 0.01);
[delegate setAlwaysOnTop:YES];
assert(window.level == NSFloatingWindowLevel);
[delegate setAlwaysOnTop:NO];
@@ -136,7 +173,7 @@ int main(void) {
[delegate release];
// A window with no traffic lights to move is left alone.
- viva_place_traffic_lights(nil, 36);
+ viva_place_traffic_lights(nil, 36, 0);
printf("traffic lights: ok (system center %.1f, strip 36 -> 18, strip 44 -> 22)\n", system);
}
return 0;
diff --git a/internal/platform/darwin/testdata/window_size.m b/internal/platform/darwin/testdata/window_size.m
new file mode 100644
index 00000000..9c9c1fac
--- /dev/null
+++ b/internal/platform/darwin/testdata/window_size.m
@@ -0,0 +1,92 @@
+#include "../native.m"
+#include "../main_menu_darwin.m"
+#include
+#include
+
+// The callbacks native.m reports to Go; this test listens to none of them.
+void vivaPointerEvent(uintptr_t handle, void *view, int kind, int button, int modifiers, int clickCount, double x, double y, double screenX, double screenY) {}
+void vivaViewResized(uintptr_t handle, void *view, double width, double height) {}
+void vivaRedrawRequested(uintptr_t handle, void *view) {}
+void vivaKeyEvent(uintptr_t handle, void *view, int key, int modifiers, int repeat, const unsigned char *utf8, size_t utf8Length) {}
+void vivaRawKeyEvent(uintptr_t handle, void *view, int key, int modifiers, int flags, unsigned int code, const unsigned char *utf8, size_t utf8Length, const unsigned char *typed, size_t typedLength) {}
+void vivaWindowActivated(uintptr_t handle) {}
+void vivaFocusLost(uintptr_t handle, void *view) {}
+int vivaTerminalCells(uint32_t character) { return 1; }
+int vivaAccessibilityAction(uintptr_t handle, void *view, uint64_t node, int action, double number, const unsigned char *utf8, size_t utf8Length, int start, int end) { return 0; }
+viva_text_line *vivaShapedLine(const unsigned char *utf8, size_t length, double size, double weight) { return viva_text_line_create(utf8, length, size, weight); }
+viva_text_line *vivaShapedLineStyle(const unsigned char *utf8, size_t length, double size, double weight, int italic) { return viva_text_line_create_style(utf8, length, size, weight, italic); }
+int vivaShortcutEvent(uintptr_t handle, void *view, int key, int modifiers, int repeat, const unsigned char *utf8, size_t utf8Length) { return 0; }
+void vivaOpenURL(const char *url) {}
+void vivaOpenFile(const char *path) {}
+int vivaApplicationQuitRequested(void) { return 0; }
+int vivaApplicationReopenRequested(void) { return 0; }
+int vivaFileDropEvent(uintptr_t handle, void *view, int phase, double x, double y, const char *paths, size_t length) { return 0; }
+void vivaScrollEvent(uintptr_t handle, void *view, double deltaX, double deltaY, double x, double y, int modifiers, int precise) {}
+void vivaCloseRequested(uintptr_t handle, void *view) {}
+void vivaWindowClosed(uintptr_t handle) {}
+void vivaFrameChanged(uintptr_t handle, void *view, double x, double y, double width, double height) {}
+void vivaWindowMinimized(uintptr_t handle, void *view, int minimized) {}
+void vivaWindowOccluded(uintptr_t handle, void *view, int visible) {}
+void vivaWindowFullscreen(uintptr_t handle, void *view, int fullscreen) {}
+int vivaWindowReopenRequested(uintptr_t handle, void *view) { return 0; }
+int vivaPressAt(uintptr_t handle, double x, double y) { return 0; }
+void vivaTextInput(uintptr_t handle, void *view, int kind, const unsigned char *utf8,
+ size_t length, int64_t start, int64_t selectedLength,
+ int64_t replacementStart, int64_t replacementLength, int command) {}
+unsigned char *vivaCopyText(uintptr_t handle, void *view, size_t *length) { *length = 0; return NULL; }
+
+static BOOL near(CGFloat a, CGFloat b) { return fabs(a - b) < 0.5; }
+
+// A titled window that is never ordered front, so nothing appears on screen.
+static NSWindow *titledWindow(CGFloat width, CGFloat height) {
+ NSWindowStyleMask style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable |
+ NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable;
+ return [[NSWindow alloc] initWithContentRect:NSMakeRect(200, 200, width, height)
+ styleMask:style
+ backing:NSBackingStoreBuffered
+ defer:NO];
+}
+
+int main(void) {
+ @autoreleasepool {
+ [NSApplication sharedApplication];
+
+ NSWindow *window = titledWindow(320, 240);
+ assert(window != nil);
+ [window setFrame:NSMakeRect(200, 200, 320, 240) display:NO];
+ // The delegate's size path runs only while the window is live; the
+ // rest of the delegate is not involved in this test.
+ VivaAppDelegate *delegate = [VivaAppDelegate alloc];
+ [delegate setValue:window forKey:@"_window"];
+ [delegate setValue:@YES forKey:@"_running"];
+
+ NSRect before = [window contentRectForFrameRect:window.frame];
+ [delegate setVivaContentSize:NSMakeSize(420, 300)];
+ NSRect after = [window contentRectForFrameRect:window.frame];
+ assert(near(after.size.width, 420) && near(after.size.height, 300));
+ // The top-left origin is preserved: same x, same top edge.
+ assert(near(NSMinX(after), NSMinX(before)));
+ assert(near(NSMaxY(after), NSMaxY(before)));
+
+ // The minimum content size wins over a smaller request.
+ window.contentMinSize = NSMakeSize(260, 200);
+ [delegate setVivaContentSize:NSMakeSize(100, 80)];
+ NSRect clamped = [window contentRectForFrameRect:window.frame];
+ assert(clamped.size.width >= 260 - 0.5 && clamped.size.height >= 200 - 0.5);
+
+ // A request larger than the screen is clamped to its visible area.
+ NSScreen *screen = window.screen ?: NSScreen.mainScreen ?: NSScreen.screens.firstObject;
+ assert(screen != nil);
+ [delegate setVivaContentSize:NSMakeSize(100000, 100000)];
+ NSRect huge = [window contentRectForFrameRect:window.frame];
+ assert(huge.size.width <= screen.visibleFrame.size.width + 0.5);
+ assert(huge.size.height <= screen.visibleFrame.size.height + 0.5);
+
+ printf("window size: content %.0fx%.0f, min clamp %.0fx%.0f, screen clamp %.0fx%.0f\n",
+ after.size.width, after.size.height, clamped.size.width, clamped.size.height,
+ huge.size.width, huge.size.height);
+ [delegate release];
+ [window release];
+ }
+ return 0;
+}
diff --git a/internal/platform/darwin/text.go b/internal/platform/darwin/text.go
index f184cf1f..da642d06 100644
--- a/internal/platform/darwin/text.go
+++ b/internal/platform/darwin/text.go
@@ -40,22 +40,25 @@ func MeasureText(value string, fontSize, weight float32) (text.Metrics, error) {
}
// measureMonoMetrics reports the system fixed-pitch font's character cell at a size: the
-// advance of one character and the line box around its baseline.
+// advance of one character, the line box around its baseline, and the thickness of the
+// strokes the face draws its light and heavy box drawing lines with.
func measureMonoMetrics(fontSize float32, face gfx.MonoFace) (text.Metrics, error) {
if fontSize <= 0 {
return text.Metrics{}, fmt.Errorf("darwin: font size must be positive")
}
syncMonoFamilies()
- var advance, ascent, descent, leading C.double
- if status := C.viva_measure_mono(C.double(fontSize), C.int(face), &advance, &ascent, &descent, &leading); status != 0 {
+ var advance, ascent, descent, leading, rule, heavy C.double
+ if status := C.viva_measure_mono(C.double(fontSize), C.int(face), &advance, &ascent, &descent, &leading, &rule, &heavy); status != 0 {
return text.Metrics{}, fmt.Errorf("darwin: CoreText fixed-pitch measurement failed with status %d", status)
}
return text.Metrics{
- Width: float32(advance),
- Height: float32(ascent + descent + leading),
- Ascent: float32(ascent),
- Descent: float32(descent),
- Leading: float32(leading),
+ Width: float32(advance),
+ Height: float32(ascent + descent + leading),
+ Ascent: float32(ascent),
+ Descent: float32(descent),
+ Leading: float32(leading),
+ Rule: float32(rule),
+ HeavyRule: float32(heavy),
}, nil
}
diff --git a/internal/platform/darwin/text_engine.go b/internal/platform/darwin/text_engine.go
index 5f69820c..c18a7294 100644
--- a/internal/platform/darwin/text_engine.go
+++ b/internal/platform/darwin/text_engine.go
@@ -112,7 +112,7 @@ func (engine *TextEngine) MonoCell(size float32, face gfx.MonoFace) gfx.MonoCell
if err != nil {
return gfx.MonoCellMetrics{}
}
- return gfx.MonoCellMetrics{Advance: metrics.Width, Ascent: metrics.Ascent, Descent: metrics.Descent, LineGap: metrics.Leading}
+ return gfx.MonoCellMetrics{Advance: metrics.Width, Ascent: metrics.Ascent, Descent: metrics.Descent, LineGap: metrics.Leading, Rule: metrics.Rule, HeavyRule: metrics.HeavyRule}
}
// FontGeneration implements gfx.FontFamilyAware.
diff --git a/internal/platform/darwin/text_test.go b/internal/platform/darwin/text_test.go
index e0e1712c..d21c662f 100644
--- a/internal/platform/darwin/text_test.go
+++ b/internal/platform/darwin/text_test.go
@@ -599,3 +599,30 @@ func TestACanvasOnTheEngineSizesTheTerminalCellByTheFont(t *testing.T) {
}
}
}
+
+// The engine reports the thicknesses the face draws its own box drawing lines with, in
+// logical pixels at the size asked: those are the strokes a cell glyph is drawn with, so box
+// drawing geometry weighs what the font's own glyphs weigh. CoreText measures the ink of the
+// light and heavy stems; a face that lacks one answers 0 for it.
+func TestTheEngineReportsTheFontsBoxDrawingStrokes(t *testing.T) {
+ engine := SharedTextEngine()
+ for _, face := range []gfx.MonoFace{gfx.MonoSystem, gfx.MonoFamily("Courier New")} {
+ cell := engine.MonoCell(13, face)
+ if cell.Advance <= 0 {
+ t.Fatalf("face %v: no cell", face)
+ }
+ if cell.Rule <= 0 || cell.Rule >= cell.Advance {
+ t.Errorf("face %v: light line %v of a cell %v wide, want a positive stroke narrower than the cell", face, cell.Rule, cell.Advance)
+ }
+ if cell.HeavyRule > 0 && cell.HeavyRule < cell.Rule {
+ t.Errorf("face %v: heavy line %v is thinner than the light one %v", face, cell.HeavyRule, cell.Rule)
+ }
+ at26 := engine.MonoCell(26, face)
+ if at26.Rule <= cell.Rule {
+ t.Errorf("face %v: light line %v at 26, %v at 13, want it to follow the size", face, at26.Rule, cell.Rule)
+ }
+ if cell.HeavyRule > 0 && at26.HeavyRule <= cell.HeavyRule {
+ t.Errorf("face %v: heavy line %v at 26, %v at 13, want it to follow the size", face, at26.HeavyRule, cell.HeavyRule)
+ }
+ }
+}
diff --git a/internal/platform/darwin/web_darwin.go b/internal/platform/darwin/web_darwin.go
index 03e0a669..b0c12e2a 100644
--- a/internal/platform/darwin/web_darwin.go
+++ b/internal/platform/darwin/web_darwin.go
@@ -3,7 +3,7 @@
package darwin
/*
-#cgo LDFLAGS: -framework Cocoa -framework WebKit -framework QuartzCore
+#cgo LDFLAGS: -framework Cocoa -framework WebKit -framework QuartzCore -framework Network
#include
#include "web_darwin.h"
*/
@@ -67,8 +67,15 @@ func newWebState(run *runState, host *platform.WebHost, initial []platform.Nativ
// open is WebHost.Open.
func (w *webState) open(config platform.WebViewConfig) (platform.WebView, error) {
+ direct := C.int(0)
if config.Proxy != "" {
- return nil, platform.ErrWebViewUnsupported
+ if config.Proxy != platform.WebProxyDirect {
+ return nil, fmt.Errorf("%w: macOS cannot hold a page to a SOCKS5 proxy", platform.ErrWebViewUnsupported)
+ }
+ if C.viva_web_direct_supported() == 0 {
+ return nil, fmt.Errorf("%w: direct web networking requires macOS 14 or newer", platform.ErrWebViewUnsupported)
+ }
+ direct = 1
}
if w.run.closed || w.token == nil {
return nil, platform.ErrWebViewUnsupported
@@ -105,7 +112,7 @@ func (w *webState) open(config platform.WebViewConfig) (platform.WebView, error)
ephemeral = 1
}
var failure *C.char
- native := C.viva_web_open(view, C.uint32_t(config.ID), C.uintptr_t(w.run.callbackHandle), url, html, base, script, mainFrameOnly, scheme, ephemeral, &failure)
+ native := C.viva_web_open(view, C.uint32_t(config.ID), C.uintptr_t(w.run.callbackHandle), url, html, base, script, mainFrameOnly, scheme, ephemeral, direct, &failure)
if native == nil {
message := "the page could not be created"
if failure != nil {
diff --git a/internal/platform/darwin/web_darwin.h b/internal/platform/darwin/web_darwin.h
index 5e734ca6..129d8db0 100644
--- a/internal/platform/darwin/web_darwin.h
+++ b/internal/platform/darwin/web_darwin.h
@@ -13,8 +13,9 @@ void *viva_application_window_view(void *token);
// bridge script is for the main frame alone (WebKit is also told to inject it there only). An
// ephemeral page keeps its data in a store in memory, which goes with the page.
void *viva_web_open(void *view, uint32_t id, uintptr_t handle, const char *url, const char *html,
- const char *base, const char *script, int main_frame_only, const char *scheme, int ephemeral,
+ const char *base, const char *script, int main_frame_only, const char *scheme, int ephemeral, int direct,
char **error);
+int viva_web_direct_supported(void);
void viva_web_load(void *web, const char *url);
void viva_web_load_html(void *web, const char *html, const char *base);
void viva_web_reload(void *web);
diff --git a/internal/platform/darwin/web_darwin.m b/internal/platform/darwin/web_darwin.m
index 3e450747..f95366a5 100644
--- a/internal/platform/darwin/web_darwin.m
+++ b/internal/platform/darwin/web_darwin.m
@@ -1,6 +1,7 @@
#import
#import
#import
+#import
#include
#include
@@ -518,8 +519,17 @@ - (void)mouseExited:(NSEvent *)event {
@end
+int viva_web_direct_supported(void) {
+#if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) && __MAC_OS_X_VERSION_MAX_ALLOWED >= 140000
+ if (@available(macOS 14.0, *)) {
+ return 1;
+ }
+#endif
+ return 0;
+}
+
void *viva_web_open(void *view, uint32_t id, uintptr_t handle, const char *url, const char *html,
- const char *base, const char *script, int mainFrameOnly, const char *scheme, int ephemeral,
+ const char *base, const char *script, int mainFrameOnly, const char *scheme, int ephemeral, int direct,
char **error) {
__block VivaWebView *web = nil;
viva_web_main(^{
@@ -531,11 +541,25 @@ - (void)mouseExited:(NSEvent *)event {
bridge->_handle = handle;
bridge->_id = id;
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
- if (ephemeral) {
- // Nothing the page keeps reaches the disk, and no other page shares it: the store lives
- // in this process only and goes with the page.
+ if (ephemeral || direct) {
+ // Ephemeral pages keep nothing on disk. Direct pages also need a private store so
+ // their proxy policy cannot change another page's in-flight requests or data store.
configuration.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore];
}
+ if (direct) {
+#if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) && __MAC_OS_X_VERSION_MAX_ALLOWED >= 140000
+ if (@available(macOS 14.0, *)) {
+ // An empty proxyConfigurations array restores system proxies. Excluding every
+ // domain selects direct connections; the excluded endpoint is never contacted.
+ nw_endpoint_t endpoint = nw_endpoint_create_host("127.0.0.1", "9");
+ nw_proxy_config_t policy = nw_proxy_config_create_http_connect(endpoint, NULL);
+ nw_proxy_config_add_excluded_domain(policy, "*");
+ configuration.websiteDataStore.proxyConfigurations = @[policy];
+ nw_release(policy);
+ nw_release(endpoint);
+ }
+#endif
+ }
WKUserContentController *controller = configuration.userContentController;
[controller addScriptMessageHandler:bridge name:@"viva"];
WKUserScript *bridgeScript = [[WKUserScript alloc] initWithSource:[NSString stringWithUTF8String:script]
diff --git a/internal/platform/darwin/window_size_darwin.go b/internal/platform/darwin/window_size_darwin.go
new file mode 100644
index 00000000..5f16d051
--- /dev/null
+++ b/internal/platform/darwin/window_size_darwin.go
@@ -0,0 +1,21 @@
+package darwin
+
+/*
+#include "native.h"
+*/
+import "C"
+
+import "fmt"
+
+// SetSize queues a content size request on the AppKit thread. The
+// system may clamp the result to the window minimum or the screen.
+func (window *nativeWindow) SetSize(width, height int) error {
+ if window == nil || window.closed.Load() {
+ return fmt.Errorf("darwin: window is closed")
+ }
+ if width < 1 || height < 1 || width > 65535 || height > 65535 {
+ return fmt.Errorf("darwin: invalid window size %dx%d", width, height)
+ }
+ C.viva_application_set_window_size(window.token, C.double(width), C.double(height))
+ return nil
+}
diff --git a/internal/platform/darwin/window_size_darwin_test.go b/internal/platform/darwin/window_size_darwin_test.go
new file mode 100644
index 00000000..c00f0593
--- /dev/null
+++ b/internal/platform/darwin/window_size_darwin_test.go
@@ -0,0 +1,24 @@
+package darwin
+
+import (
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+// The native content-size path runs on a window that is never shown, so the
+// test opens nothing on screen.
+func TestNativeWindowContentSize(t *testing.T) {
+ executable := filepath.Join(t.TempDir(), "window-size")
+ command := exec.Command("xcrun", "clang", "-fno-objc-arc", "-Werror",
+ "-Wno-deprecated-declarations", "testdata/window_size.m", "-o", executable,
+ "-framework", "Cocoa", "-framework", "CoreText", "-framework", "Metal", "-framework", "QuartzCore")
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("compile native window size test: %v\n%s", err, output)
+ }
+ if output, err := exec.Command(executable).CombinedOutput(); err != nil {
+ t.Fatalf("native window content size: %v\n%s", err, output)
+ } else {
+ t.Logf("%s", output)
+ }
+}
diff --git a/internal/platform/linux/clipboard_files_write_linux.go b/internal/platform/linux/clipboard_files_write_linux.go
index 9ec85fa6..bcf155ec 100644
--- a/internal/platform/linux/clipboard_files_write_linux.go
+++ b/internal/platform/linux/clipboard_files_write_linux.go
@@ -44,6 +44,7 @@ func SetClipboardFiles(paths []string) error {
names[index] = filepath.Base(path)
}
win.clipboard = strings.Join(names, "\n")
+ win.clipboardHTML = ""
win.clipboardFiles = append([]string(nil), paths...)
win.ownsClipboard = true
return nil
diff --git a/internal/platform/linux/clipboard_html_linux_test.go b/internal/platform/linux/clipboard_html_linux_test.go
new file mode 100644
index 00000000..676d82e1
--- /dev/null
+++ b/internal/platform/linux/clipboard_html_linux_test.go
@@ -0,0 +1,102 @@
+package linux
+
+import (
+ "testing"
+)
+
+// TestAClipboardHTMLOfferedAsTextHTMLBesideUTF8 pins the selection request
+// service of a rich copy: TARGETS names text/html only while the pair is on
+// the board, the html target hands over the fragment, and the plain targets
+// beside it are untouched.
+func TestAClipboardHTMLOfferedAsTextHTMLBesideUTF8(t *testing.T) {
+ recorder := &selectionRecorder{}
+ win := newSelectionWindowWithHTML(recorder, "alt text", "rich and 中文")
+ request := func(target uint32) propertyWrite {
+ t.Helper()
+ before := len(recorder.writes)
+ if err := win.handleSelectionRequest(&xcbSelectionRequestEvent{Requestor: 3, Selection: 10, Target: target, Property: 14}); err != nil {
+ t.Fatal(err)
+ }
+ return recorder.writes[before]
+ }
+ targets := request(13)
+ var offered []uint32
+ for offset := 0; offset+4 <= len(targets.data); offset += 4 {
+ offered = append(offered, uint32(targets.data[offset])|uint32(targets.data[offset+1])<<8|uint32(targets.data[offset+2])<<16|uint32(targets.data[offset+3])<<24)
+ }
+ if len(offered) != 5 || offered[0] != 13 || offered[4] != win.htmlAtom {
+ t.Fatalf("TARGETS = %v, want [13 .. html=17], the html named only while the pair holds", offered)
+ }
+ write := request(win.htmlAtom)
+ if write.kind != win.htmlAtom || string(write.data) != "rich and 中文" {
+ t.Fatalf("text/html = kind %d data %q", write.kind, write.data)
+ }
+ if write := request(11); string(write.data) != "alt text" {
+ t.Fatalf("UTF8_STRING beside the html = %q", write.data)
+ }
+
+ // Another copy drops the flavour: TARGETS stops naming text/html, and asking
+ // for it anyway is refused (the notify carries no property).
+ win.clipboard, win.clipboardHTML = "typed", ""
+ recorder.writes = nil
+ targets = request(13)
+ offered = nil
+ for offset := 0; offset+4 <= len(targets.data); offset += 4 {
+ offered = append(offered, uint32(targets.data[offset])|uint32(targets.data[offset+1])<<8|uint32(targets.data[offset+2])<<16|uint32(targets.data[offset+3])<<24)
+ }
+ for _, atom := range offered {
+ if atom == win.htmlAtom {
+ t.Fatal("TARGETS still names text/html after a plain copy")
+ }
+ }
+ recorder.writes, recorder.sent = nil, nil
+ if err := win.handleSelectionRequest(&xcbSelectionRequestEvent{Requestor: 3, Selection: 10, Target: win.htmlAtom, Property: 14}); err != nil {
+ t.Fatal(err)
+ }
+ if len(recorder.writes) != 0 || len(recorder.sent) != 1 || recorder.sent[0].Property != xcbAtomNone {
+ t.Fatalf("after the pair was replaced: writes %+v sent %+v", recorder.writes, recorder.sent)
+ }
+ // And losing the clipboard loses the flavour with it.
+ win.ownsClipboard = true
+ win.handleSelectionClear(&xcbSelectionClearEvent{Selection: 10})
+ if win.clipboardHTML != "" || win.ownsClipboard {
+ t.Fatal("clearing the clipboard kept the rich flavour")
+ }
+}
+
+func newSelectionWindowWithHTML(recorder *selectionRecorder, text, html string) *window {
+ win := newSelectionWindow(recorder, text)
+ win.clipboardHTML = html
+ win.htmlAtom = 17
+ return win
+}
+
+// TestSetClipboardHTMLClaimsTheBoardAndRefusesBadInput covers the ownership
+// path: the pair claims the CLIPBOARD selection, invalid input is refused
+// before anything is claimed, and without a shell hook that can write the pair
+// the X11 event loop's own serve path is what answers.
+func TestSetClipboardHTMLClaimsTheBoardAndRefusesBadInput(t *testing.T) {
+ recorder := &selectionRecorder{masks: map[uint32]uint32{}, incrAtom: 77}
+ win := &window{api: recorder.api(), connection: 1, id: 2, clipboardAtom: 10, utf8Atom: 11, textAtom: 12, targetsAtom: 13, htmlAtom: 17, uriListAtom: 15, gnomeFilesAtom: 16}
+ activeWindow.Store(win)
+ t.Cleanup(func() { activeWindow.Store(nil) })
+ if err := SetClipboardHTML("x", "x"); err != nil {
+ t.Fatal(err)
+ }
+ if !win.ownsClipboard || win.clipboard != "x" || win.clipboardHTML != "x" || len(win.clipboardFiles) != 0 {
+ t.Fatalf("the board holds %q/%q owned=%v files=%d", win.clipboard, win.clipboardHTML, win.ownsClipboard, len(win.clipboardFiles))
+ }
+ if err := SetClipboardHTML("a\x00b", "alt"); err == nil {
+ t.Fatal("html with NUL was accepted")
+ }
+ if err := SetClipboardHTML("t", "a\x00b"); err == nil {
+ t.Fatal("alt with NUL was accepted")
+ }
+ if err := SetClipboardHTML("bad\xff", "alt"); err == nil {
+ t.Fatal("invalid UTF-8 was accepted")
+ }
+ activeWindow.Store(nil)
+ if err := SetClipboardHTML("x", "x"); err == nil || err.Error() == "" {
+ t.Fatal("a copy without a window was accepted")
+ }
+}
diff --git a/internal/platform/linux/clipboard_html_write_linux.go b/internal/platform/linux/clipboard_html_write_linux.go
new file mode 100644
index 00000000..3815b7fa
--- /dev/null
+++ b/internal/platform/linux/clipboard_html_write_linux.go
@@ -0,0 +1,43 @@
+package linux
+
+import (
+ "fmt"
+ "unicode/utf8"
+)
+
+// SetClipboardHTML makes the CLIPBOARD selection hold html as its text/html
+// target beside alt as the UTF8_STRING one: a rich reader asks for text/html, a
+// plain one the text, both from one copy. The X11 owner serves the pair while
+// this window lives; GTK, which owns the display connection on Wayland, serves
+// the same two targets through its own clipboard.
+func SetClipboardHTML(html, alt string) error {
+ if len(html) > maxClipboardBytes {
+ return fmt.Errorf("linux: clipboard html exceeds 64 MiB")
+ }
+ if !utf8.ValidString(html) || !utf8.ValidString(alt) {
+ return fmt.Errorf("linux: clipboard html is not valid UTF-8")
+ }
+ if hooks := shellHooks.Load(); hooks != nil {
+ if hooks.setClipboardHTML == nil {
+ return ErrUnsupported
+ }
+ return hooks.setClipboardHTML(html, alt)
+ }
+ win := activeWindow.Load()
+ if win == nil {
+ return fmt.Errorf("linux: clipboard ownership requires a running window")
+ }
+ win.mu.Lock()
+ defer win.mu.Unlock()
+ if activeWindow.Load() != win || win.connection == 0 {
+ return fmt.Errorf("linux: clipboard ownership requires a running window")
+ }
+ if err := win.claimClipboardLocked(); err != nil {
+ return err
+ }
+ win.clipboard = alt
+ win.clipboardHTML = html
+ win.clipboardFiles = nil
+ win.ownsClipboard = true
+ return nil
+}
diff --git a/internal/platform/linux/cursor_linux.go b/internal/platform/linux/cursor_linux.go
index d9849786..8232710f 100644
--- a/internal/platform/linux/cursor_linux.go
+++ b/internal/platform/linux/cursor_linux.go
@@ -21,7 +21,7 @@ type themedCursors struct {
// cursorNames are the theme names of the cursors, in app.Cursor order.
var cursorNames = [...]string{
"left_ptr", "xterm", "hand2", "crosshair", "watch",
- "sb_h_double_arrow", "sb_v_double_arrow", "size_fdiag", "size_bdiag", "crossed_circle",
+ "sb_h_double_arrow", "sb_v_double_arrow", "size_fdiag", "size_bdiag", "crossed_circle", "grabbing",
}
// openThemedCursors returns nil when the library or the theme is not there.
diff --git a/internal/platform/linux/gtk_clipboard_html_linux_test.go b/internal/platform/linux/gtk_clipboard_html_linux_test.go
new file mode 100644
index 00000000..73b8d715
--- /dev/null
+++ b/internal/platform/linux/gtk_clipboard_html_linux_test.go
@@ -0,0 +1,53 @@
+//go:build linux && vivagtk
+
+package linux
+
+import (
+ "errors"
+ "runtime"
+ "syscall"
+ "testing"
+)
+
+// The rich pair is an optional GTK capability, like the file list: without
+// gtk_clipboard_set_with_data its absence must reach callers as the Linux
+// unsupported sentinel, and with it the pair goes through one call, without
+// disabling plain text.
+func TestGTKClipboardHTMLWriteReportsUnsupportedWithoutSetWithData(t *testing.T) {
+ runtime.LockOSThread()
+ t.Cleanup(runtime.UnlockOSThread)
+ previousClipboards, previousHooks := clipboards, shellHooks.Load()
+ t.Cleanup(func() {
+ clipboards = previousClipboards
+ shellHooks.Store(previousHooks)
+ })
+ textWrites := 0
+ clipboards = gtkClipboards{
+ ok: true, clipboard: 17,
+ setText: func(board uintptr, value *byte, length int32) { textWrites++ },
+ }
+ application := >kApplication{mainThread: syscall.Gettid()}
+ application.installHooks()
+ if err := SetClipboardHTML("bold", "bold"); !errors.Is(err, ErrUnsupported) {
+ t.Fatalf("GTK without rich writes returned %v, want ErrUnsupported", err)
+ }
+ if err := SetClipboardText("hello"); err != nil || textWrites != 1 {
+ t.Fatalf("optional rich support broke text writes: error=%v writes=%d", err, textWrites)
+ }
+ htmlWrites := 0
+ clipboards.setWithData = func(clipboard, targets uintptr, count int32, get, clear, data uintptr) int32 {
+ if clipboard != 17 || targets == 0 || count != 2 || get == 0 || clear == 0 {
+ t.Fatalf("unexpected set_with_data call: clipboard=%d targets=%d count=%d", clipboard, targets, count)
+ }
+ htmlWrites++
+ return 1
+ }
+ clipboards.selectionGet = func(uintptr) uintptr { return 1 }
+ clipboards.selectionSet = func(data, kind uintptr, format int32, bytes *byte, length int32) int32 { return 1 }
+ if err := SetClipboardHTML("bold", "bold"); err != nil || htmlWrites != 1 {
+ t.Fatalf("GTK with rich writes returned error=%v writes=%d", err, htmlWrites)
+ }
+ if err := SetClipboardText("hello again"); err != nil || textWrites != 2 {
+ t.Fatalf("rich writes broke text writes: error=%v writes=%d", err, textWrites)
+ }
+}
diff --git a/internal/platform/linux/gtk_system_linux.go b/internal/platform/linux/gtk_system_linux.go
index 5d82bbbb..7c8b8854 100644
--- a/internal/platform/linux/gtk_system_linux.go
+++ b/internal/platform/linux/gtk_system_linux.go
@@ -34,6 +34,12 @@ type gtkClipboards struct {
waitURIs func(clipboard uintptr) uintptr
setURIs func(clipboard, uris uintptr)
strfreev func(list uintptr)
+ // The rich pair: set_with_data owns the clipboard until another copy and
+ // answers the requested target from the get callback.
+ setWithData func(clipboard, targets uintptr, count int32, get, clear, data uintptr) int32
+ selectionData func(data uintptr) uintptr
+ selectionSet func(data, kind uintptr, format int32, bytes *byte, length int32) int32
+ selectionGet func(data uintptr) uintptr
clipboard, primary uintptr
ok bool
@@ -79,6 +85,14 @@ func (a *gtkApplication) clipboardAPI() (*gtkClipboards, error) {
c.saveBuffer = nil
}
}
+ // The rich pair is optional as the file list is: an older GTK without
+ // gtk_clipboard_set_with_data still has plain text.
+ if !g.lookup(&c.setWithData, "gtk_clipboard_set_with_data") ||
+ !g.lookup(&c.selectionData, "gtk_selection_data_get_data") ||
+ !g.lookup(&c.selectionSet, "gtk_selection_data_set") ||
+ !g.lookup(&c.selectionGet, "gtk_selection_data_get_target") {
+ c.setWithData, c.selectionData, c.selectionSet, c.selectionGet = nil, nil, nil, nil
+ }
c.ok = true
return c, nil
}
@@ -115,6 +129,12 @@ func (a *gtkApplication) installHooks() {
}
return
},
+ setClipboardHTML: func(html, alt string) (err error) {
+ if e := a.onMain(func() { err = a.writeHTML(html, alt) }); e != nil {
+ return e
+ }
+ return
+ },
primaryText: func() (value string, err error) {
err = a.onMain(func() { value, err = a.readText(true) })
return
@@ -200,6 +220,58 @@ func (a *gtkApplication) writeText(primary bool, value string) error {
return nil
}
+// gtkTargetEntry is C's GtkTargetEntry: the target's name, its flags and the
+// info the get callback matches on, laid out for the call that takes an array.
+type gtkTargetEntry struct {
+ target uintptr
+ flags uint32
+ info uint32
+}
+
+// writeHTML makes the clipboard hold html as text/html beside alt as plain
+// text. GTK owns the pair through gtk_clipboard_set_with_data, whose get
+// callback answers whichever target the requestor asked for; the flavour lives
+// as long as the clipboard does, as it does on X11.
+func (a *gtkApplication) writeHTML(html, alt string) error {
+ c, err := a.clipboardAPI()
+ if err != nil {
+ return err
+ }
+ if c.setWithData == nil {
+ return ErrUnsupported
+ }
+ htmlName := cString("text/html")
+ textName := cString("UTF8_STRING")
+ targets := [2]gtkTargetEntry{
+ {target: uintptr(unsafe.Pointer(&htmlName[0]))},
+ {target: uintptr(unsafe.Pointer(&textName[0]))},
+ }
+ htmlAtom := c.atomIntern(&htmlName[0], 0)
+ htmlBytes, textBytes := cString(html), cString(alt)
+ // The get callback runs on GTK's thread with the pair this closure captured,
+ // answering the target the requestor asked for by the atom of its name.
+ get := purego.NewCallback(func(clipboard, selection, data uintptr) uintptr {
+ kind := c.selectionGet(data)
+ value := textBytes
+ if kind == htmlAtom {
+ value = htmlBytes
+ }
+ c.selectionSet(data, kind, 8, &value[0], int32(len(value)))
+ return 0
+ })
+ clearCallback := purego.NewCallback(func(clipboard, userData uintptr) uintptr { return 0 })
+ if c.setWithData(c.clipboard, uintptr(unsafe.Pointer(&targets[0])), 2, get, clearCallback, 0) == 0 {
+ return fmt.Errorf("linux: gtk_clipboard_set_with_data failed")
+ }
+ // gtk_clipboard_set_with_data reads the entries in the call; the get callback
+ // keeps the two flavours for as long as GTK may ask for them.
+ runtime.KeepAlive(htmlName)
+ runtime.KeepAlive(textName)
+ runtime.KeepAlive(htmlBytes)
+ runtime.KeepAlive(textBytes)
+ return nil
+}
+
// readFiles returns the paths of the file URIs the clipboard holds. GTK asks the owner for
// text/uri-list and hands over a NULL-terminated array of strings.
func (a *gtkApplication) readFiles() ([]string, error) {
diff --git a/internal/platform/linux/gtk_wayland_linux.go b/internal/platform/linux/gtk_wayland_linux.go
index b521e56c..90850681 100644
--- a/internal/platform/linux/gtk_wayland_linux.go
+++ b/internal/platform/linux/gtk_wayland_linux.go
@@ -668,7 +668,7 @@ func intersectScissor(a, b render.Scissor) render.Scissor {
}
// wp_cursor_shape_device_v1.shape for the cursors the application asks for, in app.Cursor order.
-var cursorShapes = [...]uint32{1 /* default */, 9 /* text */, 4 /* pointer */, 8 /* crosshair */, 6 /* wait */, 26 /* ew-resize */, 27 /* ns-resize */, 29 /* nwse-resize */, 28 /* nesw-resize */, 15 /* not-allowed */}
+var cursorShapes = [...]uint32{1 /* default */, 9 /* text */, 4 /* pointer */, 8 /* crosshair */, 6 /* wait */, 26 /* ew-resize */, 27 /* ns-resize */, 29 /* nwse-resize */, 28 /* nesw-resize */, 15 /* not-allowed */, 5 /* grabbing */}
// applyCursor shows the window's cursor on the subsurface, which GDK does not manage.
func (s *waylandSurface) applyCursor() {
diff --git a/internal/platform/linux/gtk_web_linux.go b/internal/platform/linux/gtk_web_linux.go
index 7c40a263..ef395e91 100644
--- a/internal/platform/linux/gtk_web_linux.go
+++ b/internal/platform/linux/gtk_web_linux.go
@@ -281,7 +281,10 @@ type webContext struct {
}
// WebKitNetworkProxyMode.
-const webProxyCustom = 2
+const (
+ webProxyNone = 1
+ webProxyCustom = 2
+)
// proxyContext is the context for pages held to proxy, shared by the pages behind that proxy and
// made the first time.
@@ -298,7 +301,7 @@ func proxyContext(g *gtkLibrary, k *webKit, proxy string) (*webContext, error) {
return context, nil
}
-// newProxyContext makes an ephemeral context with every request sent through proxy. It is not
+// newProxyContext makes an ephemeral context with the requested network policy. It is not
// shared: a page that asked for a proxy and to keep nothing gets one of these, so it shares
// nothing with the pages behind the same proxy.
func newProxyContext(k *webKit, proxy string) (*webContext, error) {
@@ -309,16 +312,23 @@ func newProxyContext(k *webKit, proxy string) (*webContext, error) {
if err != nil {
return nil, err
}
- // No hosts are left out: loopback goes through the proxy too, and names are the proxy's to
- // resolve.
- uri := cString("socks5://" + proxy)
- settings := k.proxySettingsNew(&uri[0], 0)
+ mode := int32(webProxyCustom)
+ var settings uintptr
+ if proxy == platform.WebProxyDirect {
+ mode = webProxyNone
+ } else {
+ // No hosts are left out: loopback goes through the proxy too, and names are the proxy's to resolve.
+ uri := cString("socks5://" + proxy)
+ settings = k.proxySettingsNew(&uri[0], 0)
+ }
if k.managerSetProxy != nil {
- k.managerSetProxy(k.contextDataManager(context.object), webProxyCustom, settings)
+ k.managerSetProxy(k.contextDataManager(context.object), mode, settings)
} else {
- k.contextSetProxy(context.object, webProxyCustom, settings)
+ k.contextSetProxy(context.object, mode, settings)
+ }
+ if settings != 0 {
+ k.proxySettingsFree(settings)
}
- k.proxySettingsFree(settings)
context.proxy = proxy
return context, nil
}
diff --git a/internal/platform/linux/selection_linux.go b/internal/platform/linux/selection_linux.go
index ea07b7b1..8fd86259 100644
--- a/internal/platform/linux/selection_linux.go
+++ b/internal/platform/linux/selection_linux.go
@@ -58,6 +58,9 @@ func (win *window) ensureClipboardAtoms() error {
if win.gnomeFilesAtom, err = win.api.atom(win.connection, "x-special/gnome-copied-files"); err != nil {
return err
}
+ if win.htmlAtom, err = win.api.atom(win.connection, "text/html"); err != nil {
+ return err
+ }
return nil
}
@@ -159,13 +162,23 @@ func (win *window) handleSelectionRequest(request *xcbSelectionRequestEvent) err
}
switch request.Target {
case win.targetsAtom:
- targets := make([]uint32, 0, 6)
+ targets := make([]uint32, 0, 7)
targets = append(targets, win.targetsAtom, win.utf8Atom, win.textAtom, xcbAtomString)
+ if request.Selection == win.clipboardAtom && win.clipboardHTML != "" {
+ targets = append(targets, win.htmlAtom)
+ }
if request.Selection == win.clipboardAtom && len(win.clipboardFiles) > 0 {
targets = append(targets, win.uriListAtom, win.gnomeFilesAtom)
}
win.api.changeProperty(win.connection, xcbPropertyReplace, request.Requestor, property, xcbAtomAtom, 32, uint32(len(targets)), unsafe.Pointer(&targets[0]))
notify.Property = property
+ case win.htmlAtom:
+ // The rich flavour of a rich copy, beside the text its plain readers
+ // take; only the CLIPBOARD holds one, and only until another copy.
+ if request.Selection == win.clipboardAtom && win.clipboardHTML != "" &&
+ win.writeSelection(request.Requestor, property, request.Target, []byte(win.clipboardHTML)) {
+ notify.Property = property
+ }
case win.uriListAtom:
if request.Selection == win.clipboardAtom && len(win.clipboardFiles) > 0 && win.writeSelection(request.Requestor, property, request.Target, platform.FileURIList(win.clipboardFiles)) {
notify.Property = property
@@ -270,7 +283,7 @@ func (win *window) handleSelectionClear(event *xcbSelectionClearEvent) {
defer win.mu.Unlock()
switch {
case event.Selection == win.clipboardAtom && win.clipboardAtom != 0:
- win.clipboard, win.clipboardFiles = "", nil
+ win.clipboard, win.clipboardHTML, win.clipboardFiles = "", "", nil
win.ownsClipboard = false
case event.Selection == xcbAtomPrimary:
win.selections.primary = ""
@@ -310,6 +323,9 @@ func (win *window) saveClipboardToManager() {
win.mu.Lock()
err = win.ensureClipboardAtoms()
targets := []uint32{win.utf8Atom, win.textAtom, xcbAtomString}
+ if win.clipboardHTML != "" {
+ targets = append(targets, win.htmlAtom)
+ }
if len(win.clipboardFiles) > 0 {
targets = append(targets, win.uriListAtom, win.gnomeFilesAtom)
}
diff --git a/internal/platform/linux/shell_hooks_linux.go b/internal/platform/linux/shell_hooks_linux.go
index d2a2d087..2e138995 100644
--- a/internal/platform/linux/shell_hooks_linux.go
+++ b/internal/platform/linux/shell_hooks_linux.go
@@ -15,6 +15,7 @@ var shellHooks atomic.Pointer[desktopHooks]
type desktopHooks struct {
clipboardText func() (string, error)
setClipboardText func(string) error
+ setClipboardHTML func(html, alt string) error
hasClipboardImage func() bool
clipboardImagePNG func() ([]byte, error)
clipboardFiles func() ([]string, error)
diff --git a/internal/platform/linux/status_item_linux.go b/internal/platform/linux/status_item_linux.go
index 407be48e..66af1ef3 100644
--- a/internal/platform/linux/status_item_linux.go
+++ b/internal/platform/linux/status_item_linux.go
@@ -1,6 +1,7 @@
package linux
import (
+ "encoding/binary"
"errors"
"fmt"
"math"
@@ -141,9 +142,29 @@ func (win *window) markPanel(config platform.Config, x, y int16) error {
}
// command runs one request another goroutine posted to this window's loop.
-func (state *linuxWindowState) command(command uint32) error {
+func (state *linuxWindowState) command(command uint32, data []byte) error {
win := state.win
switch command {
+ case commandSetSize:
+ if len(data) < 12 || win.frame.maximized || win.minimized || win.fullscreen.Load() {
+ return nil
+ }
+ width := int(binary.LittleEndian.Uint32(data[4:8]))
+ height := int(binary.LittleEndian.Uint32(data[8:12]))
+ margin := uint32(0)
+ if win.margin > 0 {
+ margin = frameMargin(win.displayScale())
+ }
+ if width < 1 || height < 1 {
+ return nil
+ }
+ size := [2]uint32{
+ min(deviceLength(width, win.displayScale())+2*margin, 65535),
+ min(deviceLength(height, win.displayScale())+2*margin, 65535),
+ }
+ win.api.configureWindow(win.connection, win.id,
+ xcbConfigWindowWidth|xcbConfigWindowHeight, &size[0])
+ win.api.flush(win.connection)
case commandEnterFullscreen, commandExitFullscreen:
action := uint32(0) // _NET_WM_STATE_REMOVE
if command == commandEnterFullscreen {
diff --git a/internal/platform/linux/system_linux.go b/internal/platform/linux/system_linux.go
index 9877a3bc..5f6ab41c 100644
--- a/internal/platform/linux/system_linux.go
+++ b/internal/platform/linux/system_linux.go
@@ -475,6 +475,7 @@ func SetClipboardText(value string) error {
return err
}
win.clipboard = value
+ win.clipboardHTML = ""
win.clipboardFiles = nil
win.ownsClipboard = true
return nil
@@ -1091,8 +1092,8 @@ func (win *window) checkRequest(action string, cookie xcbVoidCookie) error {
}
// cursorHidden is the shape a surface that draws its own pointer asks for: app.CursorHidden,
-// after the ten shapes of the cursor font and the theme.
-const cursorHidden uint8 = 10
+// after the eleven shapes of the cursor font and the theme.
+const cursorHidden uint8 = 11
// applyHiddenCursor makes a fully transparent cursor: a 1x1 bitmap, cleared to zero, as both the
// source and the mask of the cursor, so no pixel of the pointer shows. The contents of a new
diff --git a/internal/platform/linux/system_linux_test.go b/internal/platform/linux/system_linux_test.go
index bc3fff52..784ca10a 100644
--- a/internal/platform/linux/system_linux_test.go
+++ b/internal/platform/linux/system_linux_test.go
@@ -270,7 +270,7 @@ func TestSetCursorShapeAppliesAndReleasesCursor(t *testing.T) {
}
}
-// The hidden cursor is the eleventh shape, past the end of the cursor font's glyphs: it makes a
+// The hidden cursor is the twelfth shape, past the end of the cursor font's glyphs: it makes a
// cursor of a 1x1 bitmap that is cleared first, since a new pixmap has undefined contents, and
// applies it in place of the one the window had.
func TestSetCursorShapeHiddenClearsItsBitmapAndReplacesTheCursor(t *testing.T) {
diff --git a/internal/platform/linux/text_engine_linux.go b/internal/platform/linux/text_engine_linux.go
index bb8c9225..aa33822a 100644
--- a/internal/platform/linux/text_engine_linux.go
+++ b/internal/platform/linux/text_engine_linux.go
@@ -138,15 +138,17 @@ func (engine *textEngine) SetFontFamily(family string) {
func (engine *textEngine) MonoCell(size float32, face gfx.MonoFace) gfx.MonoCellMetrics {
engine.mu.Lock()
defer engine.mu.Unlock()
- metrics, err := engine.services.configureLayout("M", monoFamily(face), size, 0, false)
+ metrics, err := engine.services.configureMonoLayout(monoFamily(face), size)
if err != nil {
return gfx.MonoCellMetrics{}
}
return gfx.MonoCellMetrics{
- Advance: metrics.Width,
- Ascent: metrics.Ascent,
- Descent: metrics.Descent,
- LineGap: max(metrics.Height-metrics.Ascent-metrics.Descent, 0),
+ Advance: metrics.Width,
+ Ascent: metrics.Ascent,
+ Descent: metrics.Descent,
+ LineGap: max(metrics.Height-metrics.Ascent-metrics.Descent, 0),
+ Rule: metrics.Rule,
+ HeavyRule: metrics.HeavyRule,
}
}
diff --git a/internal/platform/linux/text_linux.go b/internal/platform/linux/text_linux.go
index f714ce34..6be23c2b 100644
--- a/internal/platform/linux/text_linux.go
+++ b/internal/platform/linux/text_linux.go
@@ -40,9 +40,20 @@ type textServices struct {
layoutSetSingleParagraph func(uintptr, int32)
layoutGetExtents func(uintptr, *pangoRectangle, *pangoRectangle)
layoutGetBaseline func(uintptr) int32
- layoutGetCursorPos func(uintptr, int32, *pangoRectangle, *pangoRectangle)
- layoutIndexToPos func(uintptr, int32, *pangoRectangle)
- fontFromString func(*byte) uintptr
+ layoutSetAttributes func(uintptr, uintptr)
+ // layoutGetUnknownGlyphs is pango_layout_get_unknown_glyphs_count; nil on a Pango old
+ // enough not to have it.
+ layoutGetUnknownGlyphs func(uintptr) int32
+ layoutGetCursorPos func(uintptr, int32, *pangoRectangle, *pangoRectangle)
+ layoutIndexToPos func(uintptr, int32, *pangoRectangle)
+ fontFromString func(*byte) uintptr
+ // noFallback is the attribute list that turns Pango's font fallback off, for measuring
+ // a box drawing stroke in the face's own font; 0 when this Pango cannot make it.
+ noFallback uintptr
+ attrListNew func() uintptr
+ attrListUnref func(uintptr)
+ attrListInsert func(uintptr, uintptr)
+ attrFallbackNew func(int32) uintptr
// contextSetRoundGlyphs is pango_context_set_round_glyph_positions (Pango 1.44); nil
// before it.
contextSetRoundGlyphs func(uintptr, int32)
@@ -124,6 +135,15 @@ func loadTextAPI() (*textServices, error) {
purego.RegisterLibFunc(&services.layoutSetSingleParagraph, services.pango, "pango_layout_set_single_paragraph_mode")
purego.RegisterLibFunc(&services.layoutGetExtents, services.pango, "pango_layout_get_extents")
purego.RegisterLibFunc(&services.layoutGetBaseline, services.pango, "pango_layout_get_baseline")
+ purego.RegisterLibFunc(&services.layoutSetAttributes, services.pango, "pango_layout_set_attributes")
+ if symbol, err := purego.Dlsym(services.pango, "pango_layout_get_unknown_glyphs_count"); err == nil && symbol != 0 {
+ purego.RegisterLibFunc(&services.layoutGetUnknownGlyphs, services.pango, "pango_layout_get_unknown_glyphs_count")
+ }
+ purego.RegisterLibFunc(&services.attrListNew, services.pango, "pango_attr_list_new")
+ purego.RegisterLibFunc(&services.attrListUnref, services.pango, "pango_attr_list_unref")
+ purego.RegisterLibFunc(&services.attrListInsert, services.pango, "pango_attr_list_insert")
+ purego.RegisterLibFunc(&services.attrFallbackNew, services.pango, "pango_attr_fallback_new")
+ services.noFallback = services.fallbackOff()
if symbol, err := purego.Dlsym(services.pango, "pango_context_set_round_glyph_positions"); err == nil && symbol != 0 {
purego.RegisterLibFunc(&services.contextSetRoundGlyphs, services.pango, "pango_context_set_round_glyph_positions")
}
@@ -232,6 +252,10 @@ func (services *textServices) close() {
}
func (services *textServices) closeLibraries() {
+ if services.noFallback != 0 && services.attrListUnref != nil {
+ services.attrListUnref(services.noFallback)
+ services.noFallback = 0
+ }
for _, library := range []*uintptr{&services.gobject, &services.cairo, &services.pangoCairo, &services.pango} {
if *library != 0 {
purego.Dlclose(*library)
@@ -240,6 +264,22 @@ func (services *textServices) closeLibraries() {
}
}
+// fallbackOff is the attribute list that turns Pango's font fallback off, so a glyph laid
+// out under it comes from the description's own family; 0 when Pango cannot build it.
+func (services *textServices) fallbackOff() uintptr {
+ list := services.attrListNew()
+ if list == 0 {
+ return 0
+ }
+ attribute := services.attrFallbackNew(0) // false: no fallback
+ if attribute == 0 {
+ services.attrListUnref(list)
+ return 0
+ }
+ services.attrListInsert(list, attribute) // the list takes the attribute
+ return list
+}
+
func cString(value string) []byte {
result := make([]byte, len(value)+1)
copy(result, value)
@@ -310,23 +350,72 @@ func (services *textServices) configureLayout(value, family string, size, weight
return text.Metrics{}, err
}
defer services.fontFree(description)
+ logical, _, baseline := services.layoutValue(description, value)
+ metrics := text.Metrics{
+ Width: float32(logical.Width) / pangoScale,
+ Height: float32(logical.Height) / pangoScale,
+ Ascent: float32(baseline) / pangoScale,
+ Descent: float32(logical.Height-baseline) / pangoScale,
+ }
+ return metrics, nil
+}
+
+// layoutValue lays value out in the layout under description and reports its logical
+// extents, the ink extents of its glyphs, and its baseline.
+func (services *textServices) layoutValue(description uintptr, value string) (logical, ink pangoRectangle, baseline int32) {
valueBytes := cString(value)
services.layoutSetWidth(services.layout, -1)
services.layoutSetFont(services.layout, description)
services.layoutSetText(services.layout, &valueBytes[0], int32(len(value)))
- var logical pangoRectangle
- services.layoutGetExtents(services.layout, nil, &logical)
- baseline := services.layoutGetBaseline(services.layout)
+ services.layoutGetExtents(services.layout, &ink, &logical)
+ baseline = services.layoutGetBaseline(services.layout)
runtime.KeepAlive(valueBytes)
+ return logical, ink, baseline
+}
+
+// configureMonoLayout is configureLayout for a fixed-pitch face: the cell of "M" and the
+// thicknesses of the strokes the face draws a light and a heavy box drawing line with, in
+// logical pixels. The stroke is the ink width of the stem glyph in the face's own family.
+func (services *textServices) configureMonoLayout(family string, size float32) (text.Metrics, error) {
+ if !validFontSize(size) {
+ return text.Metrics{}, fmt.Errorf("linux: invalid font size")
+ }
+ description, err := services.description(family, size, 0, false)
+ if err != nil {
+ return text.Metrics{}, err
+ }
+ defer services.fontFree(description)
+ logical, _, baseline := services.layoutValue(description, "M")
metrics := text.Metrics{
Width: float32(logical.Width) / pangoScale,
Height: float32(logical.Height) / pangoScale,
Ascent: float32(baseline) / pangoScale,
Descent: float32(logical.Height-baseline) / pangoScale,
}
+ metrics.Rule = services.boxRule(description, "│")
+ metrics.HeavyRule = services.boxRule(description, "┃")
return metrics, nil
}
+// boxRule is the thickness, in logical pixels, of the stroke the face draws value with: the
+// ink width of the glyph in the description's own family, or 0 when that family lacks the
+// glyph. The layout is measured with fallback off, so a family the face does not have is
+// never taken as its weight, and a glyph the face lacks is a missing-glyph box rather than
+// another family's stem.
+func (services *textServices) boxRule(description uintptr, value string) float32 {
+ if services.noFallback == 0 || services.layoutGetUnknownGlyphs == nil {
+ return 0
+ }
+ services.layoutSetAttributes(services.layout, services.noFallback)
+ _, ink, _ := services.layoutValue(description, value)
+ unknown := services.layoutGetUnknownGlyphs(services.layout)
+ services.layoutSetAttributes(services.layout, 0)
+ if unknown > 0 {
+ return 0
+ }
+ return float32(ink.Width) / pangoScale
+}
+
func (services *textServices) measure(value string, size, weight float32) (text.Metrics, error) {
return services.configureLayout(value, proportionalFamily, size, weight, false)
}
diff --git a/internal/platform/linux/text_linux_test.go b/internal/platform/linux/text_linux_test.go
index 6dc18a5c..b3ceb529 100644
--- a/internal/platform/linux/text_linux_test.go
+++ b/internal/platform/linux/text_linux_test.go
@@ -311,3 +311,32 @@ func TestMeasureTextIsWiderAtAHeavierWeight(t *testing.T) {
t.Fatalf("bold %.2f is not wider than regular %.2f", bold.Width, regular.Width)
}
}
+
+// The engine reports the thicknesses the face draws its own box drawing lines with, in
+// logical pixels at the size asked: those are the strokes a cell glyph is drawn with, so box
+// drawing geometry weighs what the font's own glyphs weigh. Pango measures the ink of the
+// light and heavy stems; a face that has no box drawing glyph answers 0 for it, and the
+// canvas then uses the reference thickness.
+func TestTheEngineReportsTheFontsBoxDrawingStrokes(t *testing.T) {
+ engine := sharedTextEngine()
+ cell := engine.MonoCell(13, gfx.MonoSystem)
+ if cell.Advance <= 0 {
+ t.Fatal("no fixed-pitch cell on this runner")
+ }
+ if cell.Rule == 0 {
+ t.Skip("the runner's fixed-pitch face has no light box drawing glyph")
+ }
+ if cell.Rule < 0 || cell.Rule >= cell.Advance {
+ t.Fatalf("light line %v of a cell %v wide, want a positive stroke narrower than the cell", cell.Rule, cell.Advance)
+ }
+ if cell.HeavyRule > 0 && cell.HeavyRule < cell.Rule {
+ t.Fatalf("heavy line %v is thinner than the light one %v", cell.HeavyRule, cell.Rule)
+ }
+ at26 := engine.MonoCell(26, gfx.MonoSystem)
+ if at26.Rule <= cell.Rule {
+ t.Errorf("light line %v at size 26, %v at size 13, want it to follow the size", at26.Rule, cell.Rule)
+ }
+ if cell.HeavyRule > 0 && at26.HeavyRule <= cell.HeavyRule {
+ t.Errorf("heavy line %v at size 26, %v at size 13, want it to follow the size", at26.HeavyRule, cell.HeavyRule)
+ }
+}
diff --git a/internal/platform/linux/window_linux.go b/internal/platform/linux/window_linux.go
index 9972ec2c..f4dbc8b4 100644
--- a/internal/platform/linux/window_linux.go
+++ b/internal/platform/linux/window_linux.go
@@ -44,8 +44,10 @@ type window struct {
renderTried bool
renderErr error
clipboard string
+ clipboardHTML string
clipboardFiles []string
clipboardAtom uint32
+ htmlAtom uint32
utf8Atom uint32
textAtom uint32
targetsAtom uint32
@@ -193,6 +195,7 @@ const (
commandDesktop
commandEnterFullscreen
commandExitFullscreen
+ commandSetSize
)
type nativeWindow struct {
@@ -852,7 +855,7 @@ func (state *linuxWindowState) handleEvent(event unsafe.Pointer) (bool, error) {
case message.Format == 32 && message.Window == win.id && message.Type == win.closeAtom:
return true, nil
case message.Format == 32 && message.Window == win.id && message.Type == win.commandAtom:
- return false, state.command(binary.LittleEndian.Uint32(message.Data[:4]))
+ return false, state.command(binary.LittleEndian.Uint32(message.Data[:4]), message.Data[:])
case message.Format == 32 && message.Window == win.id && message.Type == win.caretAtom:
generation := binary.LittleEndian.Uint32(message.Data[:4])
if frame, changed := textInput.blinkCaret(generation); changed {
@@ -1466,6 +1469,25 @@ func (win *window) postClientMessage(atom, value uint32) {
win.api.flush(win.connection)
}
+func (win *window) postClientSize(width, height uint32) {
+ win.mu.Lock()
+ defer win.mu.Unlock()
+ if win.connection == 0 || win.commandAtom == 0 {
+ return
+ }
+ event := xcbClientEvent{
+ ResponseType: xcbClientMessage,
+ Format: 32,
+ Window: win.id,
+ Type: win.commandAtom,
+ }
+ binary.LittleEndian.PutUint32(event.Data[0:4], commandSetSize)
+ binary.LittleEndian.PutUint32(event.Data[4:8], width)
+ binary.LittleEndian.PutUint32(event.Data[8:12], height)
+ win.api.sendEvent(win.connection, 0, win.id, 0, (*byte)(unsafe.Pointer(&event)))
+ win.api.flush(win.connection)
+}
+
func (win *window) close() {
win.mu.Lock()
defer win.mu.Unlock()
diff --git a/internal/platform/linux/window_size_linux.go b/internal/platform/linux/window_size_linux.go
new file mode 100644
index 00000000..3b89aef7
--- /dev/null
+++ b/internal/platform/linux/window_size_linux.go
@@ -0,0 +1,16 @@
+package linux
+
+import "fmt"
+
+// SetSize posts a logical content-size request to the X11 event loop. The
+// window manager's later ConfigureNotify reports the size that was granted.
+func (window *nativeWindow) SetSize(width, height int) error {
+ if window == nil || window.closed.Load() {
+ return fmt.Errorf("linux: window is closed")
+ }
+ if width < 1 || height < 1 || width > 65535 || height > 65535 {
+ return fmt.Errorf("linux: invalid window size %dx%d", width, height)
+ }
+ window.state.win.postClientSize(uint32(width), uint32(height))
+ return nil
+}
diff --git a/internal/platform/linux/window_size_linux_test.go b/internal/platform/linux/window_size_linux_test.go
new file mode 100644
index 00000000..2aa83379
--- /dev/null
+++ b/internal/platform/linux/window_size_linux_test.go
@@ -0,0 +1,79 @@
+package linux
+
+import (
+ "encoding/binary"
+ "testing"
+ "unsafe"
+)
+
+func TestSetSizeCommandUsesLogicalContentSize(t *testing.T) {
+ var gotWidth, gotHeight uint32
+ var gotMask uint16
+ flushed := false
+ win := &window{
+ id: 7,
+ margin: frameMargin(1.5),
+ api: &xcbAPI{
+ configureWindow: func(_ uintptr, window uint32, mask uint16, values *uint32) xcbVoidCookie {
+ if window != 7 {
+ t.Fatalf("configured window = %d, want 7", window)
+ }
+ gotMask = mask
+ size := unsafe.Slice(values, 2)
+ gotWidth, gotHeight = size[0], size[1]
+ return xcbVoidCookie{}
+ },
+ flush: func(uintptr) int32 {
+ flushed = true
+ return 1
+ },
+ },
+ }
+ win.storeScale(1.5)
+ state := &linuxWindowState{win: win}
+ data := make([]byte, 12)
+ binary.LittleEndian.PutUint32(data[0:4], commandSetSize)
+ binary.LittleEndian.PutUint32(data[4:8], 420)
+ binary.LittleEndian.PutUint32(data[8:12], 300)
+ if err := state.command(commandSetSize, data); err != nil {
+ t.Fatal(err)
+ }
+ margin := 2 * frameMargin(1.5)
+ if want := deviceLength(420, 1.5) + margin; gotWidth != want {
+ t.Fatalf("configured width = %d, want %d", gotWidth, want)
+ }
+ if want := deviceLength(300, 1.5) + margin; gotHeight != want {
+ t.Fatalf("configured height = %d, want %d", gotHeight, want)
+ }
+ if want := uint16(xcbConfigWindowWidth | xcbConfigWindowHeight); gotMask != want {
+ t.Fatalf("configure mask = %#x, want %#x", gotMask, want)
+ }
+ if !flushed {
+ t.Fatal("size request was not flushed")
+ }
+}
+
+func TestSetSizeCommandIgnoresMinimizedWindows(t *testing.T) {
+ configured := false
+ win := &window{
+ id: 7,
+ minimized: true,
+ api: &xcbAPI{
+ configureWindow: func(uintptr, uint32, uint16, *uint32) xcbVoidCookie {
+ configured = true
+ return xcbVoidCookie{}
+ },
+ },
+ }
+ state := &linuxWindowState{win: win}
+ data := make([]byte, 12)
+ binary.LittleEndian.PutUint32(data[0:4], commandSetSize)
+ binary.LittleEndian.PutUint32(data[4:8], 420)
+ binary.LittleEndian.PutUint32(data[8:12], 300)
+ if err := state.command(commandSetSize, data); err != nil {
+ t.Fatal(err)
+ }
+ if configured {
+ t.Fatal("minimized window was resized")
+ }
+}
diff --git a/internal/platform/web/clipboard_async_js.go b/internal/platform/web/clipboard_async_js.go
index fee92881..d1721dcb 100644
--- a/internal/platform/web/clipboard_async_js.go
+++ b/internal/platform/web/clipboard_async_js.go
@@ -85,6 +85,66 @@ func SetClipboardTextAsync(value string, done func(error)) error {
return nil
}
+// SetClipboardHTMLAsync writes the rich copy through one ClipboardItem, the
+// browser's own form of a clipboard entry that carries several flavours at
+// once: text/html beside text/plain, so a rich reader takes the html and a
+// plain one the alt. The browser resolves a Promise; done runs on the event
+// loop, exactly once. The call retains the user gesture, so invoke it from an
+// input handler when the browser asks for permission.
+func SetClipboardHTMLAsync(html, alt string, done func(error)) error {
+ if done == nil {
+ return fmt.Errorf("%w: nil clipboard callback", ErrUnavailable)
+ }
+ navigator := js.Global().Get("navigator")
+ if navigator.Type() != js.TypeObject || navigator.IsNull() {
+ return fmt.Errorf("%w: navigator is missing", ErrUnavailable)
+ }
+ clipboard := navigator.Get("clipboard")
+ if clipboard.Type() != js.TypeObject || clipboard.IsNull() {
+ return fmt.Errorf("%w: navigator.clipboard is missing", ErrUnavailable)
+ }
+ if clipboard.Get("write").Type() != js.TypeFunction {
+ return fmt.Errorf("%w: navigator.clipboard.write is missing", ErrUnavailable)
+ }
+ item := js.Global().Get("ClipboardItem")
+ if item.Type() != js.TypeFunction {
+ return fmt.Errorf("%w: ClipboardItem is missing", ErrUnavailable)
+ }
+ blob := js.Global().Get("Blob")
+ flavours := map[string]any{
+ "text/html": blob.New([]any{html}, map[string]any{"type": "text/html"}),
+ "text/plain": blob.New([]any{alt}, map[string]any{"type": "text/plain"}),
+ }
+ var promise js.Value
+ if err := catchJS("write clipboard html", func() { promise = clipboard.Call("write", js.Global().Get("Array").New(item.New(flavours))) }); err != nil {
+ return err
+ }
+ var release []js.Func
+ finish := func(err error) {
+ for _, function := range release {
+ function.Release()
+ }
+ done(err)
+ }
+ callback := func(handler func([]js.Value) any) js.Func {
+ function := js.FuncOf(func(_ js.Value, args []js.Value) any { return handler(args) })
+ release = append(release, function)
+ return function
+ }
+ fail := callback(func(args []js.Value) any {
+ finish(clipboardError(args))
+ return nil
+ })
+ then := callback(func([]js.Value) any {
+ finish(nil)
+ return nil
+ })
+ if err := catchJS("write clipboard html", func() { promise.Call("then", then, fail) }); err != nil {
+ finish(err)
+ }
+ return nil
+}
+
func clipboardCall(name string, arguments ...any) (js.Value, error) {
navigator := js.Global().Get("navigator")
if navigator.Type() != js.TypeObject || navigator.IsNull() {
diff --git a/internal/platform/web/clipboard_html_js_test.go b/internal/platform/web/clipboard_html_js_test.go
new file mode 100644
index 00000000..f33a0ab1
--- /dev/null
+++ b/internal/platform/web/clipboard_html_js_test.go
@@ -0,0 +1,77 @@
+//go:build js && wasm
+
+package web
+
+import (
+ "errors"
+ "syscall/js"
+ "testing"
+ "time"
+)
+
+// writeClipboardHTML runs the rich copy against the installed stub and waits
+// for the callback.
+func writeClipboardHTML(t *testing.T, html, alt string) error {
+ t.Helper()
+ done := make(chan error, 1)
+ if err := SetClipboardHTMLAsync(html, alt, func(err error) { done <- err }); err != nil {
+ return err
+ }
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(5 * time.Second):
+ t.Fatal("the clipboard write never completed")
+ return nil
+ }
+}
+
+// installClipboardItem gives the runtime under test a ClipboardItem, which the
+// js/wasm test environment itself does not provide, built the way the browser
+// builds one: an object that answers getType with the flavour's Blob.
+func installClipboardItem(t *testing.T) {
+ t.Helper()
+ class := js.Global().Get("Function").New(`
+ return class ClipboardItem {
+ constructor(flavours) { this.flavours = flavours; }
+ get types() { return Object.keys(this.flavours); }
+ getType(type) { return this.flavours[type]; }
+ }`).Invoke()
+ js.Global().Set("ClipboardItem", class)
+}
+
+func TestSetClipboardHTMLAsyncWritesBothFlavours(t *testing.T) {
+ installClipboardItem(t)
+ installClipboard(t, `return {
+ write: items => {
+ const flavours = {};
+ for (const type of items[0].types) {
+ flavours[type] = items[0].getType(type);
+ }
+ globalThis.vivaWritten = flavours;
+ return Promise.resolve();
+ },
+ }`)
+ if err := writeClipboardHTML(t, "加粗", "加粗"); err != nil {
+ t.Fatalf("write = %v", err)
+ }
+ value := js.Global().Get("vivaWritten")
+ if value.Type() != js.TypeObject {
+ t.Fatal("the ClipboardItem was not written")
+ }
+ // Both flavours must be on the one item, as the ClipboardItem spec takes them.
+ if value.Get("text/html").Type() != js.TypeObject || value.Get("text/plain").Type() != js.TypeObject {
+ t.Fatalf("the ClipboardItem carries neither MIME flavour: %v", value)
+ }
+}
+
+func TestSetClipboardHTMLAsyncReportsBrowserRefusal(t *testing.T) {
+ installClipboard(t, `return { write: items => Promise.reject(Object.assign(new Error("no"), { name: "NotAllowedError" })) }`)
+ if err := writeClipboardHTML(t, "x", "x"); !errors.Is(err, ErrPermissionDenied) {
+ t.Fatalf("refused write error = %v", err)
+ }
+ installClipboard(t, `return {}`)
+ if err := writeClipboardHTML(t, "x", "x"); err == nil {
+ t.Fatal("a missing clipboard API reported success")
+ }
+}
diff --git a/internal/platform/web/painter.go b/internal/platform/web/painter.go
index 2da21ae2..7adb0feb 100644
--- a/internal/platform/web/painter.go
+++ b/internal/platform/web/painter.go
@@ -418,6 +418,7 @@ func (p *painter) paint() {
p.canvas.Reset()
focused, visible := p.focus.PaintTarget()
p.canvas.SetInteraction(p.pointer.Hovered(), p.pointer.Pressed(), focused, visible)
+ p.canvas.SetHoveredRole(p.pointer.HoveredRole())
p.canvas.SetScroll(p.scrollX, p.scrollY)
p.canvas.BeginFrame()
p.config.Paint(p.canvas, gfx.Size{Width: p.width, Height: p.height})
@@ -516,7 +517,7 @@ func (p *painter) syncTextInput() {
ID: field.ID, Bounds: field.Bounds, Layer: field.Layer, Multiline: field.Multiline,
ReadOnly: field.ReadOnly, Secure: field.Secure, NavigationKeys: field.NavigationKeys,
Terminal: field.Role == gfx.RoleTerminal, TypeAhead: field.TypeAhead && field.Role != gfx.RoleTerminal,
- TextBounds: text.Bounds, FontSize: text.Size, FontWeight: text.Weight, TextColor: text.Color,
+ TextBounds: text.Bounds, FontSize: text.Size, FontWeight: text.Weight, TextColor: text.Color, Alignment: text.Alignment,
HideNativeOverlays: field.HideNativeOverlays,
}
if field.ReadOnly {
@@ -530,6 +531,9 @@ func (p *painter) syncTextInput() {
}
p.textInputID = field.ID
p.textInput = *input
+ if p.textInput.Alignment == gfx.AlignLeft {
+ p.textInput.Alignment = context.Alignment
+ }
p.textInput.Multiline = field.Multiline
p.textInput.Placed = text.Placement != nil
p.textInput.Terminal = context.Terminal || context.TypeAhead
@@ -647,6 +651,7 @@ func (p *painter) pointerEvent(event gfx.PointerEvent) bool {
// comes up; only hover is gone. The release is delivered outside.
p.pointerOutside = true
p.pointer.SetHovered(gfx.ID{})
+ p.pointer.SetHoveredRole(gfx.Role(0))
if beforeHovered.IsZero() {
return false
}
@@ -693,9 +698,11 @@ func (p *painter) pointerCancel(event gfx.PointerEvent) bool {
// state, the application and the controls' event pass.
func (p *painter) pointerStep(event gfx.PointerEvent) (result gfx.EventResult, focusChanged, placed bool) {
p.pointer.SetHovered(gfx.ID{})
+ p.pointer.SetHoveredRole(gfx.Role(0))
if event.Kind != gfx.PointerExit {
- if target, ok := p.focus.Router().HitTest(event.X, event.Y); ok {
- p.pointer.SetHovered(target)
+ if target, ok := p.focus.Router().HitTestRegion(event.X, event.Y); ok {
+ p.pointer.SetHovered(target.Target)
+ p.pointer.SetHoveredRole(target.Role)
}
}
if event.Kind == gfx.PointerDown {
diff --git a/internal/platform/web/system_js.go b/internal/platform/web/system_js.go
index 9c88a02c..f3315db7 100644
--- a/internal/platform/web/system_js.go
+++ b/internal/platform/web/system_js.go
@@ -28,6 +28,12 @@ func SetClipboardText(string) error {
return fmt.Errorf("%w: Clipboard.writeText returns a Promise", ErrAsyncRequired)
}
+// SetClipboardHTML is the synchronous form of the rich copy: the browser answers
+// it through a Promise, so use SetClipboardHTMLAsync.
+func SetClipboardHTML(html, alt string) error {
+ return fmt.Errorf("%w: Clipboard.write returns a Promise", ErrAsyncRequired)
+}
+
// ClipboardImagePNG cannot answer synchronously: Clipboard.read returns a Promise.
func ClipboardImagePNG() error {
return fmt.Errorf("%w: Clipboard.read returns a Promise", ErrAsyncRequired)
@@ -270,6 +276,7 @@ func cursorCSS(shape uint8) (string, error) {
"nwse-resize",
"nesw-resize",
"not-allowed",
+ "grabbing",
"none",
}
if int(shape) >= len(values) {
diff --git a/internal/platform/web/system_js_test.go b/internal/platform/web/system_js_test.go
index 4a33382b..aa41c427 100644
--- a/internal/platform/web/system_js_test.go
+++ b/internal/platform/web/system_js_test.go
@@ -8,7 +8,7 @@ import (
)
func TestCursorCSS(t *testing.T) {
- want := [...]string{"default", "text", "pointer", "crosshair", "wait", "ew-resize", "ns-resize", "nwse-resize", "nesw-resize", "not-allowed", "none"}
+ want := [...]string{"default", "text", "pointer", "crosshair", "wait", "ew-resize", "ns-resize", "nwse-resize", "nesw-resize", "not-allowed", "grabbing", "none"}
for shape, expected := range want {
actual, err := cursorCSS(uint8(shape))
if err != nil || actual != expected {
diff --git a/internal/platform/web/text_input.go b/internal/platform/web/text_input.go
index 66f20894..3f0bd2d4 100644
--- a/internal/platform/web/text_input.go
+++ b/internal/platform/web/text_input.go
@@ -25,12 +25,13 @@ type TextInputContext struct {
Terminal bool
// TypeAhead reports a hit that takes typed text without being a text field.
TypeAhead bool
- // TextBounds, FontSize, FontWeight and TextColor are the text geometry the component
- // declared; they fill whatever the returned input leaves unset.
+ // TextBounds, FontSize, FontWeight, TextColor and Alignment are the text
+ // geometry the component declared; they fill what the input leaves unset.
TextBounds gfx.Rect
FontSize float32
FontWeight float32
TextColor gfx.Color
+ Alignment gfx.Alignment
HideNativeOverlays bool
}
diff --git a/internal/platform/web_host.go b/internal/platform/web_host.go
index c18c5589..93856527 100644
--- a/internal/platform/web_host.go
+++ b/internal/platform/web_host.go
@@ -15,6 +15,9 @@ import (
// window, or the system's web engine is not installed.
var ErrWebViewUnsupported = errors.New("platform: this window cannot host web pages")
+// WebProxyDirect explicitly bypasses system proxies for one page's network context.
+const WebProxyDirect = "direct"
+
// NativeView is a place in the window that a native view fills.
type NativeView struct {
// ID is the WebView it belongs to (WebViewConfig.ID).
@@ -55,6 +58,7 @@ type WebViewConfig struct {
// Proxy, when set, is the loopback host:port of a SOCKS5 proxy that carries all of the page's
// traffic, names resolved by the proxy. A platform that cannot hold the page to it returns
// ErrWebViewUnsupported rather than opening a page that connects directly.
+ // WebProxyDirect bypasses system proxies instead.
Proxy string
// OnMessage receives what the page posts with window.viva.postMessage(text).
OnMessage func(text string)
diff --git a/internal/platform/windows/clipboard_html_windows.go b/internal/platform/windows/clipboard_html_windows.go
new file mode 100644
index 00000000..c35fdf5a
--- /dev/null
+++ b/internal/platform/windows/clipboard_html_windows.go
@@ -0,0 +1,108 @@
+package windows
+
+import (
+ "encoding/binary"
+ "fmt"
+ "strconv"
+ "sync"
+ "syscall"
+ "unsafe"
+)
+
+// SetClipboardHTML writes html as the clipboard's registered "HTML Format"
+// (CF_HTML) beside alt as CF_UNICODETEXT: a rich reader takes the CF_HTML, a
+// plain one the text, both from one copy. The CF_HTML header carries byte
+// offsets into its own block, which the format pins to ten digits, so its
+// length is the same whatever they say and one pass fixes every offset.
+func SetClipboardHTML(html, alt string) error {
+ if len(html) > maxClipboardBytes {
+ return fmt.Errorf("windows: clipboard html exceeds 64 MiB")
+ }
+ encoded, err := syscall.UTF16FromString(alt)
+ if err != nil {
+ return fmt.Errorf("windows: encode clipboard text: %w", err)
+ }
+ // The block API takes bytes; the UTF-16 the clipboard wants is the same
+ // units little-endian, the encoding the file list's names already use.
+ text := make([]byte, len(encoded)*2)
+ for index, unit := range encoded {
+ binary.LittleEndian.PutUint16(text[index*2:], unit)
+ }
+ block := cfHTML(html)
+ clipboardMu.Lock()
+ defer clipboardMu.Unlock()
+ owner := uintptr(0)
+ if state := active.Load(); state != nil {
+ owner = state.window
+ }
+ if opened, _, callErr := openClipboard.Call(owner); opened == 0 {
+ return windowsCallError("open clipboard", callErr)
+ }
+ defer closeClipboard.Call()
+ if emptied, _, callErr := emptyClipboard.Call(); emptied == 0 {
+ return windowsCallError("empty clipboard", callErr)
+ }
+ if err := setClipboardBlock(clipboardUnicodeText, text); err != nil {
+ return err
+ }
+ return registeredSetClipboardHTML(block)
+}
+
+// registeredSetClipboardHTML puts the CF_HTML block of a rich copy on the open
+// clipboard, the registered "HTML Format" every CF_HTML reader looks for.
+func registeredSetClipboardHTML(block []byte) error {
+ format := registeredHTMLFormat()
+ if format == 0 {
+ return fmt.Errorf("windows: register clipboard format failed")
+ }
+ return setClipboardBlock(format, block)
+}
+
+var htmlFormatOnce sync.Once
+var htmlFormat uintptr
+
+// registeredHTMLFormat is the clipboard format named "HTML Format"; 0 when it
+// cannot be registered.
+func registeredHTMLFormat() uintptr {
+ htmlFormatOnce.Do(func() {
+ name, _ := syscall.UTF16PtrFromString("HTML Format")
+ htmlFormat, _, _ = registerClipboardFormat.Call(uintptr(unsafe.Pointer(name)))
+ })
+ return htmlFormat
+}
+
+// cfHTML builds the CF_HTML block around html: a header of byte offsets and
+// the document they point into, exactly the shape the format's published
+// description builds (the fragment wrapped in body comments, offsets counted
+// from the start of the block, in UTF-8).
+func cfHTML(html string) []byte {
+ const (
+ front = "\r\n"
+ back = "\r\n\r\n"
+ // The header is fixed once its values are ten digits wide; the
+ // numbers only name byte offsets, never change the width.
+ lands = "Version:0.9\r\n" +
+ "StartHTML:0000000000\r\n" +
+ "EndHTML:0000000000\r\n" +
+ "StartFragment:0000000000\r\n" +
+ "EndFragment:0000000000\r\n"
+ )
+ startHTML := len(lands)
+ startFragment := startHTML + len(front)
+ endFragment := startFragment + len(html)
+ endHTML := endFragment + len(back)
+ block := make([]byte, 0, endHTML)
+ block = append(block, "Version:0.9\r\n"...)
+ block = strconv.AppendUint(block, uint64(startHTML), 10)
+ block = append(block, "\r\nEndHTML:"...)
+ block = strconv.AppendUint(block, uint64(endHTML), 10)
+ block = append(block, "\r\nStartFragment:"...)
+ block = strconv.AppendUint(block, uint64(startFragment), 10)
+ block = append(block, "\r\nEndFragment:"...)
+ block = strconv.AppendUint(block, uint64(endFragment), 10)
+ block = append(block, "\r\n"...)
+ block = append(block, front...)
+ block = append(block, html...)
+ block = append(block, back...)
+ return block
+}
diff --git a/internal/platform/windows/clipboard_html_windows_test.go b/internal/platform/windows/clipboard_html_windows_test.go
new file mode 100644
index 00000000..ee4c88d4
--- /dev/null
+++ b/internal/platform/windows/clipboard_html_windows_test.go
@@ -0,0 +1,94 @@
+package windows
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// TestCFHTMLHeaderPointsAtTheDocument pins the CF_HTML block to the shape
+// readers slice the fragment out of: the header's byte offsets are counted from
+// the start of the block, and the fragment they name is the html the caller
+// passed, wrapped the way the format's description wraps it.
+func TestCFHTMLHeaderPointsAtTheDocument(t *testing.T) {
+ const html = `加粗 and text`
+ block := cfHTML(html)
+ whole := string(block)
+ if !strings.HasPrefix(whole, "Version:0.9\r\nStartHTML:") {
+ t.Fatalf("the block does not begin with the header: %q", whole[:40])
+ }
+ field := func(name string) int {
+ t.Helper()
+ mark := strings.Index(whole, name+":")
+ if mark < 0 {
+ t.Fatalf("the header has no %s", name)
+ }
+ end := mark + len(name) + 1
+ value, err := strconv.Atoi(whole[end : end+10])
+ if err != nil {
+ t.Fatalf("%s is not ten digits: %q", name, whole[end:end+10])
+ }
+ return value
+ }
+ startHTML := field("StartHTML")
+ endHTML := field("EndHTML")
+ startFragment := field("StartFragment")
+ endFragment := field("EndFragment")
+ // The header's own length: each line is its field name, ten digits and a
+ // CRLF, so the document starts right behind them.
+ const headerLength = len("Version:0.9\r\n") +
+ len("StartHTML:") + 10 + 2 +
+ len("EndHTML:") + 10 + 2 +
+ len("StartFragment:") + 10 + 2 +
+ len("EndFragment:") + 10 + 2
+ if startHTML != headerLength {
+ t.Fatalf("StartHTML = %d, want the header's own length %d: %q", startHTML, headerLength, whole[:startHTML])
+ }
+ if !strings.HasPrefix(whole[startHTML:], "") {
+ t.Fatalf("the document does not start at StartHTML: %q", whole[startHTML:])
+ }
+ if string(block[startFragment:endFragment]) != html {
+ t.Fatalf("the fragment = %q, want %q", block[startFragment:endFragment], html)
+ }
+ if !strings.HasSuffix(string(block[startHTML:endHTML]), "\r\n") {
+ t.Fatalf("the document does not end at EndHTML: %q", whole[startHTML:endHTML])
+ }
+}
+
+// TestSetClipboardHTMLRoundTrip writes both flavours and has the clipboard read
+// them the way an application pastes: CF_UNICODETEXT as the plain text,
+// "HTML Format" as the CF_HTML. It replaces the clipboard, so it runs only on
+// request, on a machine whose clipboard may be overwritten.
+func TestSetClipboardHTMLRoundTrip(t *testing.T) {
+ if os.Getenv("VIVA_WINDOWS_CLIPBOARD") == "" {
+ t.Skip("set VIVA_WINDOWS_CLIPBOARD=1 to overwrite the real clipboard")
+ }
+ const html = `加粗 and text`
+ const alt = "加粗 and text"
+ if err := SetClipboardHTML(html, alt); err != nil {
+ t.Fatal(err)
+ }
+ text, err := ClipboardText()
+ if err != nil || text != alt {
+ t.Fatalf("plain flavour = %q, %v, want %q", text, err, alt)
+ }
+ if available, _, _ := clipboardFormatAvailable.Call(registeredHTMLFormat()); available == 0 {
+ t.Fatal("the clipboard holds no HTML Format flavour")
+ }
+ block, err := clipboardBytes(registeredHTMLFormat(), maxClipboardBytes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(block), html) {
+ t.Fatalf("the CF_HTML block does not carry the html: %q", block)
+ }
+
+ // Another copy replaces the pair, as every write clears the clipboard.
+ if err := SetClipboardText("plain again"); err != nil {
+ t.Fatal(err)
+ }
+ if available, _, _ := clipboardFormatAvailable.Call(registeredHTMLFormat()); available != 0 {
+ t.Fatal("a plain copy left a rich flavour behind")
+ }
+}
diff --git a/internal/platform/windows/cursor_image_windows.go b/internal/platform/windows/cursor_image_windows.go
index 59877358..37e9d222 100644
--- a/internal/platform/windows/cursor_image_windows.go
+++ b/internal/platform/windows/cursor_image_windows.go
@@ -58,6 +58,88 @@ func retireCustomCursor(handle uintptr, image *gfx.CursorImage, scale float32) {
customCursors.image, customCursors.scale = image, scale
}
+// grabbingCursorImage draws the closed-hand cursor that Win32 does not expose as
+// a stock shape. Its 16x16 alpha bitmap is built once, on the first request, and
+// the HCURSOR made of it is kept for the process: stock cursors are shared by
+// the system and never destroyed, so a preset recreated per request would leak
+// one per change.
+var grabbingCursor = struct {
+ once sync.Once
+ image *gfx.CursorImage
+ handle uintptr
+ failure error
+}{}
+
+func grabbingCursorHandle() (uintptr, error) {
+ grabbingCursor.once.Do(func() {
+ image, err := gfx.NewCursorImage(16, 16, 6, 3, 1, grabbingCursorPixels())
+ if err != nil {
+ grabbingCursor.failure = err
+ return
+ }
+ handle, err := newCursorHandle(image, 1)
+ if err != nil {
+ grabbingCursor.failure = err
+ return
+ }
+ grabbingCursor.image, grabbingCursor.handle = image, handle
+ })
+ return grabbingCursor.handle, grabbingCursor.failure
+}
+
+// closeGrabbingCursor destroys the cached cursor as the process exits its last
+// window; the stock shapes need nothing.
+func closeGrabbingCursor() {
+ if handle := grabbingCursor.handle; handle != 0 {
+ grabbingCursor.handle = 0
+ destroyIcon.Call(handle)
+ }
+}
+
+func grabbingCursorPixels() []byte {
+ pixels := make([]byte, 16*16*4)
+ inside := func(x, y int) bool {
+ if x < 0 || x >= 16 || y < 0 || y >= 16 {
+ return false
+ }
+ switch {
+ case y == 2 && x >= 5 && x <= 6: // index finger
+ return true
+ case y == 3 && x >= 4 && x <= 7:
+ return true
+ case y >= 4 && y <= 5 && x >= 4 && x <= 7:
+ return true
+ case y >= 6 && y <= 8 && x >= 3 && x <= 12:
+ return true
+ case y >= 9 && y <= 10 && x >= 4 && x <= 12:
+ return true
+ case y == 11 && x >= 5 && x <= 11:
+ return true
+ case y >= 12 && y <= 13 && x >= 6 && x <= 11:
+ return true
+ }
+ return false
+ }
+ set := func(x, y int, shade byte) {
+ at := (y*16 + x) * 4
+ pixels[at], pixels[at+1], pixels[at+2], pixels[at+3] = shade, shade, shade, 255
+ }
+ for y := 16; y > 0; {
+ y--
+ for x := 16; x > 0; {
+ x--
+ if inside(x, y) {
+ set(x, y, 255)
+ continue
+ }
+ if inside(x-1, y) || inside(x+1, y) || inside(x, y-1) || inside(x, y+1) {
+ set(x, y, 0)
+ }
+ }
+ }
+ return pixels
+}
+
// newCursorHandle makes an HCURSOR of the picture with its hotspot. A 32-bit cursor bitmap is
// read as plain ARGB, the color not multiplied by alpha, which the cursor tests read back
// from Windows itself.
diff --git a/internal/platform/windows/cursor_windows_test.go b/internal/platform/windows/cursor_windows_test.go
index d6b5d34d..ec8eef34 100644
--- a/internal/platform/windows/cursor_windows_test.go
+++ b/internal/platform/windows/cursor_windows_test.go
@@ -20,3 +20,29 @@ func TestCursorShapesIncludeHidden(t *testing.T) {
t.Fatal("a shape past the last one was accepted")
}
}
+
+// The closed-hand cursor this package draws itself is made once and shared, as
+// the stock cursors are: asking twice must not grow a second one.
+func TestGrabbingCursorIsMadeOnce(t *testing.T) {
+ first, err := grabbingCursorHandle()
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := grabbingCursorHandle()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first == 0 || first != second {
+ t.Fatalf("grabbing cursor handles = %d then %d, want one shared handle", first, second)
+ }
+ // closeGrabbingCursor destroys the handle; a later call makes a fresh one.
+ closeGrabbingCursor()
+ third, err := grabbingCursorHandle()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if third == 0 || third == first {
+ t.Fatalf("re-made grabbing cursor = %d, want a fresh handle after the close", third)
+ }
+ closeGrabbingCursor()
+}
diff --git a/internal/platform/windows/system_windows.go b/internal/platform/windows/system_windows.go
index e5af5c77..710e13dc 100644
--- a/internal/platform/windows/system_windows.go
+++ b/internal/platform/windows/system_windows.go
@@ -734,6 +734,9 @@ func cursorHandle(shape uint8) (uintptr, error) {
if shape == cursorHidden {
return hiddenCursor()
}
+ if shape == 10 { // app.CursorGrabbing, kept numeric so backend shape IDs stay thin.
+ return grabbingCursorHandle()
+ }
identifiers := [...]uintptr{32512, 32513, 32649, 32515, 32514, 32644, 32645, 32642, 32643, 32648}
if int(shape) >= len(identifiers) {
return 0, fmt.Errorf("windows: invalid cursor %d", shape)
@@ -989,7 +992,7 @@ func notificationIcon(host uintptr) uintptr {
}
// cursorHidden is the shape a surface that draws its own pointer asks for.
-const cursorHidden uint8 = 10
+const cursorHidden uint8 = 11
var createCursor = user.NewProc("CreateCursor")
diff --git a/internal/platform/windows/text_box_windows_test.go b/internal/platform/windows/text_box_windows_test.go
new file mode 100644
index 00000000..eb5a9ab7
--- /dev/null
+++ b/internal/platform/windows/text_box_windows_test.go
@@ -0,0 +1,44 @@
+package windows
+
+import (
+ "testing"
+
+ "github.com/vivaui/viva/gfx"
+)
+
+// A cell carries the thicknesses the face draws its own box drawing lines with
+// (gfx.MonoCellMetrics.Rule and HeavyRule), so a border drawn as geometry weighs what the
+// font's own glyphs weigh. DirectWrite reports each glyph's bearings in design units; the
+// engine scales the ink between them to the logical size asked.
+func TestTheEngineReportsTheFontsBoxDrawingStrokes(t *testing.T) {
+ engine, err := theTextEngine()
+ if err != nil {
+ t.Skipf("no DirectWrite text engine: %v", err)
+ }
+ reported := 0
+ for _, name := range []string{"Consolas", "Cascadia Mono", "Courier New"} {
+ face := gfx.MonoFamily(name)
+ cell := engine.MonoCell(13, face)
+ if cell.Advance <= 0 {
+ continue // the runner does not have this family
+ }
+ if cell.Rule <= 0 || cell.Rule >= cell.Advance {
+ t.Errorf("%s: light line %v of a cell %v wide, want a positive stroke narrower than the cell", name, cell.Rule, cell.Advance)
+ continue
+ }
+ if cell.HeavyRule > 0 && cell.HeavyRule < cell.Rule {
+ t.Errorf("%s: heavy line %v is thinner than the light one %v", name, cell.HeavyRule, cell.Rule)
+ }
+ reported++
+ at26 := engine.MonoCell(26, face)
+ if at26.Rule <= cell.Rule {
+ t.Errorf("%s: light line %v at size 26, %v at size 13, want it to follow the size", name, at26.Rule, cell.Rule)
+ }
+ if cell.HeavyRule > 0 && at26.HeavyRule <= cell.HeavyRule {
+ t.Errorf("%s: heavy line %v at size 26, %v at size 13, want it to follow the size", name, at26.HeavyRule, cell.HeavyRule)
+ }
+ }
+ if reported == 0 {
+ t.Fatal("no installed fixed-pitch face reported its box drawing strokes")
+ }
+}
diff --git a/internal/platform/windows/text_windows.go b/internal/platform/windows/text_windows.go
index 606a9455..cf931fe6 100644
--- a/internal/platform/windows/text_windows.go
+++ b/internal/platform/windows/text_windows.go
@@ -472,3 +472,118 @@ func (services *textServices) measureIn(typeface, value string, size, weight flo
line := lines[0]
return text.Metrics{Width: metrics.WidthTrailing, Height: metrics.Height, Ascent: line.Baseline, Descent: line.Height - line.Baseline}, nil
}
+
+// Method slots of IDWriteFactory, IDWriteFontCollection, IDWriteFontFamily, IDWriteFont
+// and IDWriteFontFace (checked against dwrite.h).
+const (
+ writeFactoryGetSystemFontCollection = 3
+ writeCollectionGetFontFamily = 4
+ writeCollectionFindFamilyName = 5
+ writeFontFamilyGetFirstMatchingFont = 7
+ writeFontGetMetrics = 11
+ writeFontCreateFontFace = 13
+ writeFontFaceGetDesignGlyphMetrics = 10
+ writeFontFaceGetGlyphIndices = 11
+)
+
+// writeFontMetrics is DWRITE_FONT_METRICS: the face's design units per em and the
+// thicknesses and positions the font draws its rules with.
+type writeFontMetrics struct {
+ DesignUnitsPerEm, Ascent, Descent uint16
+ LineGap int16
+ CapHeight, XHeight uint16
+ UnderlinePosition int16
+ UnderlineThickness uint16
+ StrikethroughPosition int16
+ StrikethroughThickness uint16
+}
+
+// writeGlyphMetrics is DWRITE_GLYPH_METRICS: a glyph's advance and the bearings around
+// its ink, in design units.
+type writeGlyphMetrics struct {
+ LeftSideBearing int32
+ AdvanceWidth uint32
+ RightSideBearing int32
+ TopSideBearing int32
+ AdvanceHeight uint32
+ BottomSideBearing int32
+ VerticalOriginY int32
+}
+
+// boxStrokes reports the thicknesses, in logical pixels, of the strokes typeface's face at
+// weight draws a light (U+2502) and a heavy (U+2503) box drawing line with: the ink width of
+// each glyph, or 0 when DirectWrite cannot report it or the face lacks the glyph.
+func (services *textServices) boxStrokes(typeface string, size, weight float32) (light, heavy float32) {
+ if size <= 0 || typeface == "" {
+ return 0, 0
+ }
+ familyName, err := syscall.UTF16PtrFromString(typeface)
+ if err != nil {
+ return 0, 0
+ }
+ // The collection every text format is made from: the application's fonts and the
+ // system's, or the system's alone. A registered collection is held by the process
+ // and must outlive the use; its read lock keeps it from being released meanwhile.
+ customFonts.RLock()
+ collection, owned := customFonts.collection, false
+ if collection == 0 {
+ customFonts.RUnlock()
+ status := comCall(services.write, writeFactoryGetSystemFontCollection, uintptr(unsafe.Pointer(&collection)), 0)
+ if err = hresult(status); err != nil || collection == 0 {
+ return 0, 0
+ }
+ owned = true
+ } else {
+ defer customFonts.RUnlock()
+ }
+ if owned {
+ defer release(&collection)
+ }
+ var index, exists uint32
+ if err := hresult(comCall(collection, writeCollectionFindFamilyName, uintptr(unsafe.Pointer(familyName)), uintptr(unsafe.Pointer(&index)), uintptr(unsafe.Pointer(&exists)))); err != nil || exists == 0 {
+ return 0, 0
+ }
+ var family, font uintptr
+ if err := hresult(comCall(collection, writeCollectionGetFontFamily, uintptr(index), uintptr(unsafe.Pointer(&family)))); err != nil {
+ return 0, 0
+ }
+ defer release(&family)
+ const (
+ stretchNormal = 5
+ styleNormal = 0
+ )
+ if err := hresult(comCall(family, writeFontFamilyGetFirstMatchingFont, uintptr(nativeFontWeight(weight)), stretchNormal, styleNormal, uintptr(unsafe.Pointer(&font)))); err != nil {
+ return 0, 0
+ }
+ defer release(&font)
+ var fontMetrics writeFontMetrics
+ if err := hresult(comCall(font, writeFontGetMetrics, uintptr(unsafe.Pointer(&fontMetrics)))); err != nil || fontMetrics.DesignUnitsPerEm == 0 {
+ return 0, 0
+ }
+ var face uintptr
+ if err := hresult(comCall(font, writeFontCreateFontFace, uintptr(unsafe.Pointer(&face)))); err != nil || face == 0 {
+ return 0, 0
+ }
+ defer release(&face)
+ codepoints := [2]uint32{0x2502, 0x2503} // │ ┃
+ var glyphs [2]uint16
+ if err := hresult(comCall(face, writeFontFaceGetGlyphIndices, uintptr(unsafe.Pointer(&codepoints[0])), uintptr(len(codepoints)), uintptr(unsafe.Pointer(&glyphs[0])))); err != nil {
+ return 0, 0
+ }
+ var glyphMetrics [2]writeGlyphMetrics
+ if err := hresult(comCall(face, writeFontFaceGetDesignGlyphMetrics, uintptr(unsafe.Pointer(&glyphs[0])), uintptr(len(glyphs)), uintptr(unsafe.Pointer(&glyphMetrics[0])), 0)); err != nil {
+ return 0, 0
+ }
+ em := float32(fontMetrics.DesignUnitsPerEm)
+ stroke := func(glyph uint16, metrics writeGlyphMetrics) float32 {
+ if glyph == 0 {
+ return 0
+ }
+ ink := int32(metrics.AdvanceWidth) - metrics.LeftSideBearing - metrics.RightSideBearing
+ if ink <= 0 {
+ return 0
+ }
+ return float32(ink) / em * size
+ }
+ return stroke(glyphs[0], glyphMetrics[0]), stroke(glyphs[1], glyphMetrics[1])
+}
diff --git a/internal/platform/windows/textengine_windows.go b/internal/platform/windows/textengine_windows.go
index f82fb6fc..d8079cd1 100644
--- a/internal/platform/windows/textengine_windows.go
+++ b/internal/platform/windows/textengine_windows.go
@@ -181,15 +181,19 @@ func (engine *textEngine) SetFontFamily(family string) {
func (engine *textEngine) MonoCell(size float32, face gfx.MonoFace) gfx.MonoCellMetrics {
engine.mu.Lock()
defer engine.mu.Unlock()
- metrics, err := engine.services.measureIn(monoFamily(face), "M", size, 0)
+ family := monoFamily(face)
+ metrics, err := engine.services.measureIn(family, "M", size, 0)
if err != nil {
return gfx.MonoCellMetrics{}
}
+ rule, heavy := engine.services.boxStrokes(family, size, 0)
return gfx.MonoCellMetrics{
- Advance: metrics.Width,
- Ascent: metrics.Ascent,
- Descent: metrics.Descent,
- LineGap: max(metrics.Height-metrics.Ascent-metrics.Descent, 0),
+ Advance: metrics.Width,
+ Ascent: metrics.Ascent,
+ Descent: metrics.Descent,
+ LineGap: max(metrics.Height-metrics.Ascent-metrics.Descent, 0),
+ Rule: rule,
+ HeavyRule: heavy,
}
}
diff --git a/internal/platform/windows/web_proxy_windows_test.go b/internal/platform/windows/web_proxy_windows_test.go
index 8d8bfeb3..1268a22e 100644
--- a/internal/platform/windows/web_proxy_windows_test.go
+++ b/internal/platform/windows/web_proxy_windows_test.go
@@ -8,6 +8,13 @@ import (
"testing"
)
+func TestDirectBrowserArgumentsBypassSystemProxy(t *testing.T) {
+ got := proxyBrowserArguments("--lang=en", "direct")
+ if got != "--lang=en --no-proxy-server" {
+ t.Fatalf("direct browser arguments = %q", got)
+ }
+}
+
func TestProxyBrowserArgumentsKeepThePageOnTheProxy(t *testing.T) {
got := proxyBrowserArguments("", "127.0.0.1:1080")
for _, want := range []string{
diff --git a/internal/platform/windows/webview2_windows.go b/internal/platform/windows/webview2_windows.go
index eb458098..8f188896 100644
--- a/internal/platform/windows/webview2_windows.go
+++ b/internal/platform/windows/webview2_windows.go
@@ -11,6 +11,8 @@ import (
"sync"
"syscall"
"unsafe"
+
+ "github.com/vivaui/viva/internal/platform"
)
// WebView2 without WebView2Loader.dll: the loader's job is to find the installed runtime and call
@@ -329,6 +331,9 @@ func proxyBrowserArguments(existing, proxy string) string {
" --proxy-bypass-list=<-loopback>" +
` "--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ` + proxyHost(proxy) + `"` +
" --force-webrtc-ip-handling-policy=disable_non_proxied_udp"
+ if proxy == platform.WebProxyDirect {
+ arguments = "--no-proxy-server"
+ }
if existing = strings.TrimSpace(existing); existing != "" {
return existing + " " + arguments
}
diff --git a/internal/platform/windows/window_size_windows.go b/internal/platform/windows/window_size_windows.go
new file mode 100644
index 00000000..34588c19
--- /dev/null
+++ b/internal/platform/windows/window_size_windows.go
@@ -0,0 +1,64 @@
+package windows
+
+import (
+ "fmt"
+ "math"
+ "unsafe"
+
+ "github.com/vivaui/viva/internal/platform"
+)
+
+// SetSize queues a content-size request on the window thread. It reports only
+// whether the request was accepted; the resulting WM_SIZE is authoritative.
+func (window *nativeWindow) SetSize(width, height int) error {
+ if window == nil || window.closed.Load() {
+ return fmt.Errorf("windows: window is closed")
+ }
+ if width < 1 || height < 1 || width > 65535 || height > 65535 {
+ return fmt.Errorf("windows: invalid window size %dx%d", width, height)
+ }
+ packed := uintptr(uint16(width)) | uintptr(uint16(height))<<16
+ if ok, _, err := postMessage.Call(window.state.window, wmSetSize, packed, 0); ok == 0 {
+ return fmt.Errorf("windows: request window size: %w", err)
+ }
+ return nil
+}
+
+func (state *windowState) resizeClient(width, height int) {
+ if state.window == 0 || width < 1 || height < 1 || state.maximized || state.minimized || state.fullscreen.Load() {
+ return
+ }
+ scale := state.scale
+ if scale <= 0 {
+ scale = 1
+ }
+ bounds := rect{
+ Right: int32(math.Round(float64(width) * float64(scale))),
+ Bottom: int32(math.Round(float64(height) * float64(scale))),
+ }
+ style := uintptr(wsOverlappedWindow)
+ if state.config.WindowMode == platform.WindowTitled {
+ if adjustWindow.Find() == nil {
+ adjustWindow.Call(uintptr(unsafe.Pointer(&bounds)), style, 0, 0, uintptr(scale*96))
+ } else {
+ adjustWindowLegacy.Call(uintptr(unsafe.Pointer(&bounds)), style, 0, 0)
+ }
+ }
+ minimum := state.minimumTrackSize()
+ w, h := max(minimum.X, bounds.Right-bounds.Left), max(minimum.Y, bounds.Bottom-bounds.Top)
+ const noZOrder, noActivate, noMove = 0x4, 0x10, 0x2
+ flags := uintptr(noZOrder | noActivate | noMove)
+ var x, y int32
+ monitor, _, _ := monitorFromWindow.Call(state.window, monitorNearest)
+ info := monitorInfo{Size: uint32(unsafe.Sizeof(monitorInfo{}))}
+ var current rect
+ if ok, _, _ := getMonitorInfo.Call(monitor, uintptr(unsafe.Pointer(&info))); ok != 0 {
+ if ok, _, _ := getWindowRect.Call(state.window, uintptr(unsafe.Pointer(¤t))); ok != 0 {
+ w, h = min(w, info.Work.Right-info.Work.Left), min(h, info.Work.Bottom-info.Work.Top)
+ x = min(max(current.Left, info.Work.Left), info.Work.Right-w)
+ y = min(max(current.Top, info.Work.Top), info.Work.Bottom-h)
+ flags &^= noMove
+ }
+ }
+ setWindowPosition.Call(state.window, 0, uintptr(x), uintptr(y), uintptr(w), uintptr(h), flags)
+}
diff --git a/internal/platform/windows/window_windows.go b/internal/platform/windows/window_windows.go
index 9b98fa65..637595ea 100644
--- a/internal/platform/windows/window_windows.go
+++ b/internal/platform/windows/window_windows.go
@@ -63,6 +63,7 @@ const (
wmAllowClose = 32769
wmApplyCursor = 32770
wmRedraw = 32771
+ wmSetSize = 0x8052
wsOverlappedWindow = 0x00cf0000
)
@@ -412,6 +413,9 @@ func (application *windowsApplication) closeAll() {
}
return true
})
+ // The one cursor this package made — the closed hand Win32 does not ship —
+ // is destroyed once no window can still be showing it.
+ closeGrabbingCursor()
}
func windowProc(window, kind, first, second uintptr) (result uintptr) {
@@ -444,6 +448,9 @@ func windowProc(window, kind, first, second uintptr) (result uintptr) {
return 1
}
return 0
+ case wmSetSize:
+ state.resizeClient(int(first&0xffff), int((first>>16)&0xffff))
+ return 0
case wmFullscreen:
state.setFullscreen(first != 0)
return 0
diff --git a/internal/reloadwire/codec.go b/internal/reloadwire/codec.go
index 943bbd1b..a8cebf45 100644
--- a/internal/reloadwire/codec.go
+++ b/internal/reloadwire/codec.go
@@ -9,7 +9,7 @@ import (
)
const (
- ProtocolVersion = 27
+ ProtocolVersion = 28
MaxFrameBytes = 32 << 20
MaxStateBytes = 8 << 20
maxItems = 200_000
@@ -45,19 +45,21 @@ type WindowConfig struct {
WindowMode uint8
// TitlebarHeight is Config.TitlebarHeight rounded to whole logical pixels.
TitlebarHeight uint16
- ClearRed float64
- ClearGreen float64
- ClearBlue float64
- ClearAlpha float64
- HasClick bool
- HasPointer bool
- HasKey bool
- HasShortcut bool
- HasScroll bool
- HasModifierTap bool
- HasClose bool
- HasSnapshot bool
- HasRestore bool
+ // TrafficLightScale is Config.TrafficLightScale, or zero for the system size.
+ TrafficLightScale float32
+ ClearRed float64
+ ClearGreen float64
+ ClearBlue float64
+ ClearAlpha float64
+ HasClick bool
+ HasPointer bool
+ HasKey bool
+ HasShortcut bool
+ HasScroll bool
+ HasModifierTap bool
+ HasClose bool
+ HasSnapshot bool
+ HasRestore bool
}
type Hello struct {
@@ -274,6 +276,7 @@ func encodeWindowConfig(result *encoder, value WindowConfig) {
result.u32(uint32(value.MinHeight))
result.u8(value.WindowMode)
result.u16(value.TitlebarHeight)
+ result.f32(value.TrafficLightScale)
result.f64(value.ClearRed)
result.f64(value.ClearGreen)
result.f64(value.ClearBlue)
@@ -293,7 +296,8 @@ func decodeWindowConfig(value *decoder) WindowConfig {
return WindowConfig{
Title: value.string(), Width: int(value.u32()), Height: int(value.u32()),
MinWidth: int(value.u32()), MinHeight: int(value.u32()), WindowMode: value.u8(), TitlebarHeight: value.u16(),
- ClearRed: value.f64(), ClearGreen: value.f64(), ClearBlue: value.f64(), ClearAlpha: value.f64(),
+ TrafficLightScale: value.f32(),
+ ClearRed: value.f64(), ClearGreen: value.f64(), ClearBlue: value.f64(), ClearAlpha: value.f64(),
HasClick: value.bool(), HasPointer: value.bool(), HasKey: value.bool(), HasShortcut: value.bool(), HasScroll: value.bool(), HasModifierTap: value.bool(),
HasClose: value.bool(), HasSnapshot: value.bool(), HasRestore: value.bool(),
}
diff --git a/internal/reloadwire/input_test.go b/internal/reloadwire/input_test.go
index 9bf17d2a..fbafab7f 100644
--- a/internal/reloadwire/input_test.go
+++ b/internal/reloadwire/input_test.go
@@ -180,7 +180,7 @@ func decodeResultError(data []byte) error { _, err := decodeEventResult(data);
func TestWindowConfigCodecPreservesInputCapabilities(t *testing.T) {
want := Hello{Token: "capabilities", Config: WindowConfig{
- Title: "input", Width: 640, Height: 480, WindowMode: 2, TitlebarHeight: 36,
+ Title: "input", Width: 640, Height: 480, WindowMode: 2, TitlebarHeight: 36, TrafficLightScale: 0.9,
MinWidth: 480, MinHeight: 320,
HasClick: true, HasPointer: true, HasKey: true, HasShortcut: true, HasScroll: true, HasModifierTap: true,
HasClose: true, HasSnapshot: true, HasRestore: true,
diff --git a/internal/textmetrics/textmetrics.go b/internal/textmetrics/textmetrics.go
index a5a8e866..682a4a0d 100644
--- a/internal/textmetrics/textmetrics.go
+++ b/internal/textmetrics/textmetrics.go
@@ -53,6 +53,12 @@ const (
const (
MonoAdvance = 0.6
MonoLine = 1.18
+ // MonoRule is the reference thickness of the stroke a fixed-pitch face draws a light
+ // box drawing line with, in units of the font size: the rule a cell glyph is drawn
+ // with when the text engine does not report the face's own stroke. A fixed-pitch face
+ // draws its box lines at 5 to 15 percent of the size; the reference takes the low end,
+ // and a heavy stroke is twice that.
+ MonoRule = 0.05
)
// weightIndex selects the table for a Viva weight (gfx.TextStyle.Weight: 0 regular,
diff --git a/text/measure.go b/text/measure.go
index 5cbc64b7..d23152a1 100644
--- a/text/measure.go
+++ b/text/measure.go
@@ -7,6 +7,11 @@ type Metrics struct {
Ascent float32
Descent float32
Leading float32
+ // Rule and HeavyRule are the thicknesses, in logical pixels, of the strokes the font
+ // draws with a light and a heavy box drawing line: the weights a fixed-pitch cell
+ // glyph's strokes take. Zero where the platform's measurement does not include them.
+ Rule float32
+ HeavyRule float32
}
// MeasureFunc shapes and measures one line at a logical font size and weight
diff --git a/tools/cellatlas/draw.go b/tools/cellatlas/draw.go
index 8db1eba9..f57da6bb 100644
--- a/tools/cellatlas/draw.go
+++ b/tools/cellatlas/draw.go
@@ -57,8 +57,9 @@ func (s *sprite) drawAt(index int) {
}
// shapes is what gfx draws for r in a cell of the atlas's size: device pixels from the cell's
-// corner. The rule a cell glyph is drawn with is 5% of the text's size until a text engine reports
-// the underline thickness (see gfx cellLine), so the atlas's rule asks for the size that gives it.
+// corner. A cell glyph's rule is the stroke its face draws a light box drawing line with, and
+// the reference 5% of the text's size until a text engine measures the face (see gfx cellLine),
+// so the atlas's rule asks for the size that gives it.
func (s *sprite) shapes(r rune) gfx.CellGlyphShapes {
value := string(r)
bounds := gfx.Rect{Width: float32(s.atlas.width), Height: float32(s.atlas.height)}
diff --git a/tools/manifest/manifest.go b/tools/manifest/manifest.go
index a3f01ea6..9c016d7a 100644
--- a/tools/manifest/manifest.go
+++ b/tools/manifest/manifest.go
@@ -166,6 +166,10 @@ type Linux struct {
Homepage string
// Depends are the .deb's dependencies, as in Debian's Depends field.
Depends []string
+ // ArchDepends are the Arch packages the PKGBUILD depends on. They are
+ // written separately because Debian and Arch name the same library
+ // differently, and a Debian name in an Arch package cannot be resolved.
+ ArchDepends []string
// Keywords help the desktop search find the menu entry.
Keywords []string
}
@@ -258,12 +262,13 @@ type document struct {
URL string `toml:"url"`
} `toml:"windows"`
Linux struct {
- Category string `toml:"category"`
- Comment string `toml:"comment"`
- Maintainer string `toml:"maintainer"`
- Homepage string `toml:"homepage"`
- Depends []string `toml:"depends"`
- Keywords []string `toml:"keywords"`
+ Category string `toml:"category"`
+ Comment string `toml:"comment"`
+ Maintainer string `toml:"maintainer"`
+ Homepage string `toml:"homepage"`
+ Depends []string `toml:"depends"`
+ ArchDepends []string `toml:"arch_depends"`
+ Keywords []string `toml:"keywords"`
} `toml:"linux"`
}
@@ -276,7 +281,7 @@ var knownKeys = map[string][]string{
"mac": {"category", "copyright", "dmg", "entitlements", "info", "min_system"},
"mac.dmg": {"app_position", "applications_position", "background", "icon_size"},
"windows": {"publisher", "scope", "url"},
- "linux": {"category", "comment", "depends", "homepage", "keywords", "maintainer"},
+ "linux": {"arch_depends", "category", "comment", "depends", "homepage", "keywords", "maintainer"},
}
// Parse decodes the text of a viva.toml. name labels errors and dir becomes
@@ -325,7 +330,8 @@ func Parse(name, dir, source string) (*Manifest, error) {
manifest.Windows = Windows{Publisher: doc.Windows.Publisher, Scope: doc.Windows.Scope, URL: doc.Windows.URL}
manifest.Linux = Linux{
Category: doc.Linux.Category, Comment: doc.Linux.Comment, Maintainer: doc.Linux.Maintainer,
- Homepage: doc.Linux.Homepage, Depends: doc.Linux.Depends, Keywords: doc.Linux.Keywords,
+ Homepage: doc.Linux.Homepage, Depends: doc.Linux.Depends, ArchDepends: doc.Linux.ArchDepends,
+ Keywords: doc.Linux.Keywords,
}
sort.Slice(manifest.Mac.Info, func(i, j int) bool { return manifest.Mac.Info[i].Key < manifest.Mac.Info[j].Key })
diff --git a/tools/manifest/manifest_deb.go b/tools/manifest/manifest_deb.go
index ffba5108..04cca41f 100644
--- a/tools/manifest/manifest_deb.go
+++ b/tools/manifest/manifest_deb.go
@@ -24,6 +24,9 @@ var (
// relations in parentheses, architecture and alternatives (|). dpkg has the
// final say when the package is installed.
dependsPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+.:()<>=~| -]*$`)
+ // archDependsPattern accepts an Arch dependency: a package name and an
+ // optional version relation, such as vulkan-icd-loader or libxcb>=1.16.
+ archDependsPattern = regexp.MustCompile(`^[a-z0-9@._+][a-z0-9@._+-]*( ?(>=|<=|=|>|<) ?[A-Za-z0-9._+:-]+)?$`)
)
// LinuxCategory is the category the menu entry is filed under.
@@ -64,6 +67,11 @@ func (manifest *Manifest) validateLinux() error {
return fail("depends", "%q is not a Debian dependency such as libfoo1 (>= 1.2)", dependency)
}
}
+ for _, dependency := range linux.ArchDepends {
+ if !archDependsPattern.MatchString(dependency) || strings.TrimSpace(dependency) != dependency {
+ return fail("arch_depends", "%q is not an Arch dependency such as libxcb or libxcb>=1.16", dependency)
+ }
+ }
for _, keyword := range linux.Keywords {
if keyword == "" || strings.Contains(keyword, ";") || hasControl(keyword) {
return fail("keywords", "%q must be non-empty and cannot contain ; or control characters", keyword)
diff --git a/tools/patchgen/patchgen.go b/tools/patchgen/patchgen.go
new file mode 100644
index 00000000..f4216857
--- /dev/null
+++ b/tools/patchgen/patchgen.go
@@ -0,0 +1,410 @@
+// Package patchgen writes a delta update between two released payloads: the
+// installed .app bundle on macOS, or the executable a Linux tar.gz installs.
+//
+// It is a release tool: it imports the bsdiff encoder, which needs a suffix
+// array and the whole payload in memory. The update package, which runs in the
+// user's application, only applies patches.
+package patchgen
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/gabstv/go-bsdiff/pkg/bsdiff"
+ "github.com/vivaui/viva/update"
+)
+
+// Request is one patch to write.
+type Request struct {
+ App string
+ FromVersion string
+ ToVersion string
+ OS string
+ Arch string
+ // OldArchive and NewArchive are the released archives: the previous
+ // version and the one the feed lists.
+ OldArchive string
+ NewArchive string
+ // Executable is the program's file name in a Linux archive. Empty finds
+ // the only executable in it.
+ Executable string
+ // Output receives the gzip tar patch.
+ Output io.Writer
+}
+
+// Write makes the patch and returns its manifest.
+func Write(request Request) (update.PatchManifest, error) {
+ if request.App == "" || request.FromVersion == "" || request.ToVersion == "" {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: the application and both versions are required")
+ }
+ if request.OS != "darwin" && request.OS != "linux" {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: patches are made for darwin and linux, not %q", request.OS)
+ }
+ if request.Arch != "amd64" && request.Arch != "arm64" {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: architecture %q must be amd64 or arm64", request.Arch)
+ }
+ oldPayload, oldKind, cleanupOld, err := extractPayload(request.OldArchive, request.OS, request.Arch, request.Executable)
+ if err != nil {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: old release: %w", err)
+ }
+ defer cleanupOld()
+ newPayload, newKind, cleanupNew, err := extractPayload(request.NewArchive, request.OS, request.Arch, request.Executable)
+ if err != nil {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: new release: %w", err)
+ }
+ defer cleanupNew()
+ if oldKind != newKind {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: the two archives hold different payloads (%s and %s)", oldKind, newKind)
+ }
+ manifest := update.PatchManifest{
+ Schema: update.PatchSchema, App: request.App,
+ FromVersion: request.FromVersion, ToVersion: request.ToVersion,
+ OS: request.OS, Arch: request.Arch, Payload: newKind,
+ }
+
+ oldFiles, err := readFiles(oldPayload)
+ if err != nil {
+ return update.PatchManifest{}, err
+ }
+ newFiles, err := readFiles(newPayload)
+ if err != nil {
+ return update.PatchManifest{}, err
+ }
+ names := make([]string, 0, len(newFiles))
+ for name := range newFiles {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ var blobs []blob
+ for _, name := range names {
+ file := newFiles[name]
+ old, existed := oldFiles[name]
+ entry := update.PatchFile{
+ Path: name, Mode: uint32(file.mode.Perm()), Size: int64(len(file.data)),
+ SHA256: digest(file.data),
+ }
+ switch {
+ case existed && bytes.Equal(old.data, file.data):
+ entry.Op = "same"
+ case existed:
+ patch, err := bsdiff.Bytes(old.data, file.data)
+ if err != nil {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: diffing %s: %w", name, err)
+ }
+ entry.Op, entry.Blob = "diff", blobName(len(blobs)+1)
+ blobs = append(blobs, blob{name: entry.Blob, data: patch})
+ default:
+ entry.Op, entry.Blob = "new", blobName(len(blobs)+1)
+ blobs = append(blobs, blob{name: entry.Blob, data: file.data})
+ }
+ manifest.Files = append(manifest.Files, entry)
+ }
+ if len(manifest.Files) == 0 {
+ return update.PatchManifest{}, fmt.Errorf("patchgen: the new payload holds no files")
+ }
+ if err := writePatch(request.Output, manifest, blobs); err != nil {
+ return update.PatchManifest{}, err
+ }
+ return manifest, nil
+}
+
+type fileData struct {
+ data []byte
+ mode os.FileMode
+}
+
+type blob struct {
+ name string
+ data []byte
+}
+
+func blobName(index int) string { return fmt.Sprintf("files/%04d", index) }
+
+func digest(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func readFiles(root string) (map[string]fileData, error) {
+ files := make(map[string]fileData)
+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("patchgen: %s is not a regular file", path)
+ }
+ relative, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ files[filepath.ToSlash(relative)] = fileData{data: data, mode: info.Mode()}
+ return nil
+ })
+ return files, err
+}
+
+func writePatch(output io.Writer, manifest update.PatchManifest, blobs []blob) error {
+ encoded, err := json.Marshal(manifest)
+ if err != nil {
+ return err
+ }
+ gz := gzip.NewWriter(output)
+ writer := tar.NewWriter(gz)
+ now := time.Unix(0, 0).UTC()
+ writeEntry := func(name string, data []byte) error {
+ header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg, ModTime: now}
+ if err := writer.WriteHeader(header); err != nil {
+ return err
+ }
+ _, err := writer.Write(data)
+ return err
+ }
+ if err := writeEntry("patch.json", encoded); err != nil {
+ return err
+ }
+ for _, item := range blobs {
+ if err := writeEntry(item.name, item.data); err != nil {
+ return err
+ }
+ }
+ if err := writer.Close(); err != nil {
+ return err
+ }
+ return gz.Close()
+}
+
+// extractPayload unpacks archive and returns the payload root: the .app
+// directory for darwin, or a directory holding just the executable for linux.
+// The cleanup function removes the temporary tree.
+func extractPayload(archive, goos, arch, executable string) (root, kind string, cleanup func(), err error) {
+ directory, err := os.MkdirTemp("", "viva-patchgen-")
+ if err != nil {
+ return "", "", nil, err
+ }
+ removeTree := func() { _ = os.RemoveAll(directory) }
+ switch goos {
+ case "darwin":
+ if !strings.HasSuffix(strings.ToLower(archive), ".zip") {
+ removeTree()
+ return "", "", nil, fmt.Errorf("patchgen: macOS payloads come from a .zip, not %s", filepath.Base(archive))
+ }
+ if err := extractZip(archive, directory); err != nil {
+ removeTree()
+ return "", "", nil, err
+ }
+ bundles, err := filepath.Glob(filepath.Join(directory, "*.app"))
+ if err != nil || len(bundles) != 1 {
+ removeTree()
+ return "", "", nil, fmt.Errorf("patchgen: %s does not hold exactly one .app", filepath.Base(archive))
+ }
+ if _, err := os.Stat(filepath.Join(bundles[0], "Contents", "MacOS")); err != nil {
+ removeTree()
+ return "", "", nil, fmt.Errorf("patchgen: %s is not a macOS bundle", filepath.Base(archive))
+ }
+ return bundles[0], "app", removeTree, nil
+ default:
+ if !strings.HasSuffix(strings.ToLower(archive), ".tar.gz") {
+ removeTree()
+ return "", "", nil, fmt.Errorf("patchgen: Linux payloads come from a .tar.gz, not %s", filepath.Base(archive))
+ }
+ if err := extractTarGz(archive, directory); err != nil {
+ removeTree()
+ return "", "", nil, err
+ }
+ program, err := findExecutable(directory, executable)
+ if err != nil {
+ removeTree()
+ return "", "", nil, err
+ }
+ programRoot, err := os.MkdirTemp("", "viva-patchgen-payload-")
+ if err != nil {
+ removeTree()
+ return "", "", nil, err
+ }
+ target := filepath.Join(programRoot, filepath.Base(program))
+ if err := copyFile(program, target); err != nil {
+ os.RemoveAll(programRoot)
+ removeTree()
+ return "", "", nil, err
+ }
+ return programRoot, "file", func() { os.RemoveAll(programRoot); removeTree() }, nil
+ }
+}
+
+// findExecutable finds the program in an unpacked Linux archive. A named
+// executable wins; otherwise the archive must hold one executable besides
+// install.sh.
+func findExecutable(root, name string) (string, error) {
+ var named, candidates []string
+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
+ if err != nil || entry.IsDir() {
+ return err
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 || entry.Name() == "install.sh" {
+ return nil
+ }
+ if name != "" && entry.Name() == name {
+ named = append(named, path)
+ }
+ candidates = append(candidates, path)
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+ if len(named) == 1 {
+ return named[0], nil
+ }
+ if name == "" && len(candidates) == 1 {
+ return candidates[0], nil
+ }
+ if len(candidates) == 0 {
+ return "", fmt.Errorf("patchgen: the archive holds no executable")
+ }
+ return "", fmt.Errorf("patchgen: the archive holds %d executables; name one with the manifest's executable", len(candidates))
+}
+
+func copyFile(source, destination string) error {
+ data, err := os.ReadFile(source)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(destination, data, 0o755)
+}
+
+// extractZip unpacks a zip, refusing absolute paths and entries that climb
+// out of destination.
+func extractZip(archive, destination string) error {
+ reader, err := zip.OpenReader(archive)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+ for _, file := range reader.File {
+ target, err := safeJoin(destination, file.Name)
+ if err != nil {
+ return err
+ }
+ if file.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ continue
+ }
+ if !file.Mode().IsRegular() {
+ return fmt.Errorf("patchgen: %s is not a regular file", file.Name)
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ source, err := file.Open()
+ if err != nil {
+ return err
+ }
+ output, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode().Perm())
+ if err != nil {
+ source.Close()
+ return err
+ }
+ _, copyErr := io.Copy(output, source)
+ source.Close()
+ closeErr := output.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ }
+ return nil
+}
+
+// extractTarGz unpacks a tar.gz, refusing absolute paths and entries that
+// climb out of destination.
+func extractTarGz(archive, destination string) error {
+ file, err := os.Open(archive)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ gz, err := gzip.NewReader(file)
+ if err != nil {
+ return err
+ }
+ defer gz.Close()
+ reader := tar.NewReader(gz)
+ for {
+ header, err := reader.Next()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ target, err := safeJoin(destination, header.Name)
+ if err != nil {
+ return err
+ }
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ output, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(header.Mode)&0o777)
+ if err != nil {
+ return err
+ }
+ _, copyErr := io.Copy(output, reader)
+ closeErr := output.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ default:
+ return fmt.Errorf("patchgen: %s is not a regular file or directory", header.Name)
+ }
+ }
+}
+
+func safeJoin(root, name string) (string, error) {
+ clean := filepath.Clean(filepath.FromSlash(name))
+ if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+ return "", fmt.Errorf("patchgen: %q escapes the archive", name)
+ }
+ return filepath.Join(root, clean), nil
+}
diff --git a/tools/patchgen/patchgen_test.go b/tools/patchgen/patchgen_test.go
new file mode 100644
index 00000000..452793a8
--- /dev/null
+++ b/tools/patchgen/patchgen_test.go
@@ -0,0 +1,178 @@
+package patchgen
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/vivaui/viva/update"
+)
+
+func writeTarGz(t *testing.T, path, root string, files map[string][]byte) {
+ t.Helper()
+ var encoded bytes.Buffer
+ gz := gzip.NewWriter(&encoded)
+ writer := tar.NewWriter(gz)
+ for name, data := range files {
+ header := &tar.Header{Name: root + "/" + name, Mode: 0o755, Size: int64(len(data)), Typeflag: tar.TypeReg}
+ if err := writer.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := writer.Write(data); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gz.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, encoded.Bytes(), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func writeZip(t *testing.T, path string, files map[string][]byte) {
+ t.Helper()
+ var encoded bytes.Buffer
+ writer := zip.NewWriter(&encoded)
+ for name, data := range files {
+ file, err := writer.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.Write(data); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, encoded.Bytes(), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWriteLinuxPatch(t *testing.T) {
+ dir := t.TempDir()
+ oldArchive := filepath.Join(dir, "app-1.0.0-linux-amd64.tar.gz")
+ newArchive := filepath.Join(dir, "app-1.1.0-linux-amd64.tar.gz")
+ writeTarGz(t, oldArchive, "app-1.0.0-linux-amd64", map[string][]byte{
+ "app": []byte("the old program, with a fair amount of content"),
+ "install.sh": []byte("#!/bin/sh\n"),
+ })
+ writeTarGz(t, newArchive, "app-1.1.0-linux-amd64", map[string][]byte{
+ "app": []byte("the new program, with a fair amount of content and more"),
+ "install.sh": []byte("#!/bin/sh\n"),
+ })
+ patchPath := filepath.Join(dir, "app.patch")
+ file, err := os.Create(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, err := Write(Request{
+ App: "dev.example.notes", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "linux", Arch: "amd64", OldArchive: oldArchive, NewArchive: newArchive,
+ Executable: "app", Output: file,
+ })
+ file.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if manifest.Payload != "file" || len(manifest.Files) != 1 || manifest.Files[0].Path != "app" {
+ t.Fatalf("manifest = %+v", manifest)
+ }
+ patch, err := update.ReadPatch(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer patch.Close()
+ oldRoot := t.TempDir()
+ if err := os.WriteFile(filepath.Join(oldRoot, "app"), []byte("the old program, with a fair amount of content"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ staging := filepath.Join(dir, "staged")
+ if err := patch.Apply(oldRoot, staging); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(filepath.Join(staging, "app"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "the new program, with a fair amount of content and more" {
+ t.Fatalf("patched program = %q", got)
+ }
+}
+
+func TestWriteDarwinPatch(t *testing.T) {
+ dir := t.TempDir()
+ oldArchive := filepath.Join(dir, "app-1.0.0-darwin-arm64.zip")
+ newArchive := filepath.Join(dir, "app-1.1.0-darwin-arm64.zip")
+ writeZip(t, oldArchive, map[string][]byte{
+ "App.app/Contents/MacOS/app": []byte("old executable"),
+ "App.app/Contents/Info.plist": []byte("1.0.0"),
+ })
+ writeZip(t, newArchive, map[string][]byte{
+ "App.app/Contents/MacOS/app": []byte("new executable with more"),
+ "App.app/Contents/Info.plist": []byte("1.1.0"),
+ "App.app/Contents/Resources/added.txt": []byte("added"),
+ })
+ patchPath := filepath.Join(dir, "app.patch")
+ file, err := os.Create(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest, err := Write(Request{
+ App: "dev.example.notes", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "darwin", Arch: "arm64", OldArchive: oldArchive, NewArchive: newArchive, Output: file,
+ })
+ file.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if manifest.Payload != "app" || len(manifest.Files) != 3 {
+ t.Fatalf("manifest = %+v", manifest)
+ }
+ patch, err := update.ReadPatch(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer patch.Close()
+ oldRoot := t.TempDir()
+ for name, data := range map[string][]byte{
+ "Contents/MacOS/app": []byte("old executable"),
+ "Contents/Info.plist": []byte("1.0.0"),
+ } {
+ path := filepath.Join(oldRoot, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ staging := filepath.Join(dir, "staged")
+ if err := patch.Apply(oldRoot, staging); err != nil {
+ t.Fatal(err)
+ }
+ if data, err := os.ReadFile(filepath.Join(staging, "Contents", "Resources", "added.txt")); err != nil || string(data) != "added" {
+ t.Fatalf("added file = %q, %v", data, err)
+ }
+}
+
+func TestWriteRefusesUnsupportedTargets(t *testing.T) {
+ for _, request := range []Request{
+ {App: "app", FromVersion: "1.0.0", ToVersion: "1.1.0", OS: "windows", Arch: "amd64"},
+ {App: "app", FromVersion: "1.0.0", ToVersion: "1.1.0", OS: "linux", Arch: "386"},
+ {App: "", FromVersion: "1.0.0", ToVersion: "1.1.0", OS: "linux", Arch: "amd64"},
+ } {
+ if _, err := Write(request); err == nil {
+ t.Errorf("Write(%+v) accepted", request)
+ }
+ }
+}
diff --git a/tools/pkgbuild/pkgbuild.go b/tools/pkgbuild/pkgbuild.go
new file mode 100644
index 00000000..058f91b9
--- /dev/null
+++ b/tools/pkgbuild/pkgbuild.go
@@ -0,0 +1,248 @@
+// Package pkgbuild writes Arch Linux packages for a desktop application: a
+// PKGBUILD and its .SRCINFO that install a released binary archive.
+//
+// The archive is the tar.gz `viva package --format tar.gz` makes: the program,
+// a menu entry, an icon and document types. The package is a binary one (its
+// name ends in -bin), so makepkg does not build anything; it extracts the
+// archive into /opt and links the program into /usr/bin, as an AUR -bin package
+// does. Nothing here runs makepkg, and nothing needs Arch to write the files.
+package pkgbuild
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// Package describes one binary package.
+type Package struct {
+ // Executable is the program's file name in the archive. The package is
+ // named after it (see PackageName).
+ Executable string
+ // ID is the reverse-DNS application identifier; the menu entry is
+ // /usr/share/applications/.desktop.
+ ID string
+ // Name is the display name, used when no Comment is set.
+ Name string
+ Version string
+ // Arch is amd64 or arm64.
+ Arch string
+ Maintainer string
+ Homepage string
+ // Comment is the one-line description of the package.
+ Comment string
+ // Depends are Arch package names, such as vulkan-icd-loader.
+ Depends []string
+ // Archive is the tar.gz's file name, as it is in the PKGBUILD's
+ // directory or at the end of SourceURL.
+ Archive string
+ // SHA256 is the archive's lowercase hexadecimal SHA-256 digest.
+ SHA256 string
+ // SourceURL, when set, is where makepkg downloads the archive from. Empty
+ // means the archive is beside the PKGBUILD.
+ SourceURL string
+ // IconPixels is the icon's square size in pixels, or 0 without an icon.
+ IconPixels int
+}
+
+var (
+ packageNamePattern = regexp.MustCompile(`^[a-z0-9@_+][a-z0-9@._+-]+$`)
+ versionPattern = regexp.MustCompile(`^[A-Za-z0-9_+][A-Za-z0-9._+]*$`)
+ idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+ digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
+)
+
+// PackageName derives the Arch package name from the executable's file name:
+// lowercase, with the -bin suffix a binary package has.
+func PackageName(executable string) (string, error) {
+ name := strings.ToLower(executable) + "-bin"
+ if !packageNamePattern.MatchString(name) {
+ return "", fmt.Errorf("pkgbuild: %q cannot name an Arch package: it needs lowercase letters, digits and @ . _ + -, and must start with a letter, digit, @, _ or +", executable)
+ }
+ return name, nil
+}
+
+// ArchName maps a Go architecture to Arch's: amd64 is x86_64 and arm64 is
+// aarch64.
+func ArchName(arch string) (string, error) {
+ switch arch {
+ case "amd64":
+ return "x86_64", nil
+ case "arm64":
+ return "aarch64", nil
+ }
+ return "", fmt.Errorf("pkgbuild: architecture %q must be amd64 or arm64", arch)
+}
+
+func (pkg Package) check() error {
+ if _, err := pkg.packageNameValidated(); err != nil {
+ return err
+ }
+ if _, err := ArchName(pkg.Arch); err != nil {
+ return err
+ }
+ switch {
+ case !versionPattern.MatchString(pkg.Version):
+ return fmt.Errorf("pkgbuild: %q is not an Arch version (it cannot contain '-')", pkg.Version)
+ case !idPattern.MatchString(pkg.ID):
+ return fmt.Errorf("pkgbuild: %q cannot name a desktop file", pkg.ID)
+ case pkg.Name == "":
+ return fmt.Errorf("pkgbuild: the application needs a display name")
+ case pkg.Archive == "" || strings.ContainsAny(pkg.Archive, "/\\") || strings.ContainsAny(pkg.Archive, "\r\n") || pkg.Archive == "." || pkg.Archive == "..":
+ return fmt.Errorf("pkgbuild: %q is not an archive file name", pkg.Archive)
+ case !digestPattern.MatchString(pkg.SHA256):
+ return fmt.Errorf("pkgbuild: %q is not a lowercase SHA-256 digest", pkg.SHA256)
+ case strings.ContainsAny(pkg.Maintainer, "\r\n"):
+ return fmt.Errorf("pkgbuild: the maintainer must be one line")
+ case strings.ContainsAny(pkg.Comment, "\r\n"):
+ return fmt.Errorf("pkgbuild: the comment must be one line")
+ case pkg.IconPixels < 0:
+ return fmt.Errorf("pkgbuild: the icon size cannot be negative")
+ }
+ for _, dependency := range pkg.Depends {
+ if dependency == "" || strings.ContainsAny(dependency, "\r\n") {
+ return fmt.Errorf("pkgbuild: %q is not one dependency", dependency)
+ }
+ }
+ if pkg.SourceURL != "" && (!strings.HasPrefix(pkg.SourceURL, "https://") && !strings.HasPrefix(pkg.SourceURL, "http://")) {
+ return fmt.Errorf("pkgbuild: the source URL %q must be http or https", pkg.SourceURL)
+ }
+ return nil
+}
+
+// packageNameValidated is PackageName plus the length rule Arch enforces.
+func (pkg Package) packageNameValidated() (string, error) {
+ name, err := PackageName(pkg.Executable)
+ if err != nil {
+ return "", err
+ }
+ if len(name) > 255 {
+ return "", fmt.Errorf("pkgbuild: the package name %q is longer than 255 characters", name)
+ }
+ return name, nil
+}
+
+// PKGBUILD is the package description makepkg reads.
+func (pkg Package) PKGBUILD() ([]byte, error) {
+ if err := pkg.check(); err != nil {
+ return nil, err
+ }
+ name, _ := pkg.packageNameValidated()
+ arch, _ := ArchName(pkg.Arch)
+ maintainer := pkg.Maintainer
+ if maintainer == "" {
+ maintainer = pkg.Name
+ }
+ description := pkg.Comment
+ if description == "" {
+ description = pkg.Name
+ }
+ source := pkg.Archive
+ if pkg.SourceURL != "" {
+ source = pkg.Archive + "::" + pkg.SourceURL
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "# Maintainer: %s\n", oneLine(maintainer))
+ fmt.Fprintf(&b, "pkgname=%s\n", quote(name))
+ fmt.Fprintf(&b, "pkgver=%s\n", quote(pkg.Version))
+ fmt.Fprintf(&b, "pkgrel=1\n")
+ fmt.Fprintf(&b, "pkgdesc=%s\n", quote(description))
+ fmt.Fprintf(&b, "arch=(%s)\n", quote(arch))
+ if pkg.Homepage != "" {
+ fmt.Fprintf(&b, "url=%s\n", quote(pkg.Homepage))
+ }
+ fmt.Fprintf(&b, "license=(%s)\n", quote("custom"))
+ if len(pkg.Depends) > 0 {
+ fmt.Fprintf(&b, "depends=(%s)\n", quotedList(pkg.Depends))
+ }
+ fmt.Fprintf(&b, "provides=(%s)\n", quote(pkg.Executable))
+ fmt.Fprintf(&b, "conflicts=(%s)\n", quote(pkg.Executable))
+ fmt.Fprintf(&b, "options=(%s %s)\n", quote("!strip"), quote("!debug"))
+ b.WriteString("\n")
+ fmt.Fprintf(&b, "_archive=%s\n", quote(pkg.Archive))
+ fmt.Fprintf(&b, "_exe=%s\n", quote(pkg.Executable))
+ fmt.Fprintf(&b, "_desktop=%s\n", quote(pkg.ID))
+ fmt.Fprintf(&b, "_icon_size=%d\n", pkg.IconPixels)
+ b.WriteString("\n")
+ fmt.Fprintf(&b, "source=(%s)\n", quote(source))
+ fmt.Fprintf(&b, "sha256sums=(%s)\n", quote(pkg.SHA256))
+ b.WriteString(`
+package() {
+ install -dm755 "$pkgdir/opt/$pkgname"
+ bsdtar -xf "$srcdir/$_archive" -C "$pkgdir/opt/$pkgname" --strip-components=1
+ rm -f "$pkgdir/opt/$pkgname/install.sh"
+
+ install -dm755 "$pkgdir/usr/bin"
+ ln -s "/opt/$pkgname/$_exe" "$pkgdir/usr/bin/$_exe"
+
+ install -Dm644 "$pkgdir/opt/$pkgname/$_exe.desktop" "$pkgdir/usr/share/applications/$_desktop.desktop"
+ if [ "$_icon_size" -gt 0 ]; then
+ install -Dm644 "$pkgdir/opt/$pkgname/$_exe.png" \
+ "$pkgdir/usr/share/icons/hicolor/${_icon_size}x${_icon_size}/apps/$_exe.png"
+ fi
+ if [ -f "$pkgdir/opt/$pkgname/$_exe.mime.xml" ]; then
+ install -Dm644 "$pkgdir/opt/$pkgname/$_exe.mime.xml" "$pkgdir/usr/share/mime/packages/$_desktop.xml"
+ fi
+ install -Dm644 "$pkgdir/opt/$pkgname/README.txt" "$pkgdir/usr/share/doc/$pkgname/README.txt"
+}
+`)
+ return []byte(b.String()), nil
+}
+
+// SRCINFO is the package description AUR reads. It repeats the PKGBUILD's
+// fields in makepkg's plain key and value format.
+func (pkg Package) SRCINFO() ([]byte, error) {
+ if err := pkg.check(); err != nil {
+ return nil, err
+ }
+ name, _ := pkg.packageNameValidated()
+ arch, _ := ArchName(pkg.Arch)
+ description := pkg.Comment
+ if description == "" {
+ description = pkg.Name
+ }
+ source := pkg.Archive
+ if pkg.SourceURL != "" {
+ source = pkg.Archive + "::" + pkg.SourceURL
+ }
+ var b strings.Builder
+ field := func(key, value string) { b.WriteString("\t" + key + " = " + value + "\n") }
+ b.WriteString("pkgbase = " + name + "\n")
+ field("pkgdesc", oneLine(description))
+ field("pkgver", pkg.Version)
+ field("pkgrel", "1")
+ if pkg.Homepage != "" {
+ field("url", pkg.Homepage)
+ }
+ field("arch", arch)
+ field("license", "custom")
+ for _, dependency := range pkg.Depends {
+ field("depends", dependency)
+ }
+ field("provides", pkg.Executable)
+ field("conflicts", pkg.Executable)
+ field("options", "!strip")
+ field("options", "!debug")
+ field("source", source)
+ field("sha256sums", pkg.SHA256)
+ b.WriteString("\npkgname = " + name + "\n")
+ return []byte(b.String()), nil
+}
+
+// quote is a single-quoted shell word, safe for any value the checks accept.
+func quote(value string) string {
+ return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
+}
+
+func quotedList(values []string) string {
+ quoted := make([]string, len(values))
+ for i, value := range values {
+ quoted[i] = quote(value)
+ }
+ return strings.Join(quoted, " ")
+}
+
+func oneLine(value string) string {
+ return strings.Join(strings.Fields(value), " ")
+}
diff --git a/tools/pkgbuild/pkgbuild_test.go b/tools/pkgbuild/pkgbuild_test.go
new file mode 100644
index 00000000..2ce71b5b
--- /dev/null
+++ b/tools/pkgbuild/pkgbuild_test.go
@@ -0,0 +1,192 @@
+package pkgbuild
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func sample() Package {
+ return Package{
+ Executable: "Fixture-Notes", ID: "dev.example.notes", Name: "Fixture Notes", Version: "2.0.1", Arch: "amd64",
+ Maintainer: "Ann Lee ", Homepage: "https://example.com/notes",
+ Comment: "Takes notes, doesn't lose them",
+ Depends: []string{"vulkan-icd-loader", "libxcb", "cairo"},
+ Archive: "Fixture-Notes-2.0.1-linux-amd64.tar.gz",
+ SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ IconPixels: 256,
+ }
+}
+
+func TestPackageName(t *testing.T) {
+ name, err := PackageName("Fixture-Notes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if name != "fixture-notes-bin" {
+ t.Fatalf("name = %q", name)
+ }
+ for _, bad := range []string{"", "-notes", ".notes", "notes app", "notes/app"} {
+ if _, err := PackageName(bad); err == nil {
+ t.Errorf("PackageName(%q) accepted", bad)
+ }
+ }
+}
+
+func TestArchName(t *testing.T) {
+ cases := map[string]string{"amd64": "x86_64", "arm64": "aarch64"}
+ for input, want := range cases {
+ if got, err := ArchName(input); err != nil || got != want {
+ t.Errorf("ArchName(%q) = %q, %v; want %q", input, got, err, want)
+ }
+ }
+ if _, err := ArchName("386"); err == nil {
+ t.Error("ArchName(386) accepted")
+ }
+}
+
+func TestPKGBUILD(t *testing.T) {
+ data, err := sample().PKGBUILD()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pkgbuild := string(data)
+ for _, want := range []string{
+ "pkgname='fixture-notes-bin'",
+ "pkgver='2.0.1'",
+ "pkgrel=1",
+ `pkgdesc='Takes notes, doesn'\''t lose them'`,
+ "arch=('x86_64')",
+ "url='https://example.com/notes'",
+ "license=('custom')",
+ "depends=('vulkan-icd-loader' 'libxcb' 'cairo')",
+ "provides=('Fixture-Notes')",
+ "conflicts=('Fixture-Notes')",
+ "options=('!strip' '!debug')",
+ "_archive='Fixture-Notes-2.0.1-linux-amd64.tar.gz'",
+ "_icon_size=256",
+ "source=('Fixture-Notes-2.0.1-linux-amd64.tar.gz')",
+ "sha256sums=('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef')",
+ `install -dm755 "$pkgdir/opt/$pkgname"`,
+ `bsdtar -xf "$srcdir/$_archive" -C "$pkgdir/opt/$pkgname" --strip-components=1`,
+ `ln -s "/opt/$pkgname/$_exe" "$pkgdir/usr/bin/$_exe"`,
+ `$pkgdir/usr/share/applications/$_desktop.desktop`,
+ `$pkgdir/usr/share/icons/hicolor/${_icon_size}x${_icon_size}/apps/$_exe.png`,
+ `$pkgdir/usr/share/mime/packages/$_desktop.xml`,
+ `$pkgdir/usr/share/doc/$pkgname/README.txt`,
+ } {
+ if !strings.Contains(pkgbuild, want) {
+ t.Errorf("PKGBUILD does not contain %q:\n%s", want, pkgbuild)
+ }
+ }
+}
+
+func TestPKGBUILDQuotesShellValues(t *testing.T) {
+ pkg := sample()
+ pkg.Comment = `Ann's "notes" $HOME ` + "`x`"
+ data, err := pkg.PKGBUILD()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), `pkgdesc='Ann'\''s "notes" $HOME `+"`x`'") {
+ t.Fatalf("description is not single-quoted safely:\n%s", data)
+ }
+}
+
+func TestPKGBUILDSourceURL(t *testing.T) {
+ pkg := sample()
+ pkg.SourceURL = "https://example.com/downloads/Fixture-Notes-2.0.1-linux-amd64.tar.gz"
+ data, err := pkg.PKGBUILD()
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := "source=('Fixture-Notes-2.0.1-linux-amd64.tar.gz::https://example.com/downloads/Fixture-Notes-2.0.1-linux-amd64.tar.gz')"
+ if !strings.Contains(string(data), want) {
+ t.Fatalf("PKGBUILD does not contain %q:\n%s", want, data)
+ }
+}
+
+func TestPKGBUILDWithoutIcon(t *testing.T) {
+ pkg := sample()
+ pkg.IconPixels = 0
+ data, err := pkg.PKGBUILD()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), `if [ "$_icon_size" -gt 0 ]; then`) {
+ t.Fatalf("no icon guard:\n%s", data)
+ }
+}
+
+func TestPKGBUILDSyntax(t *testing.T) {
+ bash, err := exec.LookPath("bash")
+ if err != nil {
+ t.Skip("bash is not installed")
+ }
+ data, err := sample().PKGBUILD()
+ if err != nil {
+ t.Fatal(err)
+ }
+ command := exec.Command(bash, "-n")
+ command.Stdin = strings.NewReader(string(data))
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("bash -n: %v\n%s\n%s", err, output, data)
+ }
+}
+
+func TestSRCINFO(t *testing.T) {
+ data, err := sample().SRCINFO()
+ if err != nil {
+ t.Fatal(err)
+ }
+ srcinfo := string(data)
+ for _, want := range []string{
+ "pkgbase = fixture-notes-bin",
+ "\tpkgdesc = Takes notes, doesn't lose them",
+ "\tpkgver = 2.0.1",
+ "\tpkgrel = 1",
+ "\turl = https://example.com/notes",
+ "\tarch = x86_64",
+ "\tlicense = custom",
+ "\tdepends = vulkan-icd-loader",
+ "\tprovides = Fixture-Notes",
+ "\tconflicts = Fixture-Notes",
+ "\toptions = !strip",
+ "\toptions = !debug",
+ "\tsource = Fixture-Notes-2.0.1-linux-amd64.tar.gz",
+ "\tsha256sums = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ "pkgname = fixture-notes-bin",
+ } {
+ if !strings.Contains(srcinfo, want) {
+ t.Errorf("SRCINFO does not contain %q:\n%s", want, srcinfo)
+ }
+ }
+}
+
+func TestRejectsBadPackages(t *testing.T) {
+ tests := map[string]func(*Package){
+ "empty name": func(p *Package) { p.Name = "" },
+ "version hyphen": func(p *Package) { p.Version = "2.0.1-beta" },
+ "empty archive": func(p *Package) { p.Archive = "" },
+ "archive path": func(p *Package) { p.Archive = "dir/app.tar.gz" },
+ "short digest": func(p *Package) { p.SHA256 = "abc" },
+ "uppercase digest": func(p *Package) { p.SHA256 = strings.ToUpper(p.SHA256) },
+ "arch": func(p *Package) { p.Arch = "386" },
+ "bad id": func(p *Package) { p.ID = "a/b" },
+ "multiline": func(p *Package) { p.Comment = "one\ntwo" },
+ "bad maintainer": func(p *Package) { p.Maintainer = "two\nlines" },
+ "bad source": func(p *Package) { p.SourceURL = "ftp://example.com/app.tar.gz" },
+ "bad dependency": func(p *Package) { p.Depends = []string{"ok", ""} },
+ "negative icon": func(p *Package) { p.IconPixels = -1 },
+ }
+ for name, change := range tests {
+ pkg := sample()
+ change(&pkg)
+ if _, err := pkg.PKGBUILD(); err == nil {
+ t.Errorf("%s: PKGBUILD accepted", name)
+ }
+ if _, err := pkg.SRCINFO(); err == nil {
+ t.Errorf("%s: SRCINFO accepted", name)
+ }
+ }
+}
diff --git a/ui/box.go b/ui/box.go
index 3d5e30b1..4314d338 100644
--- a/ui/box.go
+++ b/ui/box.go
@@ -285,6 +285,16 @@ func (b Box) Border(width float32, c Color) Box {
return b
}
+// Dashed gives the element's Border or Stroke a repeating dash pattern,
+// continuing around rounded corners. It has no effect without a line width;
+// calling it again switches back to a solid line.
+func (b Box) Dashed() Box {
+ b.n()
+ extra := b.f.extra(b.i)
+ extra.borderDashed = !extra.borderDashed
+ return b
+}
+
// Shadow casts the shadow of that level of the elevation scale (gfx.ShadowXS to
// gfx.ShadowLG) around the element's rounded rectangle, under its fill.
func (b Box) Shadow(level gfx.ShadowLevel) Box {
diff --git a/ui/draw.go b/ui/draw.go
index 76ff4317..8e967470 100644
--- a/ui/draw.go
+++ b/ui/draw.go
@@ -49,12 +49,19 @@ type DrawContext struct {
func (d *DrawContext) Part(i int) Rect { return d.frame.selectPart(d.node, i) }
// Fade returns c with the opacity of the element, as the colors of the element itself
-// are. Colors taken from Theme are faded already.
+// are. The color of State looks is already faded; other colors taken from Theme
+// are not.
func (d *DrawContext) Fade(c Color) Color {
c.Alpha *= d.Opacity
return c
}
+// DashedBorder draws a repeating border inside bounds with the element's fade.
+// Set bounds to d.Rect for the element whose callback is drawing.
+func (d *DrawContext) DashedBorder(bounds Rect, radius, width float32, color Color) {
+ d.Canvas.DashedRoundedOutline(bounds, radius, width, d.Fade(color))
+}
+
// drawHooks is what Draw and DrawOver declared on one element.
type drawHooks struct {
under, over func(*DrawContext)
diff --git a/ui/elements.go b/ui/elements.go
index ac997ed8..c66e4fa9 100644
--- a/ui/elements.go
+++ b/ui/elements.go
@@ -153,6 +153,19 @@ func options[T comparable](p *Page, value *T, options []T, label func(T) string)
return p.labels[first:len(p.labels):len(p.labels)], selected
}
+// optionIcons writes the icons of choices into the page's scratch, as options does
+// the labels, or returns nil when icon is nil and the select paints none of them.
+func optionIcons[T comparable](p *Page, choices []T, icon func(T) gfx.Icon) []gfx.Icon {
+ if icon == nil {
+ return nil
+ }
+ first := len(p.icons)
+ for _, choice := range choices {
+ p.icons = append(p.icons, icon(choice))
+ }
+ return p.icons[first:len(p.icons):len(p.icons)]
+}
+
// pick writes the option a click chose into *value, where the control is declared,
// and records whether it did for Changed.
func pick[T comparable](c C, b Box, value *T, options []T, index int, ok bool) {
@@ -197,11 +210,23 @@ func Tabs[T comparable](c C, value *T, tabs []T, label func(T) string) Box {
// it is none of them); picking an option sets it. Whether its list is open is the
// Select's own state.
func Select[T comparable](c C, value *T, choices []T, label func(T) string) Box {
- return selectOf(c, value, choices, label, false)
+ return selectOf(c, value, choices, label, nil, false)
+}
+
+// SelectIcon is Select with an icon before every option: icon names each option's
+// own, painted before the chosen value on the trigger and before each label in the
+// open list. It is asked for once per option per paint, as label is; a nil icon
+// paints exactly what Select does.
+//
+// ui.SelectIcon(c, &m.locale, locales, localeName, localeFlag)
+func SelectIcon[T comparable](c C, value *T, choices []T, label func(T) string, icon func(T) gfx.Icon) Box {
+ return selectOf(c, value, choices, label, optionIcons(c.page, choices, icon), false)
}
-// selectOf is Select, or SelectBase when base is set: the same control without the theme's look.
-func selectOf[T comparable](c C, value *T, choices []T, label func(T) string, base bool) Box {
+// selectOf is Select (or SelectIcon), or SelectBase when base is set: the same
+// control without the theme's look. icons, when SelectIcon's icon function gave
+// them, are indexed by option like the labels; nil is the select without icons.
+func selectOf[T comparable](c C, value *T, choices []T, label func(T) string, icons []gfx.Icon, base bool) Box {
p := c.page
scope := p.boundScope(pkSelect, unsafe.Pointer(value))
state := stateIn[SelectState](p, scope, "select") // its open list follows its value
@@ -212,6 +237,7 @@ func selectOf[T comparable](c C, value *T, choices []T, label func(T) string, ba
} else {
b = c.f.Select(scope, state, labels, selected)
}
+ b.Icons(icons)
p.selects = append(p.selects, state)
index, ok := b.Picked()
pick(c, b, value, choices, index, ok)
diff --git a/ui/field.go b/ui/field.go
index 8498167b..81b8590b 100644
--- a/ui/field.go
+++ b/ui/field.go
@@ -257,6 +257,7 @@ func Fields(fields ...*Field) func(app.TextInputContext) *app.TextInput {
return func(context app.TextInputContext) *app.TextInput {
for _, field := range fields {
if field.id == context.ID && !context.ID.IsZero() {
+ field.input.Alignment = context.Alignment
field.input.Editor = field.ensure()
return &field.input
}
diff --git a/ui/field_alignment_test.go b/ui/field_alignment_test.go
new file mode 100644
index 00000000..9492eca5
--- /dev/null
+++ b/ui/field_alignment_test.go
@@ -0,0 +1,80 @@
+//go:build darwin || windows || linux
+
+package ui
+
+import (
+ "testing"
+
+ "github.com/vivaui/viva/app"
+ "github.com/vivaui/viva/gfx"
+)
+
+// TextEnd reaches the platform through the whole chain: the hit the field declares
+// tells the focused editor its alignment, so a number field stays right-aligned
+// while it is being edited in, and a field that did not ask stays start-aligned.
+func TestFieldTextEndToldToThePlatformEditor(t *testing.T) {
+ var amount, note Field
+ var view View
+ amountID, noteID := tree.ID(page, "amount"), tree.ID(page, "note")
+ amount.SetText("42")
+ config := app.Config{
+ Tree: &tree, Title: "aligned", Width: 300, Height: 200,
+ Paint: func(canvas *gfx.Canvas, size gfx.Size) {
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: size.Width, Height: size.Height}, func(f *Frame) {
+ f.Column().Fill().Pad(12).Gap(8).Children(func() {
+ f.Field(&amount, amountID, "Amount", "").TextEnd()
+ f.Field(¬e, noteID, "Note", "")
+ })
+ })
+ },
+ TextInput: Fields(&amount, ¬e),
+ }
+ var canvas gfx.Canvas
+ driver, err := app.NewDriver(config, &canvas)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer driver.Close()
+ click := func(id gfx.ID) {
+ for _, hit := range driver.Snapshot().Hits {
+ if hit.ID == id {
+ if err := driver.Click(hit.Bounds.X+hit.Bounds.Width/2, hit.Bounds.Y+hit.Bounds.Height/2); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ }
+ t.Fatalf("no field %v on screen", id)
+ }
+ editorAlignment := func() gfx.Alignment {
+ snapshot := driver.Snapshot()
+ if snapshot.Editor == nil {
+ t.Fatal("no focused editor on screen")
+ }
+ return snapshot.Editor.Alignment
+ }
+ click(amountID)
+ if got := editorAlignment(); got != gfx.AlignRight {
+ t.Errorf("the right-aligned field's editor = %v, want AlignRight (resolved from AlignEnd)", got)
+ }
+ // The line the editor draws sits at the same right edge the paint put it at, and
+ // the field's own painted line is right-aligned too.
+ snapshot := driver.Snapshot()
+ var line gfx.Text
+ for _, text := range snapshot.Texts {
+ if text.Value == "42" && text.Bounds.Width == snapshot.Editor.Bounds.Width {
+ line = text
+ break
+ }
+ }
+ if line.Value == "" {
+ t.Fatalf("the value was not painted: %+v", snapshot.Texts)
+ }
+ if line.Bounds.X < snapshot.Editor.Bounds.X+snapshot.Editor.Bounds.Width/2 {
+ t.Errorf("the painted value starts at %v, left of center: it was not right-aligned", line.Bounds.X)
+ }
+ click(noteID)
+ if got := editorAlignment(); got != gfx.AlignLeft {
+ t.Errorf("an ordinary field's editor = %v, want AlignLeft (start for LTR)", got)
+ }
+}
diff --git a/ui/frame.go b/ui/frame.go
index b274b526..0413ce9b 100644
--- a/ui/frame.go
+++ b/ui/frame.go
@@ -191,8 +191,9 @@ type extra struct {
iconTransform gfx.IconTransform // moves the icon inside its box without moving the box
border Color
shadow gfx.ShadowLevel
- ring Color // Ring: a line around the outside of the element
- ringWidth float32 // its width; zero is none
+ ring Color // Ring: a line around the outside of the element
+ ringWidth float32 // its width; zero is none
+ borderDashed bool
fade float32 // 1 less the opacity, so that the zero value is opaque
gradient int32 // 1 + the index of the element's gradient in frame.gradients, or 0 for none
states int32 // 1 + the index of the element's Hover, Pressed and Focus looks in frame.states, or 0 for none
diff --git a/ui/input_base_test.go b/ui/input_base_test.go
index a49e85a2..5d9f22e1 100644
--- a/ui/input_base_test.go
+++ b/ui/input_base_test.go
@@ -214,6 +214,7 @@ func TestInputIsTheBaseDressedByTheRecipeAndPaintsAsTheComponentDoes(t *testing.
"secure": {ID: baseField, Label: "Name", Value: "hunter2", Secure: true},
"composition": {ID: baseField, Label: "Name", Value: "Gopher", Focused: true, CompositionStart: 2, CompositionEnd: 4},
"platform": {ID: baseField, Label: "Name", Value: "Gopher", Focused: true, PlatformEditing: true},
+ "aligned": {ID: baseField, Label: "Name", Value: "Gopher", Alignment: gfx.AlignEnd},
}
for themeName, theme := range themes {
for name, snapshot := range snapshots {
@@ -513,3 +514,57 @@ func TestInputBaseLineHeightIsTheElementsAndThePadComesOnTop(t *testing.T) {
t.Errorf("height of a 20 point field = %v, want the size plus seven, 27", got)
}
}
+
+// TextEnd right-aligns the value of an Input: the element's declaration reaches where the
+// glyph line is painted, and the hit tells the platform the same end. What the snapshot
+// declared itself is not written over.
+func TestInputTextEndRightAlignsTheValueAndTheEditorGeometry(t *testing.T) {
+ theme := gfx.LightTheme()
+ snapshot := components.InputSnapshot{ID: baseField, Label: "Amount", Value: "42", Text: components.InputTextStyle{Size: 14}}
+ paint := func(style func(Box) Box) *gfx.Canvas {
+ var view View
+ canvas := testCanvas()
+ view.Draw(canvas, theme, gfx.Rect{Width: 240, Height: 80}, func(f *Frame) {
+ f.Row().Items(Start).Children(func() {
+ style(f.InputBase(snapshot).Width(200))
+ })
+ })
+ return canvas
+ }
+ lineOf := func(canvas *gfx.Canvas) gfx.Text {
+ for _, text := range canvas.Texts() {
+ if text.Value == "42" {
+ return text
+ }
+ }
+ t.Fatalf("no value painted: %+v", canvas.Texts())
+ return gfx.Text{}
+ }
+ plain, ended := paint(func(b Box) Box { return b }), paint(func(b Box) Box { return b.TextEnd() })
+ // The element's TextEnd() reaches the painted line.
+ if lineOf(plain).Bounds.X == lineOf(ended).Bounds.X {
+ t.Fatalf("the value did not move: %+v and %+v", lineOf(plain).Bounds, lineOf(ended).Bounds)
+ }
+ if lineOf(ended).Bounds.X <= lineOf(plain).Bounds.X {
+ t.Errorf("the value starts at %v, want it right of the start-aligned %v", lineOf(ended).Bounds.X, lineOf(plain).Bounds.X)
+ }
+ // What the hit tells the platform is the same edge.
+ if text := hitText(plain, &plain.Hits()[0]); text.Alignment != gfx.AlignStart {
+ t.Errorf("a field without TextEnd promises alignment %v, want AlignStart", text.Alignment)
+ }
+ if text := hitText(ended, &ended.Hits()[0]); text.Alignment != gfx.AlignEnd {
+ t.Errorf("the platform is told alignment %v, want AlignEnd", text.Alignment)
+ }
+ // The element does not write over a snapshot that declared its own.
+ own := components.InputSnapshot{ID: baseField, Label: "Amount", Value: "42", Alignment: gfx.AlignCenter}
+ var view View
+ centered := testCanvas()
+ view.Draw(centered, theme, gfx.Rect{Width: 240, Height: 80}, func(f *Frame) {
+ f.Row().Items(Start).Children(func() {
+ f.InputBase(own).Width(200).TextEnd()
+ })
+ })
+ if text := hitText(centered, ¢ered.Hits()[0]); text.Alignment != gfx.AlignCenter {
+ t.Errorf("the snapshot's own alignment was written over: %v, want the declared AlignCenter", text.Alignment)
+ }
+}
diff --git a/ui/page.go b/ui/page.go
index a92b3422..87de1698 100644
--- a/ui/page.go
+++ b/ui/page.go
@@ -271,6 +271,7 @@ type Page struct {
hoverArea gfx.ID // the scroll area or self-scrolling table the pointer is on
selects []*SelectState // the Selects of the last paint, which Wheel scrolls
labels []string // scratch for Choice's labels
+ icons []gfx.Icon // scratch for SelectIcon's icons
memos map[gfx.ID]*memoEntry
memoBoxes []memoBox
memoChecks []memoCheckOf
@@ -571,7 +572,7 @@ func (p *Page) hoverPlaceAt(x, y float32) gfx.ID {
}
for i := range p.tables {
route := &p.tables[i]
- if !route.scrolls || route.bar.IsZero() || !pointInRect(x, y, route.bounds) {
+ if (!route.scrolls && !route.sideways) || (route.bar.IsZero() && route.horizontal.IsZero()) || !pointInRect(x, y, route.bounds) {
continue
}
if area := route.bounds.Width * route.bounds.Height; best.IsZero() || area < bestArea {
@@ -783,6 +784,8 @@ func (p *Page) paintOnce(canvas *gfx.Canvas, bounds Rect, view func(C)) {
clear(p.ctx.stateSeq)
clear(p.labels)
p.labels = p.labels[:0]
+ clear(p.icons)
+ p.icons = p.icons[:0]
p.focusPaint = p.focusPaint[:0]
for i := range p.held {
if id := p.held[i].id; !id.IsZero() && id != canvas.Pressed() && id != canvas.Focused() {
@@ -1069,6 +1072,7 @@ func (p *Page) guard(b Box, id gfx.ID, label string) {
func (p *Page) TextInput(context app.TextInputContext) *app.TextInput {
for _, entry := range p.fields {
if entry.id == context.ID && !context.ID.IsZero() {
+ entry.field.input.Alignment = context.Alignment
entry.field.input.Editor = entry.field.ensure()
return &entry.field.input
}
@@ -1141,13 +1145,17 @@ func (p *Page) Wheel(event gfx.ScrollEvent) gfx.EventResult {
}
for i := len(p.tables) - 1; i >= 0; i-- {
route := &p.tables[i]
- if !route.scrolls || !pointInRect(event.X, event.Y, route.bounds) {
+ if !route.scrolls && !route.sideways || !pointInRect(event.X, event.Y, route.bounds) {
continue
}
- if result := route.state.Wheel(event); result.Handled {
- result.Repaint = result.Repaint || moved
- return result
+ state := &route.state.Scroll
+ dx, dy := state.wheelSteps(event)
+ if !state.canScroll(dx, dy) {
+ continue
}
+ result := state.Wheel(event)
+ result.Repaint = result.Repaint || moved
+ return result
}
if moved {
return gfx.EventResult{Handled: true, Repaint: true}
diff --git a/ui/page_bases.go b/ui/page_bases.go
index 9129b7a2..3cdacc79 100644
--- a/ui/page_bases.go
+++ b/ui/page_bases.go
@@ -106,7 +106,7 @@ func SliderBase(c C, state *SliderState) Box {
// list.Draw(m.paintPanel) // option i is in sel.Part(i)
// }
func SelectBase[T comparable](c C, value *T, choices []T, label func(T) string) Box {
- return selectOf(c, value, choices, label, true)
+ return selectOf(c, value, choices, label, nil, true)
}
// TextFieldBase is TextField without a look: the editable text, with no padding,
diff --git a/ui/page_test.go b/ui/page_test.go
index 5ffaf8a9..b593f9e7 100644
--- a/ui/page_test.go
+++ b/ui/page_test.go
@@ -558,3 +558,33 @@ func TestSelectStaysOpenWhenOneAppearsAbove(t *testing.T) {
t.Fatalf("size open=%v, color open=%v", d.has("L"), d.has("Red"))
}
}
+
+// SelectIcon opens, chooses and writes back through the option type of the
+// application's own, label and icon both named by functions of the option.
+func TestSelectIconChoosesAnOption(t *testing.T) {
+ type locale struct {
+ name string
+ flag int
+ }
+ flagOf := func(flag int) gfx.Icon {
+ return gfx.Icon{ViewBox: 24, Stroke: 2, Cap: gfx.CapRound, Join: gfx.JoinRound,
+ Commands: []gfx.PathCommand{gfx.MoveTo(4+float32(flag)*4, 6), gfx.LineTo(14+float32(flag)*4, 18)}}
+ }
+ locales := []locale{{"简体中文", 0}, {"English", 1}, {"日本語", 2}}
+ chosen := locales[1]
+ d := openPage(t, func(c ui.C) {
+ ui.Column(c).Pad(16).Children(func() {
+ ui.SelectIcon(c, &chosen, locales,
+ func(l locale) string { return l.name },
+ func(l locale) gfx.Icon { return flagOf(l.flag) }).Placeholder("Language")
+ })
+ })
+ d.click("Language")
+ if !d.has("日本語") {
+ t.Fatal("the open list does not offer its options")
+ }
+ d.click("日本語")
+ if chosen != locales[2] {
+ t.Fatalf("chose %+v, want %+v", chosen, locales[2])
+ }
+}
diff --git a/ui/paint.go b/ui/paint.go
index d4ee5084..9ec550b0 100644
--- a/ui/paint.go
+++ b/ui/paint.go
@@ -58,7 +58,7 @@ func (f *frame) paint(i int32) {
canvas.ElevateFaded(r, n.radius, style.shadow, f.opacity)
}
switch {
- case n.borderWidth > 0 && extra.gradient == 0 && f.opacity >= 1 && background.Alpha >= 1:
+ case n.borderWidth > 0 && !style.dashed && extra.gradient == 0 && f.opacity >= 1 && background.Alpha >= 1:
canvas.RoundedBorder(r, n.radius, n.borderWidth, border, background)
default:
// RoundedBorder paints the border color as a solid backing under the fill, which a
@@ -77,7 +77,11 @@ func (f *frame) paint(i int32) {
f.paintGradient(n, extra)
}
if n.borderWidth > 0 {
- canvas.RoundedOutline(r, n.radius, n.borderWidth, border)
+ if style.dashed {
+ canvas.DashedRoundedOutline(n.rect, n.radius, n.borderWidth, border)
+ } else {
+ canvas.RoundedOutline(r, n.radius, n.borderWidth, border)
+ }
}
}
if extra.moves {
@@ -301,7 +305,15 @@ func (f *frame) paintButton(n *node) {
func (f *frame) paintInput(n *node) {
// What the element says of its text (FontSize, Weight, Color) is the field's text style,
// over the snapshot's own: the one value the glyphs, the caret and the platform follow.
+ // What it says of its alignment is the same, but a field is start-aligned unless it says
+ // otherwise, so the snapshot's own declaration is left alone only by an alignment it
+ // declares. What the snapshot leaves at AlignLeft the element's TextEnd() takes.
snapshot := f.inputs[n.aux]
+ if n.textEnd {
+ if snapshot.Alignment == gfx.AlignLeft || snapshot.Alignment == gfx.AlignStart {
+ snapshot.Alignment = gfx.AlignEnd
+ }
+ }
if n.fontSize > 0 {
snapshot.Text.Size = n.fontSize
}
diff --git a/ui/rowidentity.go b/ui/rowidentity.go
new file mode 100644
index 00000000..895a4f3e
--- /dev/null
+++ b/ui/rowidentity.go
@@ -0,0 +1,25 @@
+package ui
+
+import "github.com/vivaui/viva/gfx"
+
+// RowID is the identity of source row i as declared by the last paint, and false
+// for a row the table did not declare (offscreen, out of range, or not yet
+// painted). Scrolling does not renumber source rows: an identity keeps its source
+// index when the row moves on screen. When the Source is sorted or reordered, the
+// identities follow those indices, not the records the application moved.
+func (s *TableState) RowID(i int) (gfx.ID, bool) {
+ if i < int(s.rowFirst) || i >= int(s.rowEnd) || s.scope.IsZero() {
+ return gfx.ID{}, false
+ }
+ return s.scope.At(i), true
+}
+
+// Rows is the scope the table named its rows in on the last paint, for a handler
+// that must recover a source row from a target:
+//
+// row, ok := event.Target.In(state.Rows())
+//
+// It is zero before the first paint, and after a paint that gave the table no row
+// scope. Identities from RowID belong to this scope. Declaring the same state in
+// more than one table leaves the calls describing only the last table painted.
+func (s *TableState) Rows() gfx.Scope { return s.scope }
diff --git a/ui/rowidentity_test.go b/ui/rowidentity_test.go
new file mode 100644
index 00000000..cff44989
--- /dev/null
+++ b/ui/rowidentity_test.go
@@ -0,0 +1,204 @@
+package ui_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/vivaui/viva/app"
+ "github.com/vivaui/viva/components"
+ "github.com/vivaui/viva/gfx"
+ "github.com/vivaui/viva/ui"
+)
+
+type rowIdentityModel struct {
+ rows ui.TableRows
+ first ui.TableState
+ second ui.TableState
+ target gfx.ID
+ row int
+ ok bool
+}
+
+func newRowIdentityModel(rows int) *rowIdentityModel {
+ m := &rowIdentityModel{rows: make(ui.TableRows, rows), row: -1}
+ for i := range m.rows {
+ m.rows[i] = []string{fmt.Sprintf("source-%d", i)}
+ }
+ return m
+}
+
+func (m *rowIdentityModel) table(c ui.C, state *ui.TableState) {
+ ui.Table(c, state, ui.TableSpec{
+ Columns: []components.TableColumn{{}},
+ Source: &m.rows,
+ RowHeight: 20,
+ }).Height(100)
+}
+
+func (m *rowIdentityModel) page(c ui.C) {
+ m.table(c, &m.first)
+}
+
+func (m *rowIdentityModel) pointer(event gfx.PointerEvent) gfx.EventResult {
+ if event.Kind != gfx.PointerDown {
+ return gfx.EventResult{}
+ }
+ m.target = event.Target
+ m.row, m.ok = event.Target.In(m.first.Rows())
+ return gfx.EventResult{}
+}
+
+func (m *rowIdentityModel) press(t *testing.T, d *pageDriver, row int) {
+ t.Helper()
+ x, y := center(d.hit(fmt.Sprintf("source-%d", row)).Bounds)
+ m.ok = false
+ if err := d.driver.Click(x, y); err != nil {
+ t.Fatal(err)
+ }
+ if !m.ok {
+ t.Fatalf("a press on source row %d did not reach the table's row scope; target: %v", row, m.target)
+ }
+ if m.row != row {
+ t.Fatalf("pointer routed to source row %d, want %d", m.row, row)
+ }
+ id, ok := m.first.RowID(row)
+ if !ok || id != m.target || id.Scope() != m.first.Rows() {
+ t.Fatalf("RowID(%d) = %v, %v; pointer target = %v, Rows = %v", row, id, ok, m.target, m.first.Rows())
+ }
+}
+
+func declaredRowHits(d *pageDriver, scope gfx.Scope) map[int]gfx.ID {
+ ids := make(map[int]gfx.ID)
+ for _, hit := range d.driver.Snapshot().Hits {
+ if row, ok := hit.ID.In(scope); ok && !hit.IsLabel {
+ ids[row] = hit.ID
+ }
+ }
+ return ids
+}
+
+func TestTableRowIDRoutesRealPointerAcrossScroll(t *testing.T) {
+ m := newRowIdentityModel(50)
+ d := openPageWith(t, ui.PageConfig{View: m.page}, app.Config{OnPointer: m.pointer})
+ m.press(t, d, 0)
+ firstRowID := m.target
+ m.press(t, d, 3)
+ if _, ok := m.first.RowID(20); ok {
+ t.Fatal("RowID names an undeclared row below the table's viewport")
+ }
+
+ at := d.hit("source-3").Bounds
+ if err := d.driver.Scroll(gfx.ScrollEvent{X: at.X + 1, Y: at.Y + 1, DeltaY: 200, Precise: true}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := m.first.RowID(0); ok {
+ t.Fatal("RowID still names row 0 after the second page replaces the first")
+ }
+ m.press(t, d, 10)
+ m.press(t, d, 14)
+ if firstRowID == m.target {
+ t.Fatal("a second-page source row reused the old first-page identity")
+ }
+ hits := declaredRowHits(d, m.first.Rows())
+ for row, id := range hits {
+ if got, ok := m.first.RowID(row); !ok || got != id {
+ t.Fatalf("declared source row %d has RowID %v, %v, hit %v", row, got, ok, id)
+ }
+ }
+ if len(hits) == 0 {
+ t.Fatal("the second page declares no row hits")
+ }
+ for _, row := range []int{-1, 50, 100} {
+ if id, ok := m.first.RowID(row); ok || !id.IsZero() {
+ t.Fatalf("RowID(%d) = %v, %v, want zero, false", row, id, ok)
+ }
+ }
+}
+
+func TestTableRowsIdentitiesArePerTableAndStableInAnOuterScroll(t *testing.T) {
+ m := newRowIdentityModel(40)
+ d := openPage(t, func(c ui.C) {
+ ui.Column(c).Fill().Children(func() {
+ ui.Scroll(c).Grow(1).Children(func() {
+ m.table(c, &m.first)
+ m.table(c, &m.second)
+ })
+ })
+ })
+ firstID, firstOK := m.first.RowID(0)
+ secondID, secondOK := m.second.RowID(0)
+ if !firstOK || !secondOK || m.first.Rows().IsZero() || m.second.Rows().IsZero() {
+ t.Fatal("the default scopes do not expose declared rows")
+ }
+ if firstID == secondID || m.first.Rows() == m.second.Rows() {
+ t.Fatal("two tables on one page share row identities")
+ }
+ if row, ok := firstID.In(m.second.Rows()); ok || row != 0 {
+ t.Fatal("the first table's row belongs to the second table's scope")
+ }
+ for _, hit := range declaredRowHits(d, m.first.Rows()) {
+ if hit == secondID {
+ t.Fatal("the default scope passed to Table changed its row identity")
+ }
+ }
+
+ at := d.hit("source-1").Bounds
+ if err := d.driver.Scroll(gfx.ScrollEvent{X: at.X + 1, Y: at.Y + 1, DeltaY: 20, Precise: true}); err != nil {
+ t.Fatal(err)
+ }
+ movedID, movedOK := m.first.RowID(0)
+ if !movedOK || movedID != firstID {
+ t.Fatal("a row's identity changed as an outer scroll moved it on screen")
+ }
+ if row, ok := movedID.In(m.second.Rows()); ok || row != 0 {
+ t.Fatal("a moved row entered another table's scope")
+ }
+
+ d.repaint()
+ if next, ok := m.first.RowID(0); !ok || next != firstID {
+ t.Fatal("the first table's identity changed after a repaint with unchanged layout")
+ }
+}
+
+func TestTableRowIDLastDeclarationAndZeroState(t *testing.T) {
+ var zero ui.TableState
+ if !zero.Rows().IsZero() {
+ t.Fatal("a zero state has a row scope before its first paint")
+ }
+ if id, ok := zero.RowID(0); ok || !id.IsZero() {
+ t.Fatal("a zero state has a row identity before its first paint")
+ }
+
+ m := newRowIdentityModel(3)
+ tree := new(gfx.Tree)
+ page := tree.Feature("row-identity-test")
+ rows := tree.Sub(page, tree.Name("rows"))
+ other := tree.Sub(page, tree.Name("other"))
+ second := false
+ var state ui.TableState
+ d := openPage(t, func(c ui.C) {
+ ui.Table(c, &state, ui.TableSpec{Rows: rows, Source: &m.rows, RowHeight: 20})
+ if second {
+ ui.Table(c, &state, ui.TableSpec{Rows: other, Source: &m.rows, RowHeight: 20})
+ }
+ })
+ firstID, ok := state.RowID(0)
+ if !ok || state.Rows() != rows {
+ t.Fatal("an explicitly named table did not publish its declared scope")
+ }
+ second = true
+ d.repaint()
+ lastID, ok := state.RowID(0)
+ if !ok || state.Rows() != other || lastID == firstID {
+ t.Fatal("the accessors do not describe the last table's declaration")
+ }
+
+ m.rows = m.rows[:1]
+ d.repaint()
+ if id, ok := state.RowID(1); ok || !id.IsZero() {
+ t.Fatal("a row removed from the source retains its old identity")
+ }
+ if id, ok := state.RowID(0); !ok || id != lastID {
+ t.Fatal("the remaining source row's identity was not preserved")
+ }
+}
diff --git a/ui/scroll.go b/ui/scroll.go
index c6255844..31bc71fa 100644
--- a/ui/scroll.go
+++ b/ui/scroll.go
@@ -5,7 +5,7 @@ import (
"github.com/vivaui/viva/gfx"
)
-// ScrollState is the scroll position of one ScrollArea. It lives in the view, keyed
+// ScrollState is a scroll position: one ScrollArea's, or a Table's. It lives in the view, keyed
// by the area's identity, so the application keeps nothing: declare the area with an
// identity and give the wheel to the view.
//
diff --git a/ui/select.go b/ui/select.go
index c6cbdb36..2eb3c572 100644
--- a/ui/select.go
+++ b/ui/select.go
@@ -165,6 +165,7 @@ type selectDecl struct {
scope gfx.Scope
state *SelectState
labels []string
+ icons []gfx.Icon
selected int
canvas *gfx.Canvas
popup int32 // the Popup that places the list, or -1 when it is closed
@@ -307,6 +308,18 @@ func (b Box) Placeholder(value string) Box {
return b
}
+// Icons sets the icons a Select paints before its chosen value on the trigger and
+// before each option's label in the open list, one per option and indexed like the
+// labels: what SelectIcon's icon function names. A select given none of them paints
+// exactly what it always has; a SelectBase keeps them for its caller and paints
+// none, as it does the rest of the look.
+func (b Box) Icons(icons []gfx.Icon) Box {
+ if n := b.n(); n.kind == kindSelect {
+ b.f.widgets.selects[n.aux].icons = icons
+ }
+ return b
+}
+
// Picked reports which option of a Select this paint is delivering a click on, and
// keeps the select's state: the trigger opens and closes the list, an option or a press
// outside closes it, the arrows move its window.
@@ -376,6 +389,9 @@ func (f *frame) paintSelect(n *node) {
options.Unpainted = n.flags&flagThemed == 0
if d.selected >= 0 && d.selected < count {
options.Value = d.labels[d.selected]
+ if d.selected < len(d.icons) {
+ options.Icon = d.icons[d.selected]
+ }
}
if options.Label == "" {
options.Label = n.text
@@ -406,7 +422,11 @@ func (f *frame) paintSelect(n *node) {
f.widgets.rows = append(f.widgets.rows, Rect{})
}
for index := first; index < first+visible && index < count; index++ {
- f.widgets.rows[int(d.rects)+index] = control.AddItem(components.SelectItem{ID: d.scope.At(index), Label: d.labels[index], Selected: index == d.selected})
+ item := components.SelectItem{ID: d.scope.At(index), Label: d.labels[index], Selected: index == d.selected}
+ if index < len(d.icons) {
+ item.Icon = d.icons[index]
+ }
+ f.widgets.rows[int(d.rects)+index] = control.AddItem(item)
}
if visible < count {
control.AddScrollDown(d.scope.At(count+4), first+visible >= count)
diff --git a/ui/select_test.go b/ui/select_test.go
index 97a26546..0b3ed30f 100644
--- a/ui/select_test.go
+++ b/ui/select_test.go
@@ -16,6 +16,17 @@ var (
selLabels = []string{"Small", "Medium", "Large"}
)
+// selIcons are the icons of the three labels, a stroke at each one's own height:
+// an icon painted on another option's row shows in the triangles.
+var selIcons = []gfx.Icon{
+ {ViewBox: 24, Stroke: 2, Cap: gfx.CapRound, Join: gfx.JoinRound,
+ Commands: []gfx.PathCommand{gfx.MoveTo(4, 6), gfx.LineTo(20, 6)}},
+ {ViewBox: 24, Stroke: 2, Cap: gfx.CapRound, Join: gfx.JoinRound,
+ Commands: []gfx.PathCommand{gfx.MoveTo(4, 10), gfx.LineTo(20, 10)}},
+ {ViewBox: 24, Stroke: 2, Cap: gfx.CapRound, Join: gfx.JoinRound,
+ Commands: []gfx.PathCommand{gfx.MoveTo(4, 14), gfx.LineTo(20, 14)}},
+}
+
// A Select looks exactly like the components.Select it is painted by, closed and
// open, with a value and with a placeholder.
func TestSelectIsPaintedLikeTheComponent(t *testing.T) {
@@ -59,6 +70,73 @@ func TestSelectIsPaintedLikeTheComponent(t *testing.T) {
}
}
+// A Select with icons is painted exactly like the components.Select an application
+// would call with the same icons: the chosen option's before the value on the
+// trigger, each option's own before its label on its row — a select that was given
+// no icon function paints what TestSelectIsPaintedLikeTheComponent pins above.
+func TestSelectWithIconsIsPaintedLikeTheComponent(t *testing.T) {
+ theme := gfx.LightTheme()
+ for _, test := range []struct {
+ open bool
+ selected int
+ }{{false, 1}, {false, -1}, {true, 1}, {true, -1}, {true, 2}} {
+ name := fmt.Sprintf("open=%v selected=%d", test.open, test.selected)
+ flexed, direct := testCanvas(), testCanvas()
+ state := SelectState{Open: test.open}
+ viewport := gfx.Rect{Width: 400, Height: 300}
+ var view View
+ view.Draw(flexed, theme, viewport, func(f *Frame) {
+ f.Column().Pad(10).Children(func() {
+ f.Select(selScope, &state, selLabels, test.selected).Placeholder("Size").Width(200).Icons(selIcons)
+ })
+ })
+ options := components.SelectOptions{
+ TriggerID: selScope.At(3), Label: "Size", Placeholder: "Size", Open: test.open,
+ Layer: 1, Scope: selScope.At(5), DismissID: selScope.At(4), Viewport: viewport,
+ }
+ if test.selected >= 0 {
+ options.Value, options.Icon = selLabels[test.selected], selIcons[test.selected]
+ }
+ if test.open {
+ options.Content = gfx.Rect{X: 10, Y: 10 + 32 + 4, Width: 200, Height: 8 + 32*3}
+ }
+ control := components.BeginSelect(direct, theme, gfx.Rect{X: 10, Y: 10, Width: 200}, options)
+ if test.open {
+ for i, label := range selLabels {
+ control.AddItem(components.SelectItem{ID: selScope.At(i), Label: label, Selected: i == test.selected, Icon: selIcons[i]})
+ }
+ }
+ sameCanvas(t, name, flexed, direct)
+ }
+}
+
+// Two options that share a label keep their own icon: the rows of a list are not
+// interchangeable behind one label.
+func TestSelectIconsFollowTheirOwnRows(t *testing.T) {
+ theme := gfx.LightTheme()
+ labels := []string{"Same", "Same"}
+ state := SelectState{Open: true}
+ flexed, direct := testCanvas(), testCanvas()
+ viewport := gfx.Rect{Width: 300, Height: 200}
+ var view View
+ view.Draw(flexed, theme, viewport, func(f *Frame) {
+ f.Column().Pad(10).Children(func() {
+ f.Select(selScope, &state, labels, 0).Placeholder("Pick").Width(200).Icons(selIcons)
+ })
+ })
+ options := components.SelectOptions{
+ TriggerID: selScope.At(2), Label: "Pick", Placeholder: "Pick", Open: true,
+ Layer: 1, Scope: selScope.At(4), DismissID: selScope.At(3), Viewport: viewport,
+ Content: gfx.Rect{X: 10, Y: 10 + 32 + 4, Width: 200, Height: 8 + 32*2},
+ Value: "Same", Icon: selIcons[0],
+ }
+ control := components.BeginSelect(direct, theme, gfx.Rect{X: 10, Y: 10, Width: 200}, options)
+ for i, label := range labels {
+ control.AddItem(components.SelectItem{ID: selScope.At(i), Label: label, Selected: i == 0, Icon: selIcons[i]})
+ }
+ sameCanvas(t, "same-label options", flexed, direct)
+}
+
func TestSelectOpensChoosesAndClosesThroughItsClicks(t *testing.T) {
theme := gfx.LightTheme()
var state SelectState
@@ -165,6 +243,7 @@ func TestSelectAllocatesNothingOnceWarm(t *testing.T) {
view.Draw(canvas, theme, gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
f.Column().Pad(10).Gap(8).Children(func() {
f.Select(selScope, &state, selLabels, 1)
+ f.Select(selScope, &state, selLabels, 1).Icons(selIcons)
f.Select(selScope, &closed, selLabels, -1).Placeholder("Pick").Small()
})
})
diff --git a/ui/style.go b/ui/style.go
index a0c2d481..df4f4a57 100644
--- a/ui/style.go
+++ b/ui/style.go
@@ -45,6 +45,7 @@ type Look struct {
color, ring Color
ringWidth, opacity float32
shadow gfx.ShadowLevel
+ dashed bool
}
type lookFields uint8
@@ -52,6 +53,7 @@ type lookFields uint8
const (
lookBackground lookFields = 1 << iota
lookBorder
+ lookDashed
lookColor
lookRing
lookShadow
@@ -73,6 +75,14 @@ func (l Look) Border(c Color) Look {
return l
}
+// Dashed switches between the dashed and solid border when this look applies.
+// Unlike colors and opacity, a pattern does not blend part way through a transition;
+// it changes when the transition is half done.
+func (l Look) Dashed(dashed bool) Look {
+ l.set, l.dashed = l.set|lookDashed, dashed
+ return l
+}
+
// Color replaces the color of a Text or the tint of an Icon.
func (l Look) Color(c Color) Look {
l.set, l.color = l.set|lookColor, c
@@ -271,12 +281,13 @@ type resolved struct {
background, border, color, ring Color
ringWidth, opacity float32
shadow gfx.ShadowLevel
+ dashed bool
}
func (f *frame) resolve(n *node, extra *extra) resolved {
s := resolved{
background: n.background, border: extra.border, color: n.color, ring: extra.ring,
- ringWidth: extra.ringWidth, opacity: 1 - extra.fade, shadow: extra.shadow,
+ ringWidth: extra.ringWidth, opacity: 1 - extra.fade, shadow: extra.shadow, dashed: extra.borderDashed,
}
if n.flags&flagThemed != 0 {
f.themedDefaults(n, extra, &s)
@@ -326,6 +337,9 @@ func (s *resolved) apply(look *Look, progress float32) {
if look.set&lookBorder != 0 {
s.border = s.border.Mix(look.border, progress)
}
+ if look.set&lookDashed != 0 && progress >= 0.5 {
+ s.dashed = look.dashed
+ }
if look.set&lookColor != 0 {
s.color = s.color.Mix(look.color, progress)
}
diff --git a/ui/style_test.go b/ui/style_test.go
index dfb79bb8..2147d9d1 100644
--- a/ui/style_test.go
+++ b/ui/style_test.go
@@ -329,6 +329,70 @@ func TestGradientFillsTheElementUnderItsBorderAndFades(t *testing.T) {
}
}
+// A dashed border is a line over the fill, never a solid shape behind it, and
+// a look can switch the pattern back to solid while hovered. Both go through
+// the stroke pipeline the element's own stroke does.
+func TestDashedBorderAndStatefulLookUseTheStrokePipeline(t *testing.T) {
+ var view View
+ paint := func(hovered gfx.ID) *gfx.Canvas {
+ canvas := styleCanvas(hovered, gfx.ID{}, gfx.ID{}, false)
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 80, Height: 50}, func(f *Frame) {
+ f.Row().Size(80, 50).Radius(6).Background(colorRest).Border(2, colorHover).Dashed().
+ Hit(styleRow, "row").Hover(Look{}.Dashed(false))
+ })
+ return canvas
+ }
+ rest := paint(gfx.ID{})
+ if len(rest.Triangles()) == 0 {
+ t.Fatal("the dashed border painted no triangles at rest")
+ }
+ for _, rectangle := range rest.Rectangles() {
+ if rectangle.Color == colorHover {
+ t.Fatal("the dashed border is a solid rectangle behind its fill")
+ }
+ }
+ hovered := paint(styleRow)
+ solid := false
+ for _, rectangle := range hovered.Rectangles() {
+ if rectangle.Color == colorHover {
+ solid = true
+ }
+ }
+ if !solid {
+ t.Fatal("the hover look did not take the border back to a solid one behind its fill")
+ }
+ for _, triangle := range hovered.Triangles() {
+ if triangle.Color == colorHover {
+ t.Error("a dashed-border triangle survived the hover look that made the border solid")
+ }
+ }
+}
+
+// DrawContext.DashedBorder paints a dashed outline inside the element with its
+// fade, so a row that draws its own dashed frame follows the element's opacity
+// like every other thing it paints.
+func TestDrawContextDashedBorderIsFadedWithTheElement(t *testing.T) {
+ var view View
+ canvas := testCanvas()
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 80, Height: 50}, func(f *Frame) {
+ f.Row().Size(80, 50).Opacity(0.5).Draw(func(d *DrawContext) {
+ d.DashedBorder(d.Rect, 4, 1, colorPressed)
+ })
+ })
+ painted := false
+ faded := colorPressed
+ faded.Alpha *= 0.5
+ for _, triangle := range canvas.Triangles() {
+ if triangle.Color != faded {
+ t.Fatalf("a dash has color %+v, want the element-faded %+v", triangle.Color, faded)
+ }
+ painted = true
+ }
+ if !painted {
+ t.Fatal("DrawContext.DashedBorder painted nothing")
+ }
+}
+
// styled declares a page with every look, ring, gradient and fade, to count what
// it allocates.
func styled(view *View, canvas *gfx.Canvas, theme gfx.Theme) {
diff --git a/ui/table.go b/ui/table.go
index ccd9f798..d3ef258b 100644
--- a/ui/table.go
+++ b/ui/table.go
@@ -54,27 +54,47 @@ type TableSpec struct {
CellPadding float32
// Striped shades every second row.
Striped bool
+ // FrozenColumns pins the first columns that have explicit widths to the
+ // table's left edge while the rest scroll under OffsetX: the vertical
+ // identity rows keep, a header's sort hits keep their place in paint
+ // order, and the horizontal scrollbar and drag mapping account for the
+ // frozen width. Zero, the default, keeps every column moving with the
+ // scroll, as tables always have; a table that does not scroll sideways
+ // ignores it.
+ FrozenColumns int
// Scrollbar is the identity of the vertical bar a table that scrolls itself
- // draws under its columns; zero paints the bar without an action. A table
- // inside a ScrollArea takes the area's bar instead and ignores this.
+ // draws under its columns; zero paints the bar without an action.
Scrollbar gfx.ID
- // ScrollbarLabel names that bar for assistive technology; empty is "Table
- // rows".
- ScrollbarLabel string
- // ScrollbarSize is the bar's thickness; zero keeps the thin hover default.
+ // HorizontalScrollbar is the identity of the bar below the body when the
+ // columns overflow their viewport; zero paints that bar without an action.
+ HorizontalScrollbar gfx.ID
+ // ScrollbarLabel names the vertical bar for assistive technology; empty is
+ // "Table rows". HorizontalScrollbarLabel does the same for the horizontal
+ // bar, whose default is "Table columns".
+ ScrollbarLabel string
+ HorizontalScrollbarLabel string
+ // ScrollbarSize is the bars' thickness; zero keeps the thin hover default.
ScrollbarSize float32
- // ScrollbarGutter is the strip reserved beside the columns for the bar; zero
- // lets the bar sit over their edge. The columns lose that width.
+ // ScrollbarGutter is the strip reserved beside or below the cells for an
+ // overflowing bar; zero lets the bars sit over their edges. The columns lose
+ // the width of a vertical gutter, and the rows the height of a horizontal one.
ScrollbarGutter float32
+ // HideHoverScrollbars hides the un-named vertical hover overlay for an
+ // application that paints its own vertical bar beside the table. It does not
+ // change scrollbar geometry, the horizontal overlay, scrolling, or any bar
+ // with an explicit Scrollbar ID. The default paints the overlay as usual.
+ HideHoverScrollbars bool
}
// tableRoute is a Table of the last paint that scrolls itself, and where: the
// page gives it the wheel and the keys that reach it.
type tableRoute struct {
- state *TableState
- bounds Rect
- scrolls bool
- bar gfx.ID
+ state *TableState
+ bounds Rect
+ scrolls bool
+ sideways bool
+ bar gfx.ID
+ horizontal gfx.ID
}
// TableState is what a Table keeps between frames, in the application's state: the
@@ -82,16 +102,19 @@ type tableRoute struct {
// scrolled. The zero value is a table with nothing selected, unsorted, at the top.
type TableState struct {
// Scroll is the scroll position of a table with a height of its own (a Height,
- // Fill or Grow that is less than its rows need). Give config.OnScroll
+ // Fill or Grow that is less than its rows need), and, when its explicit column
+ // widths are wider than it, how far across it is scrolled. Give config.OnScroll
// state.Wheel, or call it for the table under the pointer. A table in a
- // ScrollArea is as tall as its rows and needs none of it.
- Scroll ScrollState
- selected int32 // 1 + the row, or 0
- sortColumn int32 // 1 + the column, or 0
- descending bool
- reveal bool // scroll the selected row into view at the next paint
- rows, page int32 // as of the last paint: how many rows, how many fit at once
- scope gfx.Scope // the rows' identities, to tell a key meant for the table
+ // ScrollArea is as tall as its rows, as wide as its area makes it, and needs
+ // none of it.
+ Scroll ScrollState
+ selected int32 // 1 + the row, or 0
+ sortColumn int32 // 1 + the column, or 0
+ descending bool
+ reveal bool // scroll the selected row into view at the next paint
+ rows, page int32 // as of the last paint: how many rows, how many fit at once
+ rowFirst, rowEnd int32 // the declared source rows: rowFirst <= i < rowEnd
+ scope gfx.Scope // the rows' identities, to tell a key meant for the table
}
// Selected is the selected row, or -1.
@@ -174,6 +197,20 @@ type tableDecl struct {
// the rows on screen are read and painted; given a smaller height it scrolls its
// rows under a fixed header, by state.Scroll.
//
+// A table of explicit column widths wider than itself also scrolls left and
+// right under its fixed header, the header riding along with its rows: the
+// horizontal wheel, Shift with the wheel, or a horizontal scrollbar
+// (HorizontalScrollbar) moves the columns, and state.Scroll reports how far
+// across it is. FrozenColumns pins the first columns to the table's left edge
+// while the rest scroll: the pinned band covers what passes under it, a rule
+// at its right edge keeps the two apart, hits over the band still name the
+// row and column under the pointer whatever the offset, and the horizontal
+// scrollbar maps exactly the columns that move — MaxX reaches every scrolled
+// pixel while the offset never moves the pinned ones. A table whose last
+// column has no width keeps the layout it always had: the last column takes
+// what is left, however narrow that is. A table inside a ScrollArea is never
+// scrolled across by itself; the area owns its width, sideways or not.
+//
// tbl := f.Table(&state, ui.TableSpec{Rows: rows, Header: heads, Columns: cols, Headers: titles, Sortable: sortable, Source: &data})
// if row, ok := tbl.RowClicked(); ok { state.Select(row) }
// if col, ok := tbl.HeaderClicked(); ok { state.SortBy(col); data.Sort(state.Sort()) }
@@ -235,6 +272,47 @@ func (f *frame) measureTable(n *node) {
n.pref = [2]float32{width, t.headerHeight(f.theme) + float32(t.rows)*t.rowHeight(f.theme)}
}
+// tableColumnsWidth is the total width a table's columns declare: the fixed
+// widths of every column, zero for the flexible one that takes what is left.
+func tableColumnsWidth(columns []components.TableColumn) float32 {
+ width := float32(0)
+ for _, column := range columns {
+ width += max(column.Width, 0)
+ }
+ return width
+}
+
+// frozenColumnCount is how many leading columns a table with FrozenColumns
+// count pins: the count itself, cut at the first column without a width of
+// its own, which takes what is left and pins nothing after it.
+func frozenColumnCount(columns []components.TableColumn, count int) int {
+ if count <= 0 {
+ return 0
+ }
+ frozen := min(count, len(columns))
+ for index := 0; index < frozen; index++ {
+ if columns[index].Width <= 0 {
+ return index
+ }
+ }
+ return frozen
+}
+
+// frozenColumnsWidth is the combined width of the columns a table freezes.
+// Every one of them must declare an explicit width, so a flexible column ends
+// the frozen band: it takes what is left and pins nothing. The count was cut
+// at it already. Zero when the table pins nothing.
+func frozenColumnsWidth(columns []components.TableColumn, count int) float32 {
+ width := float32(0)
+ for index := 0; index < count && index < len(columns); index++ {
+ width += columns[index].Width
+ }
+ return width
+}
+
+// tableFrozenSeparator is how wide the rule at a frozen band's right edge is.
+const tableFrozenSeparator = float32(2)
+
// scrollAncestor is the state and viewport of the ScrollArea nearest around element n.
func (f *frame) scrollAncestor(n *node) (*ScrollState, Rect) {
for p := n.parent; p >= 0; p = f.nodes[p].parent {
@@ -256,29 +334,75 @@ func (f *frame) paintTable(n *node) {
canvas := f.canvas
body := Rect{X: r.X, Y: r.Y + headerHeight, Width: r.Width, Height: max(r.Height-headerHeight, 0)}
content := float32(t.rows) * rowHeight
+ gutter := max(t.spec.ScrollbarGutter, 0)
+ // A table whose last column has no width keeps the layout it always had: the
+ // last column takes what is left, however narrow that is. Only explicit widths
+ // that do not fit move the whole grid across, with the header riding along —
+ // and never inside a ScrollArea, whose viewport (sideways or not) owns the
+ // table's width; such a table is only ever clipped.
+ declaredWidth := tableColumnsWidth(t.spec.Columns)
+ flexibleLast := len(t.spec.Columns) == 0 || t.spec.Columns[len(t.spec.Columns)-1].Width <= 0
+ selfContained := true
+ if ancestor, _ := f.scrollAncestor(n); ancestor != nil {
+ selfContained = false
+ }
+ verticalGutter := float32(0)
+ if body.Height+0.5 < content {
+ verticalGutter = min(gutter, body.Width)
+ body.Width -= verticalGutter
+ }
+ sideways := selfContained && !flexibleLast && declaredWidth > body.Width+0.5
+ horizontalGutter := float32(0)
+ if sideways {
+ horizontalGutter = min(gutter, body.Height)
+ body.Height -= horizontalGutter
+ }
scrolls := body.Height+0.5 < content
- gutter := float32(0)
- if scrolls {
- gutter = min(max(t.spec.ScrollbarGutter, 0), r.Width)
- body.Width = r.Width - gutter
+ contentWidth := body.Width
+ if !flexibleLast && declaredWidth > contentWidth {
+ contentWidth = declaredWidth
}
if f.page != nil {
- f.page.tables = append(f.page.tables, tableRoute{state: t.state, bounds: r, scrolls: scrolls, bar: t.spec.Scrollbar})
+ f.page.tables = append(f.page.tables, tableRoute{
+ state: t.state, bounds: r, scrolls: scrolls, sideways: sideways,
+ bar: t.spec.Scrollbar, horizontal: t.spec.HorizontalScrollbar,
+ })
}
- var offset float32
+ var offset, offsetX float32
s := &t.state.Scroll
+ s.sideways = true // TableState.Wheel accepts both axes; only the overflows below can move.
s.Content, s.Viewport = content, min(body.Height, content)
if scrolls {
s.Viewport = body.Height
}
+ s.ContentWidth, s.ViewportWidth = contentWidth, body.Width
+ if !sideways {
+ s.OffsetX = 0 // an outer ScrollArea owns the table's geometry instead
+ }
t.state.rows, t.state.scope = int32(t.rows), t.spec.Rows
if t.state.reveal {
f.revealSelected(n, t, body, rowHeight, headerHeight, scrolls)
}
s.Offset = min(max(s.Offset, 0), s.Max())
+ s.OffsetX = min(max(s.OffsetX, 0), s.MaxX())
if scrolls {
offset = snapScroll(s.Offset)
}
+ if sideways {
+ offsetX = snapScroll(s.OffsetX)
+ }
+ left := body.X - offsetX
+ // Frozen columns pin a band of the body's leading width while the rest
+ // scrolls under the offset. A band with no width, or as wide as the body,
+ // pins nothing — there would be no window left to scroll in.
+ frozenWidth := float32(0)
+ if sideways {
+ frozenWidth = frozenColumnsWidth(t.spec.Columns, frozenColumnCount(t.spec.Columns, t.spec.FrozenColumns))
+ if frozenWidth <= 0 || frozenWidth >= body.Width {
+ frozenWidth = 0
+ }
+ }
+ seam := body.X + frozenWidth
// The rows on screen: the table's body, cut by every clip it sits in.
top := body.Y - offset
@@ -305,87 +429,143 @@ func (f *frame) paintTable(n *node) {
first = min(max(int(math.Floor(float64((lo-top)/rowHeight))), 0), t.rows)
last = min(max(int(math.Ceil(float64((hi-top)/rowHeight))), first), t.rows)
}
+ t.state.rowFirst, t.state.rowEnd = int32(first), int32(last)
if !scrolls && !sticky {
- f.paintTableHeader(t, Rect{X: r.X, Y: r.Y, Width: body.Width, Height: r.Height}, headerHeight, rowHeight)
+ // Height: r.Height lets paintTableHeader keep its rule of the header's
+ // bottom rule needing room below it.
+ f.paintTableHeader(t, Rect{X: left, Y: r.Y, Width: contentWidth, Height: r.Height}, body, headerHeight, rowHeight, sideways && frozenWidth == 0, frozenWidth)
}
if last > first {
extra := float32(0)
if top+float32(last)*rowHeight < r.Y+r.Height {
extra = 1 // a divider under the last row painted, when something follows or the table is taller
}
- if scrolls {
+ if scrolls || sideways {
canvas.Clip(body)
} else if sticky {
below := headerY + headerHeight
canvas.Clip(Rect{X: r.X, Y: below, Width: body.Width, Height: max(r.Y+r.Height-below, 0)})
}
- table := components.BeginTable(canvas, f.theme, Rect{X: r.X, Y: top + float32(first)*rowHeight, Width: body.Width, Height: float32(last-first)*rowHeight + extra}, t.spec.Columns).
- RowHeight(rowHeight).FontSize(t.spec.FontSize).CellPadding(t.spec.CellPadding)
- w := &f.widgets
- if cap(w.cells) < len(t.spec.Columns) {
- w.cells = make([]string, len(t.spec.Columns))
+ // The scrolling pass paints every column of the on-screen rows, and the
+ // clip does the work of the segmentation: the frozen columns fall
+ // outside the window the clip leaves and stay out of it.
+ if sideways && frozenWidth > 0 {
+ canvas.Clip(Rect{X: seam, Y: body.Y, Width: max(body.X+body.Width-seam, 0), Height: body.Height})
}
- cells := w.cells[:len(t.spec.Columns)]
- for row := first; row < last; row++ {
- for column := range cells {
- cells[column] = t.spec.Source.Cell(row, column)
- }
- if t.spec.Striped && row%2 == 1 {
- stripe := f.theme.Muted
- stripe.Alpha *= 0.6
- canvas.Fill(Rect{X: r.X, Y: top + float32(row)*rowHeight, Width: body.Width, Height: rowHeight}, stripe)
- }
- // A press on the bar owns the pointer: the row under it neither
- // highlights nor takes a click while the bar is held.
- id := t.spec.Rows.At(row)
- if t.state.Scroll.dragging {
- id = gfx.ID{}
- }
- table.RowState(id, cells, components.TableRowState{Selected: row == t.state.Selected()})
+ f.paintTableRows(t, Rect{X: left, Y: top + float32(first)*rowHeight, Width: contentWidth, Height: float32(last-first)*rowHeight + extra}, first, last, top, contentWidth, rowHeight)
+ if sideways && frozenWidth > 0 {
+ // The frozen band rides above, opaque, on the unscrolled origin:
+ // the same rows again, clipped to the band, with the columns
+ // painted from the body's left edge rather than the offset one.
+ canvas.Unclip()
+ canvas.Clip(Rect{X: body.X, Y: body.Y, Width: frozenWidth, Height: body.Height})
+ canvas.Fill(Rect{X: body.X, Y: top + float32(first)*rowHeight, Width: frozenWidth, Height: float32(last-first)*rowHeight + extra}, f.theme.Background)
+ f.paintTableRows(t, Rect{X: body.X, Y: top + float32(first)*rowHeight, Width: contentWidth, Height: float32(last-first)*rowHeight + extra}, first, last, top, contentWidth, rowHeight)
+ canvas.Unclip()
}
- if scrolls || sticky {
+ if scrolls || sideways || sticky {
canvas.Unclip()
}
+ if sideways && frozenWidth > 0 {
+ canvas.Fill(Rect{X: seam - tableFrozenSeparator, Y: top + float32(first)*rowHeight, Width: tableFrozenSeparator, Height: float32(last-first)*rowHeight + extra}, f.theme.Border)
+ }
}
if scrolls || sticky {
if headerHeight > 0 {
canvas.Fill(Rect{X: r.X, Y: headerY, Width: body.Width, Height: headerHeight}, f.theme.Background)
}
- f.paintTableHeader(t, Rect{X: r.X, Y: headerY, Width: body.Width, Height: r.Height}, headerHeight, rowHeight)
+ f.paintTableHeader(t, Rect{X: left, Y: headerY, Width: contentWidth, Height: r.Height}, body, headerHeight, rowHeight, sideways && frozenWidth == 0, frozenWidth)
}
- if scrolls {
- f.paintTableScrollbar(t, body, content, offset)
+ if scrolls || sideways {
+ f.paintTableScrollbars(t, r, body, gfx.Size{Width: contentWidth, Height: content}, offsetX, offset, frozenWidth)
}
}
-// paintTableScrollbar paints and drags the bar of a Table that scrolls itself.
-// The bar is the hover style announced for the area: the page's pointer reveals
-// it, and it sits in the gutter beside the columns when one is asked for.
-func (f *frame) paintTableScrollbar(t *tableDecl, body Rect, content, offset float32) {
- bounds := Rect{X: body.X, Y: body.Y, Width: body.Width + float32(max(t.spec.ScrollbarGutter, 0)), Height: body.Height}
- if t.spec.ScrollbarGutter <= 0 {
- bounds.Width = body.Width
+// paintTableRows paints the on-screen rows of a table once, with the same
+// components.Table calls an application would make by hand. The caller has
+// already clipped to the band the rows belong to, and rows keeps the whole
+// grid's geometry: every column paints, and the clip does the segmentation.
+func (f *frame) paintTableRows(t *tableDecl, rows Rect, first, last int, top, contentWidth, rowHeight float32) {
+ canvas := f.canvas
+ table := components.BeginTable(canvas, f.theme, rows, t.spec.Columns).
+ RowHeight(rowHeight).FontSize(t.spec.FontSize).CellPadding(t.spec.CellPadding)
+ w := &f.widgets
+ if cap(w.cells) < len(t.spec.Columns) {
+ w.cells = make([]string, len(t.spec.Columns))
+ }
+ cells := w.cells[:len(t.spec.Columns)]
+ for row := first; row < last; row++ {
+ for column := range cells {
+ cells[column] = t.spec.Source.Cell(row, column)
+ }
+ if t.spec.Striped && row%2 == 1 {
+ stripe := f.theme.Muted
+ stripe.Alpha *= 0.6
+ canvas.Fill(Rect{X: rows.X, Y: top + float32(row)*rowHeight, Width: contentWidth, Height: rowHeight}, stripe)
+ }
+ // A press on the bar owns the pointer: the row under it neither
+ // highlights nor takes a click while the bar is held.
+ id := t.spec.Rows.At(row)
+ if t.state.Scroll.dragging {
+ id = gfx.ID{}
+ }
+ table.RowState(id, cells, components.TableRowState{Selected: row == t.state.Selected()})
}
- revealed := f.page == nil || f.page.pointerOver(bounds) || t.state.Scroll.dragging
+}
+
+// paintTableScrollbars paints and drags the bars of a Table that scrolls itself.
+// They share the body's content extents and offsets; a bar sits in the gutter the
+// spec asked for and over the body's edge when it did not.
+func (f *frame) paintTableScrollbars(t *tableDecl, r, body Rect, content gfx.Size, offsetX, offsetY, frozenWidth float32) {
+ revealed := f.page == nil || f.page.pointerOver(r) || t.state.Scroll.dragging
label := t.spec.ScrollbarLabel
if label == "" {
label = "Table rows"
}
+ horizontalLabel := t.spec.HorizontalScrollbarLabel
+ if horizontalLabel == "" {
+ horizontalLabel = "Table columns"
+ }
size := t.spec.ScrollbarSize
if size <= 0 && f.page != nil {
size = f.page.config.ScrollbarSize
}
- area := components.BeginScrollArea(f.canvas, f.theme, bounds, gfx.Size{Height: content}).
- Offset(0, offset).
+ // The bars sit at the table's own edges, over the body's clips: the component
+ // places their tracks from these bounds alone. The area they own is the whole
+ // table below its header, the body plus the gutters it reserved, so a bar
+ // lands in its gutter symmetrically with the other one and the corner between
+ // two bars stays clear. A gutter also moves the bars beside the viewport: the
+ // component takes the bars' strips out of it, matching the strip the columns
+ // and rows have already given up, so a drag maps the body it scrolls exactly.
+ // Without a gutter the bars keep sitting over the table's edges, as they had.
+ // An un-owned vertical hover thumb may duplicate an application's adjacent
+ // bar. Omit its component declaration: an explicit ID still paints and keeps
+ // its hit and drag, while the area's geometry and horizontal bar are unchanged.
+ verticalID, verticalLabel := t.spec.Scrollbar, label
+ if t.spec.HideHoverScrollbars && verticalID.IsZero() {
+ verticalLabel = ""
+ }
+ areaBounds := Rect{X: r.X, Y: body.Y, Width: r.Width, Height: r.Y + r.Height - body.Y}
+ area := components.BeginScrollArea(f.canvas, f.theme, areaBounds, content).
+ Offset(offsetX, offsetY).
+ Frozen(frozenWidth).
ScrollbarSize(size).
- HoverScrollbars(revealed).
- VerticalScrollbar(t.spec.Scrollbar, label, "")
+ HoverScrollbars(revealed)
+ if t.spec.ScrollbarGutter > 0 {
+ area = area.ReserveScrollbars()
+ }
+ area = area.
+ VerticalScrollbar(verticalID, verticalLabel, "").
+ HorizontalScrollbar(t.spec.HorizontalScrollbar, horizontalLabel, "")
geometry := area.Geometry()
area.EndContent()
if !t.spec.Scrollbar.IsZero() && geometry.MaxOffsetY > 0 {
f.dragScrollbar(&t.state.Scroll, t.spec.Scrollbar, geometry, axisY)
}
+ if !t.spec.HorizontalScrollbar.IsZero() && geometry.MaxOffsetX > 0 {
+ f.dragScrollbar(&t.state.Scroll, t.spec.HorizontalScrollbar, geometry, axisX)
+ }
}
// revealSelected scrolls the selected row into view after a key moved it: the table's
@@ -424,11 +604,45 @@ func (f *frame) revealSelected(n *node, t *tableDecl, body Rect, rowHeight, head
}
// paintTableHeader paints the header row and, over it, the hit regions and arrow
-// of the sortable columns.
-func (f *frame) paintTableHeader(t *tableDecl, r Rect, headerHeight, rowHeight float32) {
+// of the sortable columns. A sideways table clips it to the body's columns, and
+// one with frozen columns paints it in two passes clipped to each band, so the
+// header of the frozen columns rides above what scrolls under it.
+func (f *frame) paintTableHeader(t *tableDecl, r, viewport Rect, headerHeight, rowHeight float32, sideways bool, frozenWidth float32) {
if headerHeight <= 0 {
return
}
+ canvas := f.canvas
+ if sideways {
+ // The header scrolls with the body, clipped to the same columns; without
+ // an overflow it is laid out over the table, as it always was.
+ canvas.Clip(Rect{X: viewport.X, Y: r.Y, Width: viewport.Width, Height: headerHeight})
+ defer canvas.Unclip()
+ }
+ if frozenWidth > 0 {
+ // Frozen columns are only of a sideways table, whose clip is already
+ // on. The scrolling pass narrows it to the window past the frozen
+ // band; the band then paints its header above, opaque, from the
+ // body's unscrolled origin, and rules itself off from the columns
+ // that pass under it.
+ seam := viewport.X + frozenWidth
+ canvas.Clip(Rect{X: seam, Y: r.Y, Width: max(viewport.X+viewport.Width-seam, 0), Height: headerHeight})
+ f.paintTableHeaderRow(t, r, headerHeight, rowHeight)
+ canvas.Unclip()
+ canvas.Clip(Rect{X: viewport.X, Y: r.Y, Width: frozenWidth, Height: headerHeight})
+ canvas.Fill(Rect{X: viewport.X, Y: r.Y, Width: frozenWidth, Height: headerHeight}, f.theme.Background)
+ pinned := r
+ pinned.X = viewport.X
+ f.paintTableHeaderRow(t, pinned, headerHeight, rowHeight)
+ canvas.Fill(Rect{X: seam - tableFrozenSeparator, Y: r.Y, Width: tableFrozenSeparator, Height: headerHeight}, f.theme.Border)
+ canvas.Unclip()
+ return
+ }
+ f.paintTableHeaderRow(t, r, headerHeight, rowHeight)
+}
+
+// paintTableHeaderRow paints the header cells of r once, with the hover fills,
+// the sort arrows and the hit regions of the sortable columns.
+func (f *frame) paintTableHeaderRow(t *tableDecl, r Rect, headerHeight, rowHeight float32) {
canvas := f.canvas
bounds := Rect{X: r.X, Y: r.Y, Width: r.Width, Height: headerHeight}
cells := components.TableCellBounds(t.spec.Columns, bounds, f.widgets.rects[:0])
diff --git a/ui/table_frozen_test.go b/ui/table_frozen_test.go
new file mode 100644
index 00000000..fdc819f5
--- /dev/null
+++ b/ui/table_frozen_test.go
@@ -0,0 +1,319 @@
+package ui
+
+import (
+ "testing"
+
+ "github.com/vivaui/viva/gfx"
+)
+
+// frozenSpec is the spec of a 400x300 table of six 120-wide columns with the
+// first two frozen: 240 pinned at the left edge, 480 scrolling past them.
+func frozenSpec(state *TableState, rows TableSource) TableSpec {
+ return TableSpec{
+ Rows: tableScope, Header: headerScope, Columns: wideSixColumns, Headers: sixTitles,
+ Sortable: allSortableSix, Source: rows, FrozenColumns: 2,
+ }
+}
+
+// textX reports where the last text equal to value is painted: the pass a
+// table bands its columns into paints several times, and the last is the one
+// that showed. A return of false means no such text. The scrolled pass of a
+// banded table is the first, so its caller pairs this with firstX below.
+func textX(canvas *gfx.Canvas, value string) (float32, bool) {
+ x, seen := float32(0), false
+ for _, text := range canvas.Texts() {
+ if text.Value == value {
+ x, seen = text.Bounds.X, true
+ }
+ }
+ return x, seen
+}
+
+// firstX reports where the first text equal to value is painted.
+func firstX(canvas *gfx.Canvas, value string) (float32, bool) {
+ for _, text := range canvas.Texts() {
+ if text.Value == value {
+ return text.Bounds.X, true
+ }
+ }
+ return 0, false
+}
+
+// The frozen columns stay pinned at the table's left edge while the rest
+// scroll under OffsetX, the separator stays where it was, and MaxX keeps
+// the unfrozen value: the offset covers exactly the columns that move.
+func TestFrozenColumnsStayPutWhileTheRestScroll(t *testing.T) {
+ rows := make(TableRows, 100)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc", "dd", "ee", "ff"}
+ }
+ var state TableState
+ canvas := testCanvas()
+ var view View
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, frozenSpec(&state, &rows)).Fill()
+ })
+ })
+ }
+ paint()
+ if max := state.Scroll.MaxX(); max != 320 { // 720 declared in 400 shown, as unfrozen
+ t.Fatalf("MaxX = %v, want the unfrozen 320", max)
+ }
+ frozenHeader, _ := textX(canvas, "A")
+ frozenCell, _ := textX(canvas, "aa")
+ if frozenHeader != 8 || frozenCell != 8 { // the theme's cell padding
+ t.Fatalf("the frozen header sits at %v and its first cell at %v, want both at the table's left edge plus its padding", frozenHeader, frozenCell)
+ }
+ scrolledHeader, _ := firstX(canvas, "C")
+ scrolledCell, _ := firstX(canvas, "cc")
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 200, Precise: true})
+ paint()
+ if state.Scroll.OffsetX != 200 {
+ t.Fatalf("OffsetX = %v, want 200", state.Scroll.OffsetX)
+ }
+ if x, _ := textX(canvas, "A"); x != frozenHeader {
+ t.Errorf("the frozen header moved to %v, want it at %v", x, frozenHeader)
+ }
+ if x, _ := textX(canvas, "aa"); x != frozenCell {
+ t.Errorf("the frozen cell moved to %v, want it at %v", x, frozenCell)
+ }
+ if x, _ := firstX(canvas, "C"); x != scrolledHeader-200 {
+ t.Errorf("the scrolled header sits at %v, want %v", x, scrolledHeader-200)
+ }
+ if x, _ := firstX(canvas, "cc"); x != scrolledCell-200 {
+ t.Errorf("the scrolled cell sits at %v, want %v", x, scrolledCell-200)
+ }
+}
+
+// The horizontal scrollbar of a frozen table keeps the drag the component
+// maps: the bar belongs to the scrolling window past the frozen columns, and
+// a thumb dragged across it scrolls exactly what the offset can reach.
+func TestFrozenTableScrollbarDragsWhatScrolls(t *testing.T) {
+ rows := make(TableRows, 20)
+ var state TableState
+ canvas := testCanvas()
+ var view View
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, func() TableSpec {
+ spec := frozenSpec(&state, &rows)
+ spec.HorizontalScrollbar = gfx.Item(901)
+ return spec
+ }()).Fill()
+ })
+ })
+ }
+ paint()
+ bar := hitByID(canvas, gfx.Item(901))
+ if bar.ID.IsZero() {
+ t.Fatal("the horizontal scrollbar is not painted")
+ }
+ // The track starts past the 240 px of frozen columns and spans the 160
+ // px of scrolling window, less the corner of the vertical bar.
+ if bar.Bounds.X < 240 {
+ t.Fatalf("the horizontal bar starts at %v, past the frozen columns at 240", bar.Bounds.X)
+ }
+ deliver := func(kind gfx.PointerEventKind, x, y float32) bool {
+ return canvas.DeliverPointer(gfx.PointerEvent{Kind: kind, Button: gfx.PointerButtonPrimary, Target: gfx.Item(901), Captured: kind != gfx.PointerDown, X: x, Y: y}, gfx.Size{Width: 400, Height: 300}, func(*gfx.Canvas, gfx.Size) { paint() })
+ }
+ x, y := bar.Bounds.X+2, bar.Bounds.Y+bar.Bounds.Height/2
+ if ok := deliver(gfx.PointerDown, x, y); !ok {
+ t.Fatal("a press on the horizontal bar was not taken")
+ }
+ if !deliver(gfx.PointerMove, x+50, y) {
+ t.Fatal("the drag of the bar was not taken")
+ }
+ deliver(gfx.PointerUp, x+50, y)
+ paint()
+ if got := state.Scroll.OffsetX; got <= 0 || got > 320 {
+ t.Errorf("dragging the bar left the columns at %v, want some of the 320 px of range", got)
+ }
+}
+
+// The rows of a frozen table keep the identities the same table has
+// unfrozen: every row hit has its source row, and RowID answers for them.
+func TestFrozenTableKeepsRowIdentities(t *testing.T) {
+ rows := make(TableRows, 100)
+ paint := func(frozen int) (*TableState, []gfx.Hit) {
+ var state TableState
+ canvas := testCanvas()
+ var view View
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ spec := frozenSpec(&state, &rows)
+ spec.FrozenColumns = frozen
+ f.Table(&state, spec).Fill()
+ })
+ })
+ return &state, canvas.Hits()
+ }
+ plainState, _ := paint(0)
+ frozenState, frozenHits := paint(2)
+ if plainState.Rows() != frozenState.Rows() {
+ t.Fatalf("the frozen table named its rows %v, want the unfrozen %v", frozenState.Rows(), plainState.Rows())
+ }
+ if plainState.rowFirst != frozenState.rowFirst || plainState.rowEnd != frozenState.rowEnd {
+ t.Fatalf("the frozen table painted rows %d..%d, want the unfrozen %d..%d",
+ frozenState.rowFirst, frozenState.rowEnd, plainState.rowFirst, plainState.rowEnd)
+ }
+ rowNamed := func(hits []gfx.Hit, row int) bool {
+ for _, hit := range hits {
+ if index, ok := hit.ID.In(tableScope); ok && index == row {
+ return true
+ }
+ }
+ return false
+ }
+ for row := int(plainState.rowFirst); row < int(plainState.rowEnd); row++ {
+ _, ok := frozenState.RowID(row)
+ if !rowNamed(frozenHits, row) {
+ t.Errorf("row %d of the frozen table declares no hit", row)
+ }
+ if !ok {
+ t.Errorf("RowID does not answer for row %d of the frozen layout", row)
+ }
+ }
+}
+
+// A press on a frozen column's row still names that row after the table has
+// scrolled across: the overlay's hits stay above the scrolled ones, and the
+// row under the frozen band's clip is the one the pointer rests on.
+func TestFrozenColumnHitsKeepTheirRowsAfterScrolling(t *testing.T) {
+ rows := make(TableRows, 100)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc", "dd", "ee", "ff"}
+ }
+ var state TableState
+ canvas := testCanvas()
+ var view View
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, frozenSpec(&state, &rows)).Fill()
+ })
+ })
+ }
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 150, Precise: true})
+ paint()
+ // Every row hit of the frozen band keeps its row's own bounds: none of
+ // them follows the scrolled columns out from under the band.
+ seen := map[int]bool{}
+ for _, hit := range canvas.Hits() {
+ index, ok := hit.ID.In(tableScope)
+ if !ok {
+ continue
+ }
+ if seen[index] {
+ continue // one hit a row arrives twice, clipped to each band
+ }
+ if hit.Bounds.Width > 240.01 {
+ t.Errorf("row %d reaches %v px across, past the frozen band and into the scrolled columns", index, hit.Bounds.Width)
+ }
+ seen[index] = true
+ }
+ // The front-most hit at a point inside the frozen band is the row itself —
+ // the overlay's, not a scrolled one underneath.
+ for _, hit := range canvas.Hits() {
+ if _, ok := hit.ID.In(tableScope); !ok {
+ continue
+ }
+ if hit.Bounds.X != 0 || hit.Bounds.Width != 240 {
+ continue
+ }
+ row, _ := hit.ID.In(tableScope)
+ x, y := hit.Bounds.X+10, hit.Bounds.Y+hit.Bounds.Height/2
+ front := gfx.ID{}
+ hits := canvas.Hits()
+ for i := len(hits) - 1; i >= 0; i-- {
+ region := hits[i]
+ if region.IsLabel || region.Backdrop || region.ID.IsZero() {
+ continue
+ }
+ if x >= region.Bounds.X && x <= region.Bounds.Right() && y >= region.Bounds.Y && y <= region.Bounds.Bottom() {
+ front = region.ID
+ break
+ }
+ }
+ if index, ok := front.In(tableScope); !ok || index != row {
+ t.Fatalf("the front-most hit over frozen row %d is %v, want the row itself", row, front)
+ }
+ break
+ }
+}
+
+// A warm frozen table paints without allocating: the two bands cost no more
+// a frame than the wide table's one.
+func TestFrozenTableAllocatesNothingOnceWarm(t *testing.T) {
+ rows := &countingRows{n: 100}
+ var state TableState
+ canvas := testCanvas()
+ var view View
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ spec := frozenSpec(&state, rows)
+ spec.Scrollbar, spec.HorizontalScrollbar = gfx.Item(900), gfx.Item(901)
+ f.Table(&state, spec).Fill()
+ })
+ })
+ }
+ paint()
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 40, Precise: true})
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaY: 90, Precise: true})
+ for range 3 {
+ paint()
+ }
+ if allocs := testing.AllocsPerRun(100, paint); allocs != 0 {
+ t.Fatalf("a warm frozen table allocates %v times a frame, want 0", allocs)
+ }
+}
+
+func BenchmarkFrozenTable(b *testing.B) {
+ rows := &countingRows{n: 1000}
+ var state TableState
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 60, Precise: true})
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaY: 3000, Precise: true})
+ canvas := testCanvas()
+ view := View{}
+ b.ReportAllocs()
+ for b.Loop() {
+ canvas.Reset()
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ spec := frozenSpec(&state, rows)
+ spec.Scrollbar, spec.HorizontalScrollbar = gfx.Item(900), gfx.Item(901)
+ f.Table(&state, spec).Fill()
+ })
+ })
+ }
+}
+
+// A table whose FrozenColumns pins nothing — zero, negative, or every column,
+// leaving nothing to scroll — paints exactly the canvas it did before the
+// field existed: rectangles, texts and hits, end to end.
+func TestTableWithNothingFrozenPaintsAsItAlwaysDid(t *testing.T) {
+ rows := make(TableRows, 50)
+ paint := func(columns int) *gfx.Canvas {
+ canvas := testCanvas()
+ var view View
+ view.Draw(canvas, gfx.LightTheme(), gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ spec := frozenSpec(&TableState{}, &rows)
+ spec.FrozenColumns = columns
+ f.Table(&TableState{}, spec).Fill()
+ })
+ })
+ return canvas
+ }
+ plain := paint(0)
+ for _, count := range []int{-3, 0, 6} { // 6 would pin every column: as wide as the content, so nothing scrolls and nothing freezes
+ sameCanvas(t, "count that pins nothing", plain, paint(count))
+ }
+}
diff --git a/ui/table_scroll_horizontal_test.go b/ui/table_scroll_horizontal_test.go
new file mode 100644
index 00000000..c1780ae3
--- /dev/null
+++ b/ui/table_scroll_horizontal_test.go
@@ -0,0 +1,415 @@
+package ui
+
+import (
+ "testing"
+
+ "github.com/vivaui/viva/components"
+ "github.com/vivaui/viva/gfx"
+)
+
+// wideFourColumns is four explicitly 120-wide columns: 480 in a table given
+// less, so it scrolls them left and right. Package-level slices, like
+// tableColumns, because a slice literal built inside a paint allocates every
+// frame.
+var wideFourColumns = []components.TableColumn{{Width: 120}, {Width: 120}, {Width: 120}, {Width: 120}}
+
+var wideSixColumns = []components.TableColumn{{Width: 120}, {Width: 120}, {Width: 120}, {Width: 120}, {Width: 120}, {Width: 120}}
+
+var fourTitles = []string{"A", "B", "C", "D"}
+var sixTitles = []string{"A", "B", "C", "D", "E", "F"}
+var allSortableSix = []bool{true, true, true, true, true, true}
+
+// A table whose explicit columns do not fit the room it was given scrolls them
+// left and right, and its header rides along with its rows.
+func TestTableScrollsItsColumnsWhenTheyDoNotFit(t *testing.T) {
+ theme := gfx.LightTheme()
+ viewport := gfx.Size{Width: 320, Height: 240}
+ rows := make(TableRows, 50)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc", "dd"}
+ }
+ var state TableState
+ var view View
+ canvas := testCanvas()
+ // headerX and rowX are read from cell hits: the header is not sortable
+ // here, so the header texts are the witnesses of where the header sits.
+ var headerX, rowX float32
+ paint := func() {
+ view.Draw(canvas, theme, gfx.Rect{Width: viewport.Width, Height: viewport.Height}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, TableSpec{Rows: tableScope, Header: headerScope, Columns: wideFourColumns, Headers: fourTitles, Sortable: []bool{true, false, false, false}, Source: &rows}).Size(viewport.Width, viewport.Height)
+ })
+ })
+ headerX, rowX = headerAndRowX(canvas)
+ }
+ paint()
+ if max := state.Scroll.MaxX(); max != 160 { // 480 declared in 320 shown
+ t.Fatalf("MaxX = %v, want 160", max)
+ }
+ beforeHeader, beforeRow := headerX, rowX
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 60, Precise: true})
+ canvas.Reset()
+ paint()
+ if state.Scroll.OffsetX != 60 {
+ t.Errorf("OffsetX = %v, want 60", state.Scroll.OffsetX)
+ }
+ if moved := headerX - beforeHeader; moved != -60 {
+ t.Errorf("the header moved %v, want it left by 60", moved)
+ }
+ if moved := rowX - beforeRow; moved != -60 {
+ t.Errorf("the body moved %v, want it left by 60 with the header", moved)
+ }
+
+ // Out of range on either side stays in bounds.
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 1e5, Precise: true})
+ canvas.Reset()
+ paint()
+ if got := state.Scroll.OffsetX; got != state.Scroll.MaxX() {
+ t.Errorf("past the end OffsetX = %v, want %v", got, state.Scroll.MaxX())
+ }
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: -1e5, Precise: true})
+ canvas.Reset()
+ paint()
+ if got := state.Scroll.OffsetX; got != 0 {
+ t.Errorf("before the start OffsetX = %v, want 0", got)
+ }
+}
+
+func headerAndRowX(canvas *gfx.Canvas) (headerX, rowX float32) {
+ for _, text := range canvas.Texts() {
+ if text.Value == "B" {
+ headerX = text.Bounds.X
+ }
+ if text.Value == "bb" {
+ rowX = text.Bounds.X
+ }
+ }
+ return
+}
+
+// The wheel moves the columns of a wide table and its rows at once: a
+// horizontal trackpad delta (or the wheel with Shift held, as for a Sideways
+// area) moves the columns while the plain wheel still moves the rows.
+func TestTableWheelScrollsBothWays(t *testing.T) {
+ theme := gfx.LightTheme()
+ viewport := gfx.Size{Width: 320, Height: 240}
+ rows := make(TableRows, 200)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc", "dd"}
+ }
+ var state TableState
+ var view View
+ canvas := testCanvas()
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: viewport.Width, Height: viewport.Height}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, TableSpec{Rows: tableScope, Columns: wideFourColumns, Headers: fourTitles, Source: &rows}).Size(viewport.Width, viewport.Height)
+ })
+ })
+ }
+ paint()
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 30, Precise: true})
+ paint()
+ if state.Scroll.OffsetX != 30 || state.Scroll.Offset != 0 {
+ t.Errorf("a horizontal delta left the table at (%v, %v), want (30, 0)", state.Scroll.OffsetX, state.Scroll.Offset)
+ }
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaY: 30, Precise: true, Modifiers: gfx.ModifierShift})
+ paint()
+ if state.Scroll.OffsetX != 60 {
+ t.Errorf("Shift with the wheel moved the columns to %v, want 60", state.Scroll.OffsetX)
+ }
+ if state.Scroll.Offset != 0 {
+ t.Errorf("a horizontal gesture also moved the rows to %v, want 0", state.Scroll.Offset)
+ }
+ before := state.Scroll.Offset
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaY: 40, Precise: true})
+ paint()
+ if state.Scroll.Offset == before {
+ t.Error("the plain wheel no longer scrolls the rows")
+ }
+}
+
+// A table whose columns fit (the last flexible, or all of them explicit and
+// room still left) keeps the layout it always had: no horizontal offset, and
+// the last column takes whatever is left.
+func TestTableThatFitsDoesNotScrollAcross(t *testing.T) {
+ theme := gfx.LightTheme()
+ viewport := gfx.Size{Width: 320, Height: 240}
+ rows := make(TableRows, 50)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc"}
+ }
+ cases := []struct {
+ name string
+ columns []components.TableColumn
+ }{
+ {"flexible last", []components.TableColumn{{Width: 100}, {Width: 100}, {}}},
+ {"all explicit", []components.TableColumn{{Width: 60}, {Width: 60}, {Width: 60}}},
+ }
+ for _, c := range cases {
+ headers := []string{"A", "B", "C"}
+ var state TableState
+ var view View
+ canvas := testCanvas()
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: viewport.Width, Height: viewport.Height}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, TableSpec{Rows: tableScope, Columns: c.columns, Headers: headers, Source: &rows}).Size(viewport.Width, viewport.Height)
+ })
+ })
+ }
+ paint()
+ if max := state.Scroll.MaxX(); max != 0 {
+ t.Errorf("%s: a fitting table scrolls across, MaxX %v", c.name, max)
+ }
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 30, Precise: true})
+ paint()
+ if state.Scroll.OffsetX != 0 {
+ t.Errorf("%s: the wheel moved a fitting table to %v", c.name, state.Scroll.OffsetX)
+ }
+ if state.Scroll.ContentWidth != state.Scroll.ViewportWidth {
+ t.Errorf("%s: content is %v wide in a %v viewport, want them equal", c.name, state.Scroll.ContentWidth, state.Scroll.ViewportWidth)
+ }
+ }
+}
+
+// A table inside a ScrollArea is as tall as its rows and as wide as the area
+// makes it; the area scrolls left and right when it is Sideways, and the table
+// leaves the horizontal offset to it.
+func TestTableInAScrollAreaLeavesAcrossToTheArea(t *testing.T) {
+ theme := gfx.LightTheme()
+ viewport := gfx.Size{Width: 300, Height: 200}
+ rows := make(TableRows, 50)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc", "dd"}
+ }
+ for _, sideways := range []bool{false, true} {
+ var state TableState
+ var view View
+ canvas := testCanvas()
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: viewport.Width, Height: viewport.Height}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ area := f.ScrollArea(listID).Grow(1)
+ if sideways {
+ area = area.Sideways()
+ }
+ area.Children(func() {
+ f.Table(&state, TableSpec{Rows: tableScope, Columns: wideFourColumns, Headers: fourTitles, Source: &rows})
+ })
+ })
+ })
+ }
+ paint()
+ area := view.ScrollState(listID)
+ want := float32(300)
+ if sideways {
+ want = 480
+ }
+ if area.ContentWidth != want {
+ t.Errorf("sideways %v: the area's content is %v wide, want %v", sideways, area.ContentWidth, want)
+ }
+ // However wide its columns are, a table in an area leaves the horizontal
+ // offset to it: it never moves itself across, sideways area or not.
+ if state.Scroll.OffsetX != 0 {
+ t.Errorf("sideways %v: the table moved itself across to %v", sideways, state.Scroll.OffsetX)
+ }
+ }
+}
+
+// The horizontal scrollbar drags like the vertical one: a press on the thumb
+// holds it, a drag moves the columns with it, and the release lets go.
+func TestTableHorizontalScrollbarDrags(t *testing.T) {
+ theme := gfx.LightTheme()
+ viewport := gfx.Size{Width: 320, Height: 240}
+ rows := make(TableRows, 10)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc", "dd"}
+ }
+ var state TableState
+ var view View
+ canvas := testCanvas()
+ paint := func(*gfx.Canvas, gfx.Size) {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: viewport.Width, Height: viewport.Height}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, TableSpec{
+ Rows: tableScope, Header: headerScope, Columns: wideFourColumns,
+ Headers: fourTitles, Sortable: []bool{true, true, true, true}, Source: &rows,
+ HorizontalScrollbar: gfx.Item(901),
+ }).Size(viewport.Width, viewport.Height)
+ })
+ })
+ }
+ paint(canvas, gfx.Size{})
+ // A 320 viewport shows the first two 120 columns whole and the third cut at
+ // its right edge; the fourth is off screen and declares no hit. The header
+ // scissored by the same clip as the body shows three.
+ if hits := tableHeaderHits(canvas, headerScope); len(hits) != 3 {
+ t.Fatalf("the header shows %d cells in a 320 viewport showing 320 of 480 px, want the 2 whole and 1 cut", len(hits))
+ }
+ hbar := hitByID(canvas, gfx.Item(901))
+ thumb := hbar.Bounds
+ deliver := func(kind gfx.PointerEventKind, x, y float32) bool {
+ return canvas.DeliverPointer(gfx.PointerEvent{Kind: kind, Button: gfx.PointerButtonPrimary, Target: gfx.Item(901), Captured: kind != gfx.PointerDown, X: x, Y: y}, gfx.Size{Width: viewport.Width, Height: viewport.Height}, paint)
+ }
+ x, y := thumb.X+2, thumb.Y+thumb.Height/2
+ if ok := deliver(gfx.PointerDown, x, y); !ok {
+ t.Fatal("a press on the horizontal bar was not taken")
+ }
+ if !deliver(gfx.PointerMove, x+40, y) {
+ t.Fatal("the drag of the bar was not taken")
+ }
+ if got := state.Scroll.OffsetX; got <= 0 || got > 160 {
+ t.Errorf("dragging the bar left the columns at %v, want some of the 160 px of range", got)
+ }
+ deliver(gfx.PointerUp, x+40, y)
+ paint(canvas, gfx.Size{})
+ if state.Scroll.Dragging() {
+ t.Error("the bar is still dragging after the release")
+ }
+}
+
+func hitByID(canvas *gfx.Canvas, id gfx.ID) gfx.Hit {
+ var hit gfx.Hit
+ for _, h := range canvas.Hits() {
+ if h.ID == id {
+ return h
+ }
+ }
+ return hit
+}
+
+func tableHeaderHits(canvas *gfx.Canvas, scope gfx.Scope) []gfx.Hit {
+ var hits []gfx.Hit
+ for _, hit := range canvas.Hits() {
+ if hit.ID.Scope() == scope {
+ hits = append(hits, hit)
+ }
+ }
+ return hits
+}
+
+// A warm wide table paints without allocating: the horizontal scroll path adds
+// no per-frame allocations to the vertical one.
+func TestWideTableAllocatesNothingOnceWarm(t *testing.T) {
+ theme := gfx.LightTheme()
+ rows := &countingRows{n: 100}
+ var view View
+ var state TableState
+ canvas := testCanvas()
+ paint := func() {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, TableSpec{Rows: tableScope, Header: headerScope, Columns: wideSixColumns, Headers: sixTitles, Sortable: allSortableSix, Striped: true, Source: rows, Scrollbar: gfx.Item(900), HorizontalScrollbar: gfx.Item(901)}).Fill()
+ })
+ })
+ }
+ paint()
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 40, Precise: true})
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaY: 90, Precise: true})
+ for range 3 {
+ paint()
+ }
+ if allocs := testing.AllocsPerRun(100, paint); allocs != 0 {
+ t.Fatalf("a warm wide table allocates %v times a frame, want 0", allocs)
+ }
+}
+
+func BenchmarkWideTable(b *testing.B) {
+ theme := gfx.LightTheme()
+ rows := &countingRows{n: 1000}
+ var view View
+ var state TableState
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaX: 60, Precise: true})
+ state.Scroll.Wheel(gfx.ScrollEvent{DeltaY: 3000, Precise: true})
+ canvas := testCanvas()
+ b.ReportAllocs()
+ for b.Loop() {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: 400, Height: 300}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(&state, TableSpec{Rows: tableScope, Header: headerScope, Columns: wideSixColumns, Headers: sixTitles, Sortable: allSortableSix, Striped: true, Source: rows, Scrollbar: gfx.Item(900), HorizontalScrollbar: gfx.Item(901)}).Fill()
+ })
+ })
+ }
+}
+
+// gutterColumns are three explicitly 240-wide columns: 720 in a 480-wide table,
+// so it scrolls its columns sideways while its 30-tall rows overflow the body
+// below a 30-tall header — both bars, both gutters.
+var gutterColumns = []components.TableColumn{{Width: 240}, {Width: 240}, {Width: 240}}
+
+// A table that reserves a gutter for its horizontal scrollbar keeps the bar in
+// the reserved strip below its rows, so the bar's track covers no row and the
+// right-click on the last visible row's text still reaches the row. A table
+// without a gutter keeps the bar riding its bottom edge, as it always has.
+func TestTableHorizontalBarKeepsTheGutterStripAndClearRows(t *testing.T) {
+ theme := gfx.LightTheme()
+ viewport := gfx.Size{Width: 480, Height: 172}
+ rows := make(TableRows, 20)
+ for i := range rows {
+ rows[i] = []string{"aa", "bb", "cc"}
+ }
+ paint := func(canvas *gfx.Canvas, view *View, state *TableState, gutter float32) {
+ canvas.Reset()
+ view.Draw(canvas, theme, gfx.Rect{Width: viewport.Width, Height: viewport.Height}, func(f *Frame) {
+ f.Column().Fill().Children(func() {
+ f.Table(state, TableSpec{
+ Rows: tableScope, Columns: gutterColumns, Headers: []string{"A", "B", "C"},
+ Source: &rows, RowHeight: 30,
+ Scrollbar: gfx.Item(900), HorizontalScrollbar: gfx.Item(901),
+ ScrollbarSize: 12, ScrollbarGutter: gutter,
+ }).Size(viewport.Width, viewport.Height)
+ })
+ })
+ }
+ canvas, view, state := testCanvas(), &View{}, &TableState{}
+ paint(canvas, view, state, 12)
+ deepest := gfx.Hit{}
+ for _, hit := range canvas.Hits() {
+ if hit.ID.Scope() == tableScope && hit.Bounds.Bottom() > deepest.Bounds.Bottom() {
+ deepest = hit
+ }
+ }
+ if deepest.ID.IsZero() {
+ t.Fatal("no row is painted")
+ }
+ bar := hitByID(canvas, gfx.Item(901))
+ if bar.ID.IsZero() {
+ t.Fatal("the horizontal scrollbar is not painted")
+ }
+ if bar.Bounds.Y < deepest.Bounds.Bottom()-0.01 || bar.Bounds.Bottom() > viewport.Height+0.01 {
+ t.Fatalf("the horizontal bar sits at %+v over the deepest row ending at %v; it belongs in the strip between the rows and the table's bottom edge %v",
+ bar.Bounds, deepest.Bounds.Bottom(), viewport.Height)
+ }
+ frontMost := func(x, y float32) gfx.ID {
+ hits := canvas.Hits()
+ for i := len(hits) - 1; i >= 0; i-- {
+ hit := hits[i]
+ if hit.IsLabel || hit.Backdrop || hit.ID.IsZero() {
+ continue
+ }
+ if x >= hit.Bounds.X && x <= hit.Bounds.Right() && y >= hit.Bounds.Y && y <= hit.Bounds.Bottom() {
+ return hit.ID
+ }
+ }
+ return gfx.ID{}
+ }
+ id := frontMost(deepest.Bounds.X+20, deepest.Bounds.Bottom()-5)
+ if _, ok := id.In(tableScope); !ok {
+ t.Fatalf("the front-most hit at the last visible row's text is %v, want the row: the bar takes the row's right-click", id)
+ }
+
+ // Without a gutter the bar rides the table's bottom edge, as it always did.
+ paint(canvas, view, state, 0)
+ bar = hitByID(canvas, gfx.Item(901))
+ if bar.Bounds.Bottom() != viewport.Height || bar.Bounds.Height != 12 {
+ t.Fatalf("without a gutter the bar sits at %+v, want it on the bottom edge: 12 tall and flush with the table's bottom %v",
+ bar.Bounds, viewport.Height)
+ }
+}
diff --git a/ui/table_scrollbars_test.go b/ui/table_scrollbars_test.go
index 735506ea..7038eefb 100644
--- a/ui/table_scrollbars_test.go
+++ b/ui/table_scrollbars_test.go
@@ -57,6 +57,112 @@ func TestScrollAreaBarsFollowThePointerAtTheirSize(t *testing.T) {
}
}
+// An application-owned vertical bar can replace a table's anonymous hover
+// overlay without losing the table's geometry, wheel, or horizontal overlay.
+func TestTableHideHoverScrollbarsOmitsOnlyTheAnonymousVerticalThumb(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ hide bool
+ gutter float32
+ }{
+ {name: "default overlay", gutter: 0},
+ {name: "default over a gutter", gutter: 10},
+ {name: "hidden overlay", hide: true, gutter: 0},
+ {name: "hidden overlay over a gutter", hide: true, gutter: 10},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ rows := make(ui.TableRows, 100)
+ for i := range rows {
+ rows[i] = []string{"row", "a", "b", "c"}
+ }
+ var state ui.TableState
+ var table ui.Box
+ view := func(c ui.C) {
+ ui.Column(c).Children(func() {
+ table = ui.Table(c, &state, ui.TableSpec{
+ Columns: []components.TableColumn{{Width: 120}, {Width: 120}, {Width: 120}, {Width: 120}},
+ Headers: []string{"A", "B", "C", "D"}, Rows: gfx.Item(1).Scope(), Source: &rows,
+ ScrollbarSize: 10, ScrollbarGutter: test.gutter,
+ HideHoverScrollbars: test.hide,
+ }).Size(320, 200)
+ })
+ }
+ d := openPage(t, view)
+ d.driver.Move(150, 100) // rest over the overflowing table
+ bounds := table.Rect()
+ bounds.Y += components.TableHeaderHeight(pageTheme)
+ bounds.Height -= components.TableHeaderHeight(pageTheme)
+ layout := components.BeginScrollArea(nil, pageTheme, bounds, gfx.Size{Width: 480, Height: float32(len(rows)) * components.TableRowHeight(pageTheme)}).
+ ScrollbarSize(10).HoverScrollbars(true)
+ if test.gutter > 0 {
+ layout = layout.ReserveScrollbars()
+ }
+ geometry := layout.Geometry()
+ if geometry.MaxOffsetX <= 0 || geometry.MaxOffsetY <= 0 {
+ t.Fatalf("the table does not overflow both ways: %+v", geometry)
+ }
+ haveVertical, haveHorizontal := false, false
+ for _, primitive := range d.driver.Snapshot().Primitives {
+ if primitive.Kind != app.RectanglePrimitive || primitive.Radius <= 0 {
+ continue
+ }
+ switch primitive.Bounds {
+ case geometry.VerticalThumb:
+ haveVertical = true
+ case geometry.HorizontalThumb:
+ haveHorizontal = true
+ }
+ }
+ if haveVertical == test.hide {
+ t.Errorf("vertical thumb painted = %v with HideHoverScrollbars %v", haveVertical, test.hide)
+ }
+ if !haveHorizontal {
+ t.Error("the horizontal thumb disappeared")
+ }
+ if err := d.driver.Scroll(gfx.ScrollEvent{X: 150, Y: 100, DeltaY: 100, Precise: true}); err != nil {
+ t.Fatal(err)
+ }
+ if state.Scroll.Offset == 0 {
+ t.Error("hiding the overlay stopped the table's wheel")
+ }
+ })
+ }
+}
+
+// An explicitly owned scrollbar is not an anonymous hover ornament: the opt-out
+// leaves it painted, hit, draggable, and able to move the table.
+func TestTableHideHoverScrollbarsKeepsAnExplicitVerticalBarInteractive(t *testing.T) {
+ rows := make(ui.TableRows, 100)
+ for i := range rows {
+ rows[i] = []string{"row", "state"}
+ }
+ var state ui.TableState
+ view := func(c ui.C) {
+ ui.Table(c, &state, ui.TableSpec{
+ Columns: []components.TableColumn{{Width: 200}, {Width: 0}},
+ Rows: gfx.Item(1).Scope(), Source: &rows,
+ Scrollbar: gfx.Item(900), ScrollbarLabel: "Table rows",
+ ScrollbarSize: 10, HideHoverScrollbars: true,
+ }).Size(320, 200)
+ }
+ d := openPage(t, view)
+ d.driver.Move(150, 100)
+ bar := d.hit("Table rows")
+ at := gfx.Point{X: bar.Bounds.X + bar.Bounds.Width/2, Y: bar.Bounds.Y + 20}
+ if err := d.driver.Press(at.X, at.Y, gfx.PointerButtonPrimary); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.driver.Move(at.X, at.Y+50); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.driver.Release(at.X, at.Y+50, gfx.PointerButtonPrimary); err != nil {
+ t.Fatal(err)
+ }
+ if state.Scroll.Offset == 0 {
+ t.Fatal("the explicit vertical scrollbar did not drag the table")
+ }
+}
+
// A table given a smaller height than its rows scrolls them itself, with the
// bar in the gutter it is asked for, and keeps the pointer from the rows while
// the bar is held. Config.OnKey hands it the keys, as the application does.
diff --git a/ui/table_test.go b/ui/table_test.go
index 74f2ae46..a4d1eee8 100644
--- a/ui/table_test.go
+++ b/ui/table_test.go
@@ -48,6 +48,20 @@ func sameCanvas(t *testing.T, name string, flexed, direct *gfx.Canvas) {
}
}
}
+ // Triangles are compared without Layer, as the rectangles are: the caller picks
+ // the layer a component paints its overlay on (ui raises a select's list one
+ // above the page's widget layer; a direct call names its own).
+ if a, b := flexed.Triangles(), direct.Triangles(); len(a) != len(b) {
+ t.Fatalf("%s: ui painted %d triangles, the component %d", name, len(a), len(b))
+ } else {
+ for i := range a {
+ x, y := a[i], b[i]
+ if x.Points != y.Points || x.Color != y.Color || x.Shaded != y.Shaded || x.Shade != y.Shade ||
+ x.EdgeMask != y.EdgeMask || x.ClipOf != y.ClipOf || x.HasClip != y.HasClip || x.Order != y.Order {
+ t.Errorf("%s: triangle %d is %+v, the component's %+v", name, i, x, y)
+ }
+ }
+ }
}
// A Table looks exactly like the components.Table it is painted by: the same
diff --git a/update/copy.go b/update/copy.go
new file mode 100644
index 00000000..f0bb1980
--- /dev/null
+++ b/update/copy.go
@@ -0,0 +1,60 @@
+package update
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+// copyTree copies the regular files and directories under source into
+// destination, which is created. Links, devices and other entries are refused:
+// a payload is made of files a patch wrote.
+func copyTree(source, destination string) error {
+ return filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ relative, err := filepath.Rel(source, path)
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(destination, relative)
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ switch {
+ case entry.IsDir():
+ return os.MkdirAll(target, info.Mode().Perm()|0o700)
+ case info.Mode().IsRegular():
+ return copyRegularFile(path, target, info.Mode().Perm())
+ default:
+ return fmt.Errorf("%w: %s is not a regular file or directory", ErrUnsafeArchive, path)
+ }
+ })
+}
+
+// copyRegularFile copies source to destination, which is created with mode.
+func copyRegularFile(source, destination string, mode os.FileMode) error {
+ in, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, in); err != nil {
+ out.Close()
+ return err
+ }
+ if err := out.Close(); err != nil {
+ return err
+ }
+ return os.Chmod(destination, mode)
+}
diff --git a/update/doc.go b/update/doc.go
index fb040d30..5025c1b6 100644
--- a/update/doc.go
+++ b/update/doc.go
@@ -31,8 +31,17 @@
//
// It does not show a dialog, decide whether updating is allowed, check on a
// timer, or stage a release. The application decides when to call Check, what
-// to ask, and when to call Download and Install. It also does not do delta
-// updates: a download is the complete archive the feed lists.
+// to ask, and when to call Download and Install.
+//
+// # Delta updates
+//
+// A feed may list patches for an asset, each between an earlier version's
+// installed payload and this one. PatchFrom finds one for the running version;
+// DownloadPatch fetches and verifies it; ApplyPatch applies it to the running
+// application and returns a staging directory holding the new payload. Install
+// accepts that payload in place of an archive. Windows downloads the full
+// installer: the NSIS installer owns the installation, so it has no payload to
+// patch.
//
// # Installing
//
diff --git a/update/download.go b/update/download.go
index 24e6c9aa..0d0fe95e 100644
--- a/update/download.go
+++ b/update/download.go
@@ -34,13 +34,38 @@ func (client *Client) Download(ctx context.Context, asset Asset, dir string, pro
if asset.Size > client.maxAsset {
return "", fmt.Errorf("%w: archive is %d bytes, limit is %d", ErrTooLarge, asset.Size, client.maxAsset)
}
- if asset.Size >= math.MaxInt64 {
- return "", fmt.Errorf("%w: archive is %d bytes", ErrTooLarge, asset.Size)
+ return client.download(ctx, asset.URL, asset.Size, asset.SHA256, assetFileName(asset), dir, progress)
+}
+
+// DownloadPatch streams a delta update into dir and returns the patch file's
+// path. It checks the bytes exactly as Download does, against the size and
+// SHA-256 the signed feed declared for the patch. The patch is not applied
+// here: ReadPatch and PatchReader.Apply do that, after the caller has checked the
+// patch names the running application and version.
+func (client *Client) DownloadPatch(ctx context.Context, asset Asset, patch Patch, dir string, progress func(done, total int64)) (string, error) {
+ if err := asset.validate(); err != nil {
+ return "", fmt.Errorf("%w: %w", ErrManifest, err)
+ }
+ if err := patch.validate(); err != nil {
+ return "", fmt.Errorf("%w: %w", ErrManifest, err)
+ }
+ if patch.Size > client.maxAsset {
+ return "", fmt.Errorf("%w: patch is %d bytes, limit is %d", ErrTooLarge, patch.Size, client.maxAsset)
+ }
+ name := assetFileName(asset) + "." + patch.FromVersion + ".patch"
+ return client.download(ctx, patch.URL, patch.Size, patch.SHA256, name, dir, progress)
+}
+
+// download fetches one file and keeps it only when its length and digest are
+// the declared ones.
+func (client *Client) download(ctx context.Context, rawURL string, size int64, digest string, name, dir string, progress func(done, total int64)) (string, error) {
+ if size >= math.MaxInt64 {
+ return "", fmt.Errorf("%w: file is %d bytes", ErrTooLarge, size)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("update: %w", err)
}
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", fmt.Errorf("update: %w", err)
}
@@ -51,55 +76,55 @@ func (client *Client) Download(ctx context.Context, asset Asset, dir string, pro
}
defer response.Body.Close()
if response.Request != nil && response.Request.URL != nil && response.Request.URL.Scheme != "https" {
- return "", fmt.Errorf("%w: %s redirected to %s", ErrInsecureURL, asset.URL, response.Request.URL)
+ return "", fmt.Errorf("%w: %s redirected to %s", ErrInsecureURL, rawURL, response.Request.URL)
}
if response.StatusCode != http.StatusOK {
- return "", fmt.Errorf("update: %s: %s", asset.URL, response.Status)
+ return "", fmt.Errorf("update: %s: %s", rawURL, response.Status)
}
file, err := os.CreateTemp(dir, ".viva-update-*")
if err != nil {
return "", fmt.Errorf("update: %w", err)
}
- name := file.Name()
+ temporary := file.Name()
keep := false
defer func() {
_ = file.Close()
if !keep {
- _ = os.Remove(name)
+ _ = os.Remove(temporary)
}
}()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
if progress != nil {
- progress(0, asset.Size)
+ progress(0, size)
}
- counter := &progressWriter{writer: writer, total: asset.Size, progress: progress}
- written, err := io.Copy(counter, io.LimitReader(response.Body, asset.Size+1))
+ counter := &progressWriter{writer: writer, total: size, progress: progress}
+ written, err := io.Copy(counter, io.LimitReader(response.Body, size+1))
if err != nil {
- return "", fmt.Errorf("update: reading %s: %w", asset.URL, err)
+ return "", fmt.Errorf("update: reading %s: %w", rawURL, err)
}
- if written != asset.Size {
- return "", fmt.Errorf("%w: got %d bytes, feed declares %d", ErrSize, written, asset.Size)
+ if written != size {
+ return "", fmt.Errorf("%w: got %d bytes, feed declares %d", ErrSize, written, size)
}
if err := file.Close(); err != nil {
return "", fmt.Errorf("update: %w", err)
}
- digest := hex.EncodeToString(hasher.Sum(nil))
- if !strings.EqualFold(digest, asset.SHA256) {
- return "", fmt.Errorf("%w: %s", ErrChecksum, digest)
+ sum := hex.EncodeToString(hasher.Sum(nil))
+ if !strings.EqualFold(sum, digest) {
+ return "", fmt.Errorf("%w: %s", ErrChecksum, sum)
}
- if err := os.Chmod(name, 0o644); err != nil {
+ if err := os.Chmod(temporary, 0o644); err != nil {
return "", fmt.Errorf("update: %w", err)
}
- destination := filepath.Join(dir, assetFileName(asset))
- if err := os.Rename(name, destination); err != nil {
+ destination := filepath.Join(dir, name)
+ if err := os.Rename(temporary, destination); err != nil {
return "", fmt.Errorf("update: %w", err)
}
keep = true
if progress != nil {
- progress(asset.Size, asset.Size)
+ progress(size, size)
}
return destination, nil
}
diff --git a/update/download_test.go b/update/download_test.go
index 513cf7a9..969176ea 100644
--- a/update/download_test.go
+++ b/update/download_test.go
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -206,3 +207,66 @@ func TestAssetFileNameRefusesAPathFromTheURL(t *testing.T) {
}
}
}
+
+func TestDownloadPatchWritesTheVerifiedFile(t *testing.T) {
+ content := []byte("a delta between two versions")
+ server, client := downloadFixture(t, content)
+ asset := testAsset(server, []byte("the full archive"))
+ patch := Patch{
+ FromVersion: "0.9.0", URL: server.URL + "/downloads/app.patch",
+ Size: int64(len(content)), SHA256: testDigest(string(content)),
+ }
+ asset.Patches = []Patch{patch}
+ dir := t.TempDir()
+ path, err := client.DownloadPatch(context.Background(), asset, patch, dir, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Base(path) != "app.zip.0.9.0.patch" {
+ t.Errorf("file name = %q", filepath.Base(path))
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(content) {
+ t.Errorf("patch = %q", got)
+ }
+}
+
+func TestDownloadPatchRefusesTheWrongBytes(t *testing.T) {
+ content := []byte("the patch's bytes")
+ server, client := downloadFixture(t, content)
+ asset := testAsset(server, []byte("the full archive"))
+ patch := Patch{
+ FromVersion: "0.9.0", URL: server.URL + "/app.patch",
+ Size: int64(len(content)), SHA256: strings.Repeat("0", 64),
+ }
+ asset.Patches = []Patch{patch}
+ dir := t.TempDir()
+ if _, err := client.DownloadPatch(context.Background(), asset, patch, dir, nil); !errors.Is(err, ErrChecksum) {
+ t.Fatalf("err = %v, want ErrChecksum", err)
+ }
+ entries, _ := os.ReadDir(dir)
+ if len(entries) != 0 {
+ t.Errorf("a refused patch left %d files", len(entries))
+ }
+ patch.SHA256 = testDigest(string(content))
+ patch.Size++
+ if _, err := client.DownloadPatch(context.Background(), asset, patch, dir, nil); !errors.Is(err, ErrSize) {
+ t.Fatalf("err = %v, want ErrSize", err)
+ }
+}
+
+func TestDownloadPatchRefusesPlaintext(t *testing.T) {
+ content := []byte("patch")
+ server, client := downloadFixture(t, content)
+ asset := testAsset(server, []byte("the full archive"))
+ patch := Patch{
+ FromVersion: "0.9.0", URL: "http://example.com/app.patch",
+ Size: int64(len(content)), SHA256: testDigest(string(content)),
+ }
+ if _, err := client.DownloadPatch(context.Background(), asset, patch, t.TempDir(), nil); !errors.Is(err, ErrInsecureURL) {
+ t.Fatalf("err = %v, want ErrInsecureURL", err)
+ }
+}
diff --git a/update/install.go b/update/install.go
index 5ccf93aa..dd764f61 100644
--- a/update/install.go
+++ b/update/install.go
@@ -34,8 +34,17 @@ var (
// Artifact and Asset come from Client.Check and Client.Download; an application
// must not build them from somewhere else.
type InstallOptions struct {
- // Artifact is the file Download returned.
+ // Artifact is the file Download returned. Exactly one of Artifact and
+ // Payload is set.
Artifact string
+ // Payload is a new application the caller already made with PatchReader.Apply:
+ // the staged .app on macOS, or the staged executable on Linux. On macOS the
+ // directory's own name does not matter, so the one ApplyPatch returns can be
+ // passed as it is: the copy takes the running bundle's name. Install
+ // replaces the running application with it exactly as it would with an
+ // unpacked archive, running the same checks. Windows has no patch support,
+ // so a payload there reports ErrUnsupported.
+ Payload string
// Asset is the archive it came from. Its Format tells Install how to unpack
// it, and the format must suit the running platform.
Asset Asset
@@ -74,31 +83,41 @@ func Install(options InstallOptions, quit func()) error {
}
func (options InstallOptions) validate() error {
- if options.Artifact == "" {
- return fmt.Errorf("update: install needs the downloaded artifact")
+ if (options.Artifact == "") == (options.Payload == "") {
+ return fmt.Errorf("update: install needs exactly one of the downloaded artifact and a staged payload")
}
- info, err := os.Lstat(options.Artifact)
- if err != nil {
- return fmt.Errorf("update: %w", err)
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("%w: %s is a link", ErrManifest, options.Artifact)
- }
- if !info.Mode().IsRegular() {
- return fmt.Errorf("%w: %s is not a regular file", ErrManifest, options.Artifact)
- }
- // Download verified these bytes, but the file sits on disk between the two
- // calls. Hash it again at the moment it is about to be installed.
- if options.Asset.Size > 0 && info.Size() != options.Asset.Size {
- return fmt.Errorf("%w: %s is %d bytes, the feed declared %d", ErrSize, options.Artifact, info.Size(), options.Asset.Size)
- }
- if len(options.Asset.SHA256) == 64 {
- digest, err := fileSHA256(options.Artifact)
+ if options.Payload != "" {
+ info, err := os.Lstat(options.Payload)
+ if err != nil {
+ return fmt.Errorf("update: %w", err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("%w: %s is a link", ErrManifest, options.Payload)
+ }
+ } else {
+ info, err := os.Lstat(options.Artifact)
if err != nil {
return fmt.Errorf("update: %w", err)
}
- if !strings.EqualFold(digest, options.Asset.SHA256) {
- return fmt.Errorf("%w: %s", ErrChecksum, options.Artifact)
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("%w: %s is a link", ErrManifest, options.Artifact)
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("%w: %s is not a regular file", ErrManifest, options.Artifact)
+ }
+ // Download verified these bytes, but the file sits on disk between the two
+ // calls. Hash it again at the moment it is about to be installed.
+ if options.Asset.Size > 0 && info.Size() != options.Asset.Size {
+ return fmt.Errorf("%w: %s is %d bytes, the feed declared %d", ErrSize, options.Artifact, info.Size(), options.Asset.Size)
+ }
+ if len(options.Asset.SHA256) == 64 {
+ digest, err := fileSHA256(options.Artifact)
+ if err != nil {
+ return fmt.Errorf("update: %w", err)
+ }
+ if !strings.EqualFold(digest, options.Asset.SHA256) {
+ return fmt.Errorf("%w: %s", ErrChecksum, options.Artifact)
+ }
}
}
if options.Version == "" {
diff --git a/update/install_darwin.go b/update/install_darwin.go
index ebf75255..d628aaaa 100644
--- a/update/install_darwin.go
+++ b/update/install_darwin.go
@@ -57,11 +57,17 @@ func install(options InstallOptions, quit func()) error {
}
}()
- options.report("unpacking")
- if err := extractZip(options.Artifact, staging); err != nil {
- return err
+ var staged string
+ if options.Payload != "" {
+ options.report("copying the patched application")
+ staged, err = stagePayload(options.Payload, staging, filepath.Base(bundle))
+ } else {
+ options.report("unpacking")
+ if err := extractZip(options.Artifact, staging); err != nil {
+ return err
+ }
+ staged, err = stagedBundle(staging)
}
- staged, err := stagedBundle(staging)
if err != nil {
return err
}
@@ -144,6 +150,18 @@ func isDirectory(path string) bool {
// stagedBundle returns the one application bundle at the top of staging. An
// archive that holds none, several, or a file with the .app suffix is refused.
+// stagePayload copies a patched application into staging as name, the running
+// bundle's directory name, and returns the copy. ApplyPatch builds the payload
+// in a temporary directory whose own name has no .app suffix, so the copy cannot
+// keep it; the helper moves the copy over the running bundle under that name
+// anyway.
+func stagePayload(payload, staging, name string) (string, error) {
+ if err := copyTree(payload, filepath.Join(staging, name)); err != nil {
+ return "", err
+ }
+ return stagedBundle(staging)
+}
+
func stagedBundle(staging string) (string, error) {
entries, err := os.ReadDir(staging)
if err != nil {
diff --git a/update/install_darwin_test.go b/update/install_darwin_test.go
index 8adba212..f39507c2 100644
--- a/update/install_darwin_test.go
+++ b/update/install_darwin_test.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "runtime"
"slices"
"strings"
"testing"
@@ -375,3 +376,59 @@ func TestInstallShellQuote(t *testing.T) {
}
}
}
+
+// TestInstallStagesAnAppliedPatch follows the documented order: ApplyPatch
+// rebuilds the bundle in a directory named viva-payload-*, and Install has to
+// find that copy as an application bundle.
+func TestInstallStagesAnAppliedPatch(t *testing.T) {
+ oldRoot := filepath.Join(t.TempDir(), "My App.app")
+ program := []byte("program")
+ if err := os.MkdirAll(filepath.Join(oldRoot, "Contents", "MacOS"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldRoot, "Contents", "MacOS", "My App"), program, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ oldPlist := []byte(strings.Replace(fixtureInfoPlist, "1.2.3", "1.0.0", 1))
+ if err := os.WriteFile(filepath.Join(oldRoot, "Contents", "Info.plist"), oldPlist, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ newPlist := []byte(strings.Replace(fixtureInfoPlist, "1.2.3", "1.1.0", 1))
+ manifest := PatchManifest{
+ Schema: PatchSchema, App: "dev.viva.gallery", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: runtime.GOOS, Arch: runtime.GOARCH, Payload: "app",
+ Files: []PatchFile{
+ fileManifest("Contents/MacOS/My App", program, "same", "", 0o755),
+ fileManifest("Contents/Info.plist", newPlist, "new", "files/0001", 0o644),
+ },
+ }
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": newPlist})
+
+ original := locateInstalledPayload
+ defer func() { locateInstalledPayload = original }()
+ locateInstalledPayload = func() (string, string, error) { return oldRoot, "app", nil }
+
+ payload, err := ApplyPatch(patchPath, "dev.viva.gallery", "1.0.0", "1.1.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(payload)
+ staging := t.TempDir()
+ staged, err := stagePayload(payload, staging, filepath.Base(oldRoot))
+ if err != nil {
+ t.Fatalf("stagePayload: %v", err)
+ }
+ if want := filepath.Join(staging, "My App.app"); staged != want {
+ t.Errorf("staged = %q, want %q", staged, want)
+ }
+ info, err := readBundleInfo(staged)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Identifier != "dev.viva.gallery" || info.Version != "1.1.0" {
+ t.Errorf("staged bundle = %+v, want dev.viva.gallery 1.1.0", info)
+ }
+ if got, err := os.ReadFile(filepath.Join(staged, "Contents", "MacOS", "My App")); err != nil || !slices.Equal(got, program) {
+ t.Errorf("staged program = %q, %v", got, err)
+ }
+}
diff --git a/update/install_linux.go b/update/install_linux.go
index 6dd99927..edbf73e0 100644
--- a/update/install_linux.go
+++ b/update/install_linux.go
@@ -65,12 +65,35 @@ func install(options InstallOptions, quit func()) error {
_ = os.RemoveAll(staging)
}
}()
- if err := extractTarGz(options.Artifact, staging); err != nil {
- return err
- }
- replacement, err := findProgram(staging, filepath.Base(executable))
- if err != nil {
- return err
+ var replacement string
+ if options.Payload != "" {
+ options.report("copying the patched executable")
+ info, err := os.Lstat(options.Payload)
+ if err != nil {
+ return fmt.Errorf("update: %w", err)
+ }
+ program := options.Payload
+ if info.IsDir() {
+ program, err = findProgram(options.Payload, filepath.Base(executable))
+ if err != nil {
+ return err
+ }
+ } else if !info.Mode().IsRegular() {
+ return fmt.Errorf("%w: the payload is not a regular file", ErrManifest)
+ }
+ replacement = filepath.Join(staging, filepath.Base(executable))
+ if err := copyRegularFile(program, replacement, 0o755); err != nil {
+ return fmt.Errorf("update: copying the patched executable: %w", err)
+ }
+ } else {
+ options.report("unpacking")
+ if err := extractTarGz(options.Artifact, staging); err != nil {
+ return err
+ }
+ replacement, err = findProgram(staging, filepath.Base(executable))
+ if err != nil {
+ return err
+ }
}
options.report("replacing")
diff --git a/update/install_windows.go b/update/install_windows.go
index da88d5ae..21159c4b 100644
--- a/update/install_windows.go
+++ b/update/install_windows.go
@@ -104,6 +104,9 @@ var (
// installed program. It returns only after quit, so the helper can wait for
// this process to end before it replaces the running executable.
func install(options InstallOptions, quit func()) error {
+ if options.Payload != "" {
+ return fmt.Errorf("%w: Windows has no installed payload to patch; download the installer", ErrUnsupported)
+ }
if options.Asset.Format != FormatNSIS {
return fmt.Errorf("%w: windows needs an NSIS installer, not %q", ErrManifest, options.Asset.Format)
}
diff --git a/update/manifest.go b/update/manifest.go
index e4dbf6ad..2edd06b8 100644
--- a/update/manifest.go
+++ b/update/manifest.go
@@ -70,6 +70,32 @@ type Asset struct {
Size int64 `json:"size"`
// SHA256 is the archive's lowercase hexadecimal SHA-256 digest.
SHA256 string `json:"sha256"`
+ // Patches are updates from earlier versions, each a delta against the
+ // installed payload. A client that does not read them downloads the
+ // archive.
+ Patches []Patch `json:"patches,omitempty"`
+}
+
+// Patch is one delta update for an asset.
+type Patch struct {
+ // FromVersion is the installed version the patch applies to.
+ FromVersion string `json:"from_version"`
+ // URL is the HTTPS address of the patch file.
+ URL string `json:"url"`
+ // Size is the patch's exact length in bytes.
+ Size int64 `json:"size"`
+ // SHA256 is the patch's lowercase hexadecimal SHA-256 digest.
+ SHA256 string `json:"sha256"`
+}
+
+// PatchFrom returns the patch that applies to version, if the feed offers one.
+func (asset Asset) PatchFrom(version string) (Patch, bool) {
+ for _, patch := range asset.Patches {
+ if patch.FromVersion == version {
+ return patch, true
+ }
+ }
+ return Patch{}, false
}
// Asset returns the archive this feed offers for goos and goarch. When a
@@ -194,6 +220,38 @@ func (asset Asset) validate() error {
if _, err := hex.DecodeString(asset.SHA256); err != nil {
return fmt.Errorf("sha256: %v", err)
}
+ seenPatches := make(map[string]bool, len(asset.Patches))
+ for _, patch := range asset.Patches {
+ if err := patch.validate(); err != nil {
+ return err
+ }
+ if seenPatches[patch.FromVersion] {
+ return fmt.Errorf("patches from %s appear twice", patch.FromVersion)
+ }
+ seenPatches[patch.FromVersion] = true
+ }
+ return nil
+}
+
+func (patch Patch) validate() error {
+ if _, err := parseVersion(patch.FromVersion); err != nil {
+ return fmt.Errorf("patch from_version: %v", err)
+ }
+ if patch.FromVersion == "" {
+ return fmt.Errorf("patch from_version is empty")
+ }
+ if err := checkHTTPS(patch.URL); err != nil {
+ return fmt.Errorf("patch from %s: %w", patch.FromVersion, err)
+ }
+ if patch.Size <= 0 {
+ return fmt.Errorf("patch from %s: size must be positive", patch.FromVersion)
+ }
+ if len(patch.SHA256) != 64 || strings.ToLower(patch.SHA256) != patch.SHA256 {
+ return fmt.Errorf("patch from %s: sha256 must be 64 lowercase hexadecimal digits", patch.FromVersion)
+ }
+ if _, err := hex.DecodeString(patch.SHA256); err != nil {
+ return fmt.Errorf("patch from %s: sha256: %v", patch.FromVersion, err)
+ }
return nil
}
diff --git a/update/manifest_test.go b/update/manifest_test.go
index 4cbd3148..d2ca3e55 100644
--- a/update/manifest_test.go
+++ b/update/manifest_test.go
@@ -165,3 +165,48 @@ func TestFeedWithoutExpiryNeverExpires(t *testing.T) {
t.Fatalf("a feed with no expires: %v", err)
}
}
+
+func TestFeedAcceptsPatches(t *testing.T) {
+ feed := validFeed()
+ feed.Latest.Assets[0].Patches = []Patch{{
+ FromVersion: "1.4.1", URL: "https://example.com/app.patch",
+ Size: 4096, SHA256: testDigest("patch"),
+ }}
+ if err := feed.validate(); err != nil {
+ t.Fatalf("valid feed: %v", err)
+ }
+ if patch, ok := feed.Latest.Assets[0].PatchFrom("1.4.1"); !ok || patch.FromVersion != "1.4.1" {
+ t.Fatalf("PatchFrom = %+v, %v", patch, ok)
+ }
+ if _, ok := feed.Latest.Assets[0].PatchFrom("1.4.0"); ok {
+ t.Error("PatchFrom found a patch for a version the feed does not offer")
+ }
+}
+
+func TestFeedRefusesBadPatches(t *testing.T) {
+ valid := func() Patch {
+ return Patch{FromVersion: "1.4.1", URL: "https://example.com/app.patch", Size: 4096, SHA256: testDigest("patch")}
+ }
+ tests := map[string]func(*Patch){
+ "plaintext": func(p *Patch) { p.URL = "http://example.com/app.patch" },
+ "no digest": func(p *Patch) { p.SHA256 = "" },
+ "uppercase": func(p *Patch) { p.SHA256 = strings.ToUpper(p.SHA256) },
+ "zero size": func(p *Patch) { p.Size = 0 },
+ "bad version": func(p *Patch) { p.FromVersion = "1.4" },
+ "empty version": func(p *Patch) { p.FromVersion = "" },
+ }
+ for name, change := range tests {
+ patch := valid()
+ change(&patch)
+ feed := validFeed()
+ feed.Latest.Assets[0].Patches = []Patch{patch}
+ if err := feed.validate(); !errors.Is(err, ErrManifest) {
+ t.Errorf("%s: err = %v, want ErrManifest", name, err)
+ }
+ }
+ feed := validFeed()
+ feed.Latest.Assets[0].Patches = []Patch{valid(), valid()}
+ if err := feed.validate(); !errors.Is(err, ErrManifest) {
+ t.Errorf("duplicate patches: err = %v, want ErrManifest", err)
+ }
+}
diff --git a/update/patch.go b/update/patch.go
new file mode 100644
index 00000000..ccadac9e
--- /dev/null
+++ b/update/patch.go
@@ -0,0 +1,455 @@
+package update
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/vivaui/viva/internal/bsdiff"
+)
+
+// PatchSchema is the only patch format this package reads.
+const PatchSchema = 1
+
+// patch limits, matching the archive limits in the feed: a patch is not a
+// reason to trust a larger document or to fill the disk.
+const (
+ maxPatchManifest = 4 << 20
+ maxPatchFile = 512 << 20
+ maxPatchTotal = 2 << 30
+)
+
+// PatchFile is one file of a patch. Path is relative to the payload root and
+// slash-separated. Op is "same" (the file is copied from the old payload),
+// "diff" (the blob is a bsdiff against the old file) or "new" (the blob is the
+// whole new file).
+type PatchFile struct {
+ Path string `json:"path"`
+ Mode uint32 `json:"mode"`
+ Size int64 `json:"size"`
+ SHA256 string `json:"sha256"`
+ Op string `json:"op"`
+ Blob string `json:"blob,omitempty"`
+}
+
+// PatchManifest describes a patch: what it applies to, from where, and every
+// file the new payload holds.
+type PatchManifest struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ FromVersion string `json:"from_version"`
+ ToVersion string `json:"to_version"`
+ OS string `json:"os"`
+ Arch string `json:"arch"`
+ // Payload is "app" for a macOS bundle and "file" for a Linux executable.
+ Payload string `json:"payload"`
+ Files []PatchFile `json:"files"`
+}
+
+// Patch is an open patch file. Its manifest is read; blobs are applied by
+// Apply, which streams them, so a large patch is never held in memory whole.
+type PatchReader struct {
+ manifest PatchManifest
+ source *tar.Reader
+ closer io.Closer
+ byBlob map[string]*PatchFile
+ done map[string]bool
+}
+
+// ReadPatch opens path and reads its manifest. The caller must Close it.
+func ReadPatch(path string) (*PatchReader, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("update: opening the patch: %w", err)
+ }
+ patch, err := newPatch(file)
+ if err != nil {
+ file.Close()
+ return nil, err
+ }
+ patch.closer = file
+ return patch, nil
+}
+
+func newPatch(reader io.Reader) (*PatchReader, error) {
+ gz, err := gzip.NewReader(reader)
+ if err != nil {
+ return nil, fmt.Errorf("%w: the patch is not a gzip stream: %v", ErrManifest, err)
+ }
+ tarReader := tar.NewReader(gz)
+ header, err := tarReader.Next()
+ if err != nil {
+ return nil, fmt.Errorf("%w: the patch is empty: %v", ErrManifest, err)
+ }
+ if header.Name != "patch.json" || header.Typeflag != tar.TypeReg || header.Size > maxPatchManifest {
+ return nil, fmt.Errorf("%w: the patch does not start with patch.json", ErrManifest)
+ }
+ encoded, err := io.ReadAll(io.LimitReader(tarReader, maxPatchManifest+1))
+ if err != nil {
+ return nil, fmt.Errorf("%w: reading patch.json: %v", ErrManifest, err)
+ }
+ var manifest PatchManifest
+ if err := json.Unmarshal(encoded, &manifest); err != nil {
+ return nil, fmt.Errorf("%w: patch.json: %v", ErrManifest, err)
+ }
+ if err := manifest.validate(); err != nil {
+ return nil, err
+ }
+ byBlob := make(map[string]*PatchFile, len(manifest.Files))
+ for i := range manifest.Files {
+ file := &manifest.Files[i]
+ if file.Blob != "" {
+ byBlob[file.Blob] = file
+ }
+ }
+ return &PatchReader{manifest: manifest, source: tarReader, closer: gz, byBlob: byBlob, done: map[string]bool{}}, nil
+}
+
+// Manifest returns the patch's description.
+func (patch *PatchReader) Manifest() PatchManifest { return patch.manifest }
+
+// Close closes the patch file.
+func (patch *PatchReader) Close() error {
+ if patch.closer != nil {
+ return patch.closer.Close()
+ }
+ return nil
+}
+
+// Apply writes the new payload into staging, taking unchanged files from
+// oldRoot. staging must not exist or must be empty; it is created here. The
+// result is verified file by file; on error staging is removed, so a failed
+// patch leaves nothing behind.
+func (patch *PatchReader) Apply(oldRoot, staging string) error {
+ if info, statErr := os.Stat(staging); statErr == nil && (!info.IsDir() || !emptyDir(staging)) {
+ return fmt.Errorf("update: the staging directory %s is not empty", staging)
+ }
+ if err := os.MkdirAll(staging, 0o755); err != nil {
+ return fmt.Errorf("update: creating the staging directory: %w", err)
+ }
+ complete := false
+ defer func() {
+ if !complete {
+ _ = os.RemoveAll(staging)
+ }
+ }()
+
+ // The files that need no blob are copied first; the tar reader waits at
+ // the next entry while this happens.
+ for i := range patch.manifest.Files {
+ file := &patch.manifest.Files[i]
+ if file.Op == "same" {
+ if err := patch.copySame(oldRoot, staging, file); err != nil {
+ return err
+ }
+ }
+ }
+ for {
+ header, err := patch.source.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("%w: reading the patch: %v", ErrManifest, err)
+ }
+ if !strings.HasPrefix(header.Name, "files/") {
+ return fmt.Errorf("%w: unexpected entry %q", ErrManifest, header.Name)
+ }
+ file, ok := patch.byBlob[header.Name]
+ if !ok {
+ return fmt.Errorf("%w: blob %q is not in the manifest", ErrManifest, header.Name)
+ }
+ if header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > maxPatchFile {
+ return fmt.Errorf("%w: blob %q has an invalid size", ErrManifest, header.Name)
+ }
+ if patch.done[header.Name] {
+ return fmt.Errorf("%w: blob %q appears twice", ErrManifest, header.Name)
+ }
+ if err := patch.applyBlob(oldRoot, staging, file, io.LimitReader(patch.source, header.Size)); err != nil {
+ return err
+ }
+ patch.done[header.Name] = true
+ }
+ for i := range patch.manifest.Files {
+ file := &patch.manifest.Files[i]
+ if file.Op != "same" && !patch.done[file.Blob] {
+ return fmt.Errorf("%w: %s has no blob %q", ErrManifest, file.Path, file.Blob)
+ }
+ }
+ complete = true
+ return nil
+}
+
+func (patch *PatchReader) copySame(oldRoot, staging string, file *PatchFile) error {
+ source := filepath.Join(oldRoot, filepath.FromSlash(file.Path))
+ info, err := os.Lstat(source)
+ if err != nil {
+ return fmt.Errorf("%w: reading the old %s: %v", ErrManifest, file.Path, err)
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("%w: the installed %s is not a regular file", ErrManifest, file.Path)
+ }
+ return copyPatchFile(source, patchDestination(staging, file.Path), file, nil)
+}
+
+func (patch *PatchReader) applyBlob(oldRoot, staging string, file *PatchFile, blob io.Reader) error {
+ destination := patchDestination(staging, file.Path)
+ switch file.Op {
+ case "new":
+ return copyPatchFile("", destination, file, blob)
+ case "diff":
+ encoded, err := io.ReadAll(io.LimitReader(blob, maxPatchFile))
+ if err != nil {
+ return fmt.Errorf("%w: reading the patch for %s: %v", ErrManifest, file.Path, err)
+ }
+ oldPath := filepath.Join(oldRoot, filepath.FromSlash(file.Path))
+ oldData, err := readRegularFile(oldPath, file.Path)
+ if err != nil {
+ return err
+ }
+ newData, err := bsdiff.Patch(oldData, encoded)
+ if err != nil {
+ return fmt.Errorf("%w: %s: %v", ErrManifest, file.Path, err)
+ }
+ if int64(len(newData)) != file.Size {
+ return fmt.Errorf("%w: %s is %d bytes, want %d", ErrChecksum, file.Path, len(newData), file.Size)
+ }
+ sum := sha256.Sum256(newData)
+ if hex.EncodeToString(sum[:]) != file.SHA256 {
+ return fmt.Errorf("%w: %s does not match its digest", ErrChecksum, file.Path)
+ }
+ if err := writeData(destination, newData, os.FileMode(file.Mode&0o777)); err != nil {
+ return fmt.Errorf("update: writing %s: %w", file.Path, err)
+ }
+ return nil
+ }
+ return fmt.Errorf("%w: %s has unknown op %q", ErrManifest, file.Path, file.Op)
+}
+
+func readRegularFile(name, label string) ([]byte, error) {
+ info, err := os.Lstat(name)
+ if err != nil {
+ return nil, fmt.Errorf("%w: reading the old %s: %v", ErrManifest, label, err)
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("%w: the installed %s is not a regular file", ErrManifest, label)
+ }
+ if info.Size() > maxPatchFile {
+ return nil, fmt.Errorf("%w: the installed %s is too large", ErrManifest, label)
+ }
+ data, err := os.ReadFile(name)
+ if err != nil {
+ return nil, fmt.Errorf("%w: reading the old %s: %v", ErrManifest, label, err)
+ }
+ return data, nil
+}
+
+func patchDestination(staging, relative string) string {
+ return filepath.Join(staging, filepath.FromSlash(relative))
+}
+
+// copyPatchFile writes source (or blob when source is empty) to destination,
+// hashing as it goes, and checks the result against the manifest.
+func copyPatchFile(source, destination string, file *PatchFile, blob io.Reader) error {
+ var reader io.Reader = blob
+ var handle *os.File
+ if source != "" {
+ var err error
+ handle, err = os.Open(source)
+ if err != nil {
+ return fmt.Errorf("%w: reading the old %s: %v", ErrManifest, file.Path, err)
+ }
+ defer handle.Close()
+ reader = handle
+ }
+ digest := sha256.New()
+ written, err := writeStream(destination, io.TeeReader(reader, digest), os.FileMode(file.Mode&0o777))
+ if err != nil {
+ return fmt.Errorf("update: writing %s: %w", file.Path, err)
+ }
+ if written != file.Size {
+ return fmt.Errorf("%w: %s is %d bytes, want %d", ErrChecksum, file.Path, written, file.Size)
+ }
+ if hex.EncodeToString(digest.Sum(nil)) != file.SHA256 {
+ return fmt.Errorf("%w: %s does not match its digest", ErrChecksum, file.Path)
+ }
+ return nil
+}
+
+// writeStream writes reader to a file that is named only when it is complete.
+func writeStream(name string, reader io.Reader, mode os.FileMode) (int64, error) {
+ if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
+ return 0, err
+ }
+ temporary, err := os.CreateTemp(filepath.Dir(name), "."+filepath.Base(name)+".viva-patch-*")
+ if err != nil {
+ return 0, err
+ }
+ path := temporary.Name()
+ defer os.Remove(path)
+ written, err := io.Copy(temporary, reader)
+ if err != nil {
+ temporary.Close()
+ return 0, err
+ }
+ if err := temporary.Chmod(mode); err != nil {
+ temporary.Close()
+ return 0, err
+ }
+ if err := temporary.Close(); err != nil {
+ return 0, err
+ }
+ if err := os.Rename(path, name); err != nil {
+ return 0, err
+ }
+ return written, nil
+}
+
+func writeData(name string, data []byte, mode os.FileMode) error {
+ _, err := writeStream(name, bytes.NewReader(data), mode)
+ return err
+}
+
+func (manifest PatchManifest) validate() error {
+ if manifest.Schema != PatchSchema {
+ return fmt.Errorf("%w: patch schema is %d, want %d", ErrManifest, manifest.Schema, PatchSchema)
+ }
+ if manifest.App == "" || manifest.FromVersion == "" || manifest.ToVersion == "" {
+ return fmt.Errorf("%w: the patch does not name the application and both versions", ErrManifest)
+ }
+ if _, err := Compare(manifest.FromVersion, manifest.FromVersion); err != nil {
+ return fmt.Errorf("%w: from_version: %v", ErrManifest, err)
+ }
+ if _, err := Compare(manifest.ToVersion, manifest.ToVersion); err != nil {
+ return fmt.Errorf("%w: to_version: %v", ErrManifest, err)
+ }
+ if manifest.OS != "darwin" && manifest.OS != "linux" {
+ return fmt.Errorf("%w: os %q has no patch support", ErrManifest, manifest.OS)
+ }
+ if manifest.Arch != "amd64" && manifest.Arch != "arm64" {
+ return fmt.Errorf("%w: arch %q is not supported", ErrManifest, manifest.Arch)
+ }
+ if manifest.Payload != "app" && manifest.Payload != "file" {
+ return fmt.Errorf("%w: payload %q is not app or file", ErrManifest, manifest.Payload)
+ }
+ if len(manifest.Files) == 0 {
+ return fmt.Errorf("%w: the patch holds no files", ErrManifest)
+ }
+ if len(manifest.Files) > 100000 {
+ return fmt.Errorf("%w: the patch holds too many files", ErrManifest)
+ }
+ seen := make(map[string]bool, len(manifest.Files))
+ var total int64
+ for i := range manifest.Files {
+ file := &manifest.Files[i]
+ clean, err := patchPath(file.Path)
+ if err != nil {
+ return err
+ }
+ if seen[clean] {
+ return fmt.Errorf("%w: %s appears twice", ErrManifest, clean)
+ }
+ seen[clean] = true
+ if file.Mode&^0o777 != 0 || file.Mode == 0 {
+ return fmt.Errorf("%w: %s has mode %o", ErrManifest, clean, file.Mode)
+ }
+ if file.Size < 0 || file.Size > maxPatchFile {
+ return fmt.Errorf("%w: %s has size %d", ErrManifest, clean, file.Size)
+ }
+ if len(file.SHA256) != 64 || strings.ToLower(file.SHA256) != file.SHA256 {
+ return fmt.Errorf("%w: %s has no lowercase SHA-256", ErrManifest, clean)
+ }
+ total += file.Size
+ if total > maxPatchTotal {
+ return fmt.Errorf("%w: the patched payload is larger than %d bytes", ErrManifest, int64(maxPatchTotal))
+ }
+ switch file.Op {
+ case "same":
+ if file.Blob != "" {
+ return fmt.Errorf("%w: %s is same and has a blob", ErrManifest, clean)
+ }
+ case "diff", "new":
+ if file.Blob == "" || !strings.HasPrefix(file.Blob, "files/") || strings.Contains(file.Blob, "..") {
+ return fmt.Errorf("%w: %s has an invalid blob name", ErrManifest, clean)
+ }
+ default:
+ return fmt.Errorf("%w: %s has op %q", ErrManifest, clean, file.Op)
+ }
+ }
+ return nil
+}
+
+// patchPath accepts a clean relative slash path inside the payload.
+func patchPath(relative string) (string, error) {
+ if relative == "" || strings.HasPrefix(relative, "/") || strings.Contains(relative, "\\") ||
+ strings.ContainsRune(relative, 0) || strings.Contains(relative, ":") {
+ return "", fmt.Errorf("%w: %q is not a relative path", ErrManifest, relative)
+ }
+ clean := path.Clean(relative)
+ if clean != relative || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
+ return "", fmt.Errorf("%w: %q is not a clean relative path", ErrManifest, relative)
+ }
+ return clean, nil
+}
+
+func emptyDir(dir string) bool {
+ entries, err := os.ReadDir(dir)
+ return err == nil && len(entries) == 0
+}
+
+// locateInstalledPayload is installedPayload, replaceable by tests.
+var locateInstalledPayload = installedPayload
+
+// ApplyPatch applies a downloaded patch to the running application and returns
+// a directory holding the new payload, ready for InstallOptions.Payload. app
+// may be empty to skip the identity check; the versions, the operating system
+// and the architecture the patch names must match.
+//
+// The installation is not touched: a staging directory is made, and the caller
+// removes it when Install fails or is not called.
+func ApplyPatch(patchPath, app, fromVersion, toVersion string) (string, error) {
+ patch, err := ReadPatch(patchPath)
+ if err != nil {
+ return "", err
+ }
+ defer patch.Close()
+ manifest := patch.Manifest()
+ if app != "" && manifest.App != app {
+ return "", fmt.Errorf("%w: the patch is for %q, not %q", ErrManifest, manifest.App, app)
+ }
+ if manifest.FromVersion != fromVersion {
+ return "", fmt.Errorf("%w: the patch updates from %s, not %s", ErrManifest, manifest.FromVersion, fromVersion)
+ }
+ if manifest.ToVersion != toVersion {
+ return "", fmt.Errorf("%w: the patch updates to %s, not %s", ErrManifest, manifest.ToVersion, toVersion)
+ }
+ if manifest.OS != runtime.GOOS || manifest.Arch != runtime.GOARCH {
+ return "", fmt.Errorf("%w: the patch is for %s/%s, not %s/%s", ErrManifest, manifest.OS, manifest.Arch, runtime.GOOS, runtime.GOARCH)
+ }
+ root, kind, err := locateInstalledPayload()
+ if err != nil {
+ return "", err
+ }
+ if kind != manifest.Payload {
+ return "", fmt.Errorf("%w: the patch carries a %s payload, but this installation is a %s", ErrManifest, manifest.Payload, kind)
+ }
+ staging, err := os.MkdirTemp("", "viva-payload-")
+ if err != nil {
+ return "", fmt.Errorf("update: creating the staging directory: %w", err)
+ }
+ if err := patch.Apply(root, staging); err != nil {
+ _ = os.RemoveAll(staging)
+ return "", err
+ }
+ return staging, nil
+}
diff --git a/update/patch_test.go b/update/patch_test.go
new file mode 100644
index 00000000..f67b3ac5
--- /dev/null
+++ b/update/patch_test.go
@@ -0,0 +1,296 @@
+package update
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/gabstv/go-bsdiff/pkg/bsdiff"
+)
+
+func digest(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func writePatch(t *testing.T, manifest PatchManifest, blobs map[string][]byte) string {
+ t.Helper()
+ var encoded bytes.Buffer
+ gz := gzip.NewWriter(&encoded)
+ writer := tar.NewWriter(gz)
+ manifestData, err := json.Marshal(manifest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries := []struct {
+ name string
+ data []byte
+ }{{"patch.json", manifestData}}
+ names := make([]string, 0, len(blobs))
+ for name := range blobs {
+ names = append(names, name)
+ }
+ for i := 0; i < len(names); i++ {
+ for j := i + 1; j < len(names); j++ {
+ if names[j] < names[i] {
+ names[i], names[j] = names[j], names[i]
+ }
+ }
+ }
+ for _, name := range names {
+ entries = append(entries, struct {
+ name string
+ data []byte
+ }{name, blobs[name]})
+ }
+ for _, entry := range entries {
+ header := &tar.Header{Name: entry.name, Mode: 0o644, Size: int64(len(entry.data)), Typeflag: tar.TypeReg}
+ if err := writer.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := writer.Write(entry.data); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gz.Close(); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "app.patch")
+ if err := os.WriteFile(path, encoded.Bytes(), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func fileManifest(path string, data []byte, op, blob string, mode uint32) PatchFile {
+ return PatchFile{Path: path, Mode: mode, Size: int64(len(data)), SHA256: digest(data), Op: op, Blob: blob}
+}
+
+func TestPatchApply(t *testing.T) {
+ oldRoot := t.TempDir()
+ oldData := []byte(strings.Repeat("old content ", 100))
+ sameData := []byte("unchanged")
+ if err := os.WriteFile(filepath.Join(oldRoot, "program"), oldData, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldRoot, "same.txt"), sameData, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldRoot, "dropped.txt"), []byte("gone"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ newData := []byte(strings.Repeat("old content ", 100) + " and something new")
+ addedData := []byte("a new resource")
+ diff, err := bsdiff.Bytes(oldData, newData)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest := PatchManifest{
+ Schema: PatchSchema, App: "dev.example.notes", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "linux", Arch: "amd64", Payload: "file",
+ Files: []PatchFile{
+ fileManifest("program", newData, "diff", "files/0001", 0o755),
+ fileManifest("same.txt", sameData, "same", "", 0o644),
+ fileManifest("resource/added.txt", addedData, "new", "files/0002", 0o644),
+ },
+ }
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": diff, "files/0002": addedData})
+
+ patch, err := ReadPatch(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staging := filepath.Join(t.TempDir(), "staged")
+ if err := patch.Apply(oldRoot, staging); err != nil {
+ t.Fatal(err)
+ }
+ patch.Close()
+
+ for name, want := range map[string][]byte{
+ "program": newData, "same.txt": sameData, "resource/added.txt": addedData,
+ } {
+ got, err := os.ReadFile(filepath.Join(staging, filepath.FromSlash(name)))
+ if err != nil {
+ t.Errorf("%s: %v", name, err)
+ continue
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("%s differs", name)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(staging, "dropped.txt")); !os.IsNotExist(err) {
+ t.Errorf("a deleted file was staged: %v", err)
+ }
+ if info, err := os.Stat(filepath.Join(staging, "program")); err != nil || info.Mode().Perm() != 0o755 {
+ t.Errorf("program mode = %v, %v", info.Mode(), err)
+ }
+}
+
+func TestPatchRefusesAChangedBase(t *testing.T) {
+ oldRoot := t.TempDir()
+ oldData := []byte("the installed file")
+ newData := []byte("the installed file, updated")
+ diff, _ := bsdiff.Bytes(oldData, newData)
+ manifest := PatchManifest{
+ Schema: PatchSchema, App: "dev.example.notes", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "linux", Arch: "amd64", Payload: "file",
+ Files: []PatchFile{fileManifest("program", newData, "diff", "files/0001", 0o755)},
+ }
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": diff})
+ patch, err := ReadPatch(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldRoot, "program"), []byte("something else entirely"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ staging := filepath.Join(t.TempDir(), "staged")
+ if err := patch.Apply(oldRoot, staging); !errors.Is(err, ErrChecksum) {
+ t.Fatalf("err = %v, want ErrChecksum", err)
+ }
+ if _, err := os.Stat(staging); !os.IsNotExist(err) {
+ t.Errorf("staging survived a failed patch: %v", err)
+ }
+}
+
+func TestPatchRefusesUnsafePaths(t *testing.T) {
+ for _, path := range []string{"../evil", "/absolute", "a/../../evil", "a\\b", "a:b", "", "."} {
+ manifest := PatchManifest{
+ Schema: PatchSchema, App: "app", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "linux", Arch: "amd64", Payload: "file",
+ Files: []PatchFile{{Path: path, Mode: 0o644, Size: 1, SHA256: digest([]byte("x")), Op: "new", Blob: "files/0001"}},
+ }
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": []byte("x")})
+ if _, err := ReadPatch(patchPath); !errors.Is(err, ErrManifest) {
+ t.Errorf("%q: err = %v, want ErrManifest", path, err)
+ }
+ }
+}
+
+func TestPatchRefusesBadManifests(t *testing.T) {
+ base := func() PatchManifest {
+ return PatchManifest{
+ Schema: PatchSchema, App: "app", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "linux", Arch: "amd64", Payload: "file",
+ Files: []PatchFile{{Path: "program", Mode: 0o755, Size: 1, SHA256: digest([]byte("x")), Op: "new", Blob: "files/0001"}},
+ }
+ }
+ tests := map[string]func(*PatchManifest){
+ "schema": func(m *PatchManifest) { m.Schema = 2 },
+ "os": func(m *PatchManifest) { m.OS = "windows" },
+ "arch": func(m *PatchManifest) { m.Arch = "386" },
+ "payload": func(m *PatchManifest) { m.Payload = "dir" },
+ "no files": func(m *PatchManifest) { m.Files = nil },
+ "bad version": func(m *PatchManifest) { m.FromVersion = "1" },
+ "mode setuid": func(m *PatchManifest) { m.Files[0].Mode = 0o4755 },
+ "mode zero": func(m *PatchManifest) { m.Files[0].Mode = 0 },
+ "bad digest": func(m *PatchManifest) { m.Files[0].SHA256 = "abc" },
+ "missing blob": func(m *PatchManifest) { m.Files[0].Blob = "" },
+ "unknown op": func(m *PatchManifest) { m.Files[0].Op = "copy" },
+ "same with blob": func(m *PatchManifest) {
+ m.Files[0].Op, m.Files[0].Blob = "same", "files/0001"
+ },
+ }
+ for name, change := range tests {
+ manifest := base()
+ change(&manifest)
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": []byte("x")})
+ if _, err := ReadPatch(patchPath); !errors.Is(err, ErrManifest) {
+ t.Errorf("%s: err = %v, want ErrManifest", name, err)
+ }
+ }
+}
+
+func TestPatchRefusesANonEmptyStaging(t *testing.T) {
+ oldRoot := t.TempDir()
+ data := []byte("x")
+ manifest := PatchManifest{
+ Schema: PatchSchema, App: "app", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: "linux", Arch: "amd64", Payload: "file",
+ Files: []PatchFile{fileManifest("program", data, "new", "files/0001", 0o755)},
+ }
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": data})
+ patch, err := ReadPatch(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staging := filepath.Join(t.TempDir(), "staged")
+ if err := os.MkdirAll(staging, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(staging, "existing"), []byte("keep"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := patch.Apply(oldRoot, staging); err == nil {
+ t.Fatal("a non-empty staging directory was accepted")
+ }
+ if data, err := os.ReadFile(filepath.Join(staging, "existing")); err != nil || string(data) != "keep" {
+ t.Errorf("the existing staging content was removed: %v", err)
+ }
+}
+
+func TestApplyPatchChecksNameAndVersions(t *testing.T) {
+ oldRoot := t.TempDir()
+ oldData := []byte("old program")
+ newData := []byte("new program")
+ if err := os.WriteFile(filepath.Join(oldRoot, "program"), oldData, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ diff, err := bsdiff.Bytes(oldData, newData)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifest := PatchManifest{
+ Schema: PatchSchema, App: "dev.example.notes", FromVersion: "1.0.0", ToVersion: "1.1.0",
+ OS: runtime.GOOS, Arch: runtime.GOARCH, Payload: "file",
+ Files: []PatchFile{fileManifest("program", newData, "diff", "files/0001", 0o755)},
+ }
+ patchPath := writePatch(t, manifest, map[string][]byte{"files/0001": diff})
+
+ original := locateInstalledPayload
+ defer func() { locateInstalledPayload = original }()
+ located := "file"
+ locateInstalledPayload = func() (string, string, error) { return oldRoot, located, nil }
+
+ staging, err := ApplyPatch(patchPath, "dev.example.notes", "1.0.0", "1.1.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(filepath.Join(staging, "program"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, newData) {
+ t.Fatal("the staged program is not the new one")
+ }
+ if err := os.RemoveAll(staging); err != nil {
+ t.Fatal(err)
+ }
+
+ for name, call := range map[string]func() (string, error){
+ "wrong app": func() (string, error) { return ApplyPatch(patchPath, "other", "1.0.0", "1.1.0") },
+ "wrong from": func() (string, error) { return ApplyPatch(patchPath, "dev.example.notes", "0.9.0", "1.1.0") },
+ "wrong to": func() (string, error) { return ApplyPatch(patchPath, "dev.example.notes", "1.0.0", "1.2.0") },
+ "wrong platform": func() (string, error) { return ApplyPatch(patchPath, "dev.example.notes", "1.0.0", "1.1.0") },
+ } {
+ if name == "wrong platform" {
+ locateInstalledPayload = func() (string, string, error) { return oldRoot, "app", nil }
+ }
+ if _, err := call(); !errors.Is(err, ErrManifest) {
+ t.Errorf("%s: err = %v, want ErrManifest", name, err)
+ }
+ locateInstalledPayload = func() (string, string, error) { return oldRoot, "file", nil }
+ }
+}
diff --git a/update/payload_darwin.go b/update/payload_darwin.go
new file mode 100644
index 00000000..c61fc602
--- /dev/null
+++ b/update/payload_darwin.go
@@ -0,0 +1,21 @@
+//go:build darwin
+
+package update
+
+import (
+ "fmt"
+ "os"
+)
+
+// installedPayload returns the .app bundle the running program belongs to.
+func installedPayload() (root, kind string, err error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return "", "", fmt.Errorf("update: locating the running program: %w", err)
+ }
+ bundle, err := bundleRootFor(executable)
+ if err != nil {
+ return "", "", err
+ }
+ return bundle, "app", nil
+}
diff --git a/update/payload_linux.go b/update/payload_linux.go
new file mode 100644
index 00000000..3e556399
--- /dev/null
+++ b/update/payload_linux.go
@@ -0,0 +1,23 @@
+//go:build linux
+
+package update
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// installedPayload returns the directory holding the running executable, which
+// is all a Linux update installs.
+func installedPayload() (root, kind string, err error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return "", "", fmt.Errorf("update: locating the running executable: %w", err)
+ }
+ resolved, err := filepath.EvalSymlinks(executable)
+ if err != nil {
+ return "", "", fmt.Errorf("update: resolving the running executable: %w", err)
+ }
+ return filepath.Dir(resolved), "file", nil
+}
diff --git a/update/payload_other.go b/update/payload_other.go
new file mode 100644
index 00000000..4af4f741
--- /dev/null
+++ b/update/payload_other.go
@@ -0,0 +1,9 @@
+//go:build !darwin && !linux && !windows
+
+package update
+
+// installedPayload has no application to describe on a target without a native
+// executable.
+func installedPayload() (root, kind string, err error) {
+ return "", "", ErrUnsupported
+}
diff --git a/update/payload_windows.go b/update/payload_windows.go
new file mode 100644
index 00000000..e5bc7a03
--- /dev/null
+++ b/update/payload_windows.go
@@ -0,0 +1,11 @@
+//go:build windows
+
+package update
+
+import "fmt"
+
+// installedPayload has nothing to describe on Windows: the NSIS installer owns
+// the installation and patches are not offered there.
+func installedPayload() (root, kind string, err error) {
+ return "", "", fmt.Errorf("%w: Windows installs updates from an installer, not a payload", ErrUnsupported)
+}
diff --git a/website/content/guides/en/build-and-package.md b/website/content/guides/en/build-and-package.md
index d1306a83..8cfad371 100644
--- a/website/content/guides/en/build-and-package.md
+++ b/website/content/guides/en/build-and-package.md
@@ -72,7 +72,7 @@ category = "public.app-category.productivity"
entitlements = "app.entitlements" # optional: signed into the bundle with the hardened runtime
```
-The file is decoded strictly. An unknown table or key, a duplicate or a value of the wrong type is an error that names the line, so a typo never changes what gets built. `[mac.dmg]` shapes the window the `.dmg` opens in: `background` (a PNG, tagged 144 dpi for a 2x picture; its size in points is the window's size), `icon_size`, and `app_position` / `applications_position` as `[x, y]` points from the window's top left to an icon's middle. Without it the image opens in a 540×380 window with a plain light ground and an arrow from the application to the Applications shortcut. There are also `[mac.info]` for extra `Info.plist` strings, `[windows]` (publisher, install scope, URL) and `[linux]` (menu category, comment, maintainer, dependencies, keywords) for the installers.
+The file is decoded strictly. An unknown table or key, a duplicate or a value of the wrong type is an error that names the line, so a typo never changes what gets built. `[mac.dmg]` shapes the window the `.dmg` opens in: `background` (a PNG, tagged 144 dpi for a 2x picture; its size in points is the window's size), `icon_size`, and `app_position` / `applications_position` as `[x, y]` points from the window's top left to an icon's middle. Without it the image opens in a 540×380 window with a plain light ground and an arrow from the application to the Applications shortcut. There are also `[mac.info]` for extra `Info.plist` strings, `[windows]` (publisher, install scope, URL) and `[linux]` (menu category, comment, maintainer, Debian dependencies, Arch dependencies, keywords) for the installers.
## Release builds
@@ -92,6 +92,7 @@ viva package # build/Notes.app
viva package --format app,zip,dmg
viva package --target windows --format nsis,zip
viva package --target linux --format tar.gz,deb
+viva package --target linux --format tar.gz,pkgbuild --pkgbuild-url https://example.com/downloads/notes-1.0.0-linux-x86_64.tar.gz
viva package --program ./notes-signed # package an executable you built or signed yourself
```
@@ -99,7 +100,9 @@ viva package --program ./notes-signed # package an executable you buil
| --- | --- |
| macOS | `.app`, reproducible `.zip`, `.dmg` |
| Windows | NSIS installer (needs `makensis`), or `.zip` |
-| Linux | `tar.gz` with an install script, or `.deb` |
+| Linux | `tar.gz` with an install script, `.deb`, or a `PKGBUILD` with its `.SRCINFO` for an Arch binary package |
+
+A `PKGBUILD` installs the program under `/opt/-bin`, links it into `/usr/bin`, and installs the menu entry, icon and document types; it never builds anything, so it needs no Arch machine to write. The `tar.gz` is its source: by default makepkg looks for it beside the `PKGBUILD`, and `--pkgbuild-url` gives the address of the released archive instead (the `tar.gz::` form, so the file keeps its name). Write each architecture into its own directory, as `PKGBUILD` is one file per directory. Set `[linux] arch_depends` for libraries the application needs beyond the Vulkan, XCB and Cairo packages every Viva application uses.
**Signing is optional.** With nothing configured nothing is signed: macOS blocks a downloaded bundle and Windows SmartScreen warns about the installer. `--program` exists so you can build, sign, and then package that exact executable yourself.
diff --git a/website/content/guides/en/getting-started.md b/website/content/guides/en/getting-started.md
index 8255e2e2..be08cb71 100644
--- a/website/content/guides/en/getting-started.md
+++ b/website/content/guides/en/getting-started.md
@@ -22,7 +22,7 @@ go mod init example.com/hello
go get github.com/vivaui/viva
```
-`go get` without a version resolves to the latest tagged release, which is `v0.6.1`. Pin it by naming the version: `go get github.com/vivaui/viva@v0.6.1`. Viva is not yet at 1.0, so the API in `go.mod` can still change between minor releases; read the release notes before upgrading.
+`go get` without a version resolves to the latest tagged release, which is `v0.6.3`. Pin it by naming the version: `go get github.com/vivaui/viva@v0.6.3`. Viva is not yet at 1.0, so the API in `go.mod` can still change between minor releases; read the release notes before upgrading.
If you are working on Viva itself rather than using it, `replace` it with your checkout instead:
diff --git a/website/content/guides/en/testing.md b/website/content/guides/en/testing.md
index 52971203..bc557371 100644
--- a/website/content/guides/en/testing.md
+++ b/website/content/guides/en/testing.md
@@ -74,6 +74,40 @@ func TestClickCounts(t *testing.T) {
The test finds controls by the name a user and a screen reader know them by (`Hit.Label`), not by coordinates. A page names its elements itself, as paths such as `page/~k1/#0` in its tree; `c.Part("word")` gives an element a name of yours, which a test or the headless driver can address by path.
+## headless.Session
+
+`headless.Open` wraps that driver in a session that finds controls by their
+accessible name and owns the clock. `Now`, `Advance` and `Frame` move time and
+paint, `Transitioning` says whether anything is still moving, `Access` returns
+the accessibility tree of the last frame, and `Notifications` returns what the
+application sent:
+
+```go
+session, err := headless.Open(config, nil, headless.Options{})
+if err != nil {
+ t.Fatal(err)
+}
+defer session.Close()
+
+node, ok := session.AccessByRole(gfx.RoleCheckbox, "Auto update")
+if !ok || !node.Checked {
+ t.Fatalf("checkbox = %+v", node)
+}
+session.ClickLabel("Install")
+session.Advance(200 * time.Millisecond)
+if session.Transitioning() {
+ t.Error("the progress bar is still moving")
+}
+if got := session.Notifications(); len(got) != 1 || got[0].Title != "Installed" {
+ t.Errorf("notifications = %+v", got)
+}
+```
+
+`Access` reports what assistive technology is told: the role, bounds, name,
+value, description, states, range and live region of every control, in reading
+order. It is the same tree the platform adapters send to the system, without the
+platform.
+
## Mistakes that compile
A page can compile and still do the wrong thing: a `Button` with the same label in every row of a loop whose rows have no Key can take a click meant for another row when a row is added above. `viva vet` finds it without running anything:
diff --git a/website/content/guides/en/windows.md b/website/content/guides/en/windows.md
index e27e9f59..6e07cb57 100644
--- a/website/content/guides/en/windows.md
+++ b/website/content/guides/en/windows.md
@@ -65,6 +65,17 @@ The headless application's `Window.SetFullscreen` queues work for the driver's
next pump, without a worker or polling. Its command protocol also provides
`fullscreen on|off` and `fullscreenmonitor x y width height`.
+## Changing the content size
+
+Call `window.SetSize(width, height)` to ask the window manager for a new content
+size in logical pixels. The call is asynchronous and safe from any goroutine;
+`Config.OnResize` reports the size that was actually granted, which may be
+clamped to the window minimum, the screen's work area or the platform's limit
+(width and height must be 1 through 65535). A maximized, minimized or fullscreen
+window ignores the request. `ContentWindow` from `OpenContentWindow` forwards the
+same call, and the headless application applies it on the driver's next pump.
+Hosts without the capability return an error wrapping `app.ErrUnsupported`.
+
## A complete example
The program below opens a custom-title-bar window with a Settings button that opens a second window, and refuses to close while a note is unsaved. The strip along the top is an ordinary `ui.Row` padded by `app.TitlebarInsets`, so the title never runs under the window buttons, and the confirmation is a `ui.ModalWith` declared only while a close is pending:
diff --git a/website/content/guides/zh/build-and-package.md b/website/content/guides/zh/build-and-package.md
index c2c1ac86..eb3b3253 100644
--- a/website/content/guides/zh/build-and-package.md
+++ b/website/content/guides/zh/build-and-package.md
@@ -72,7 +72,7 @@ category = "public.app-category.productivity"
entitlements = "app.entitlements" # 可选:连同 hardened runtime 一起签进应用包
```
-文件按严格模式解析。未知的表或键、重复项、类型错误的值,都会报出带行号的错误,所以拼写错误不会悄悄改变构建结果。`[mac.dmg]` 决定 `.dmg` 打开时的窗口样子:`background`(PNG,2x 图片标 144 dpi;其点数尺寸就是窗口尺寸)、`icon_size`,以及 `app_position` / `applications_position`(`[x, y]`,从窗口左上角到图标中心的点数)。不设置时镜像以 540×380 的窗口打开,底色为浅色,并画有从应用指向 Applications 的箭头。另外还有用于额外 `Info.plist` 字符串的 `[mac.info]`,以及供安装包使用的 `[windows]`(发布者、安装范围、网址)和 `[linux]`(菜单分类、说明、维护者、依赖、关键字)。
+文件按严格模式解析。未知的表或键、重复项、类型错误的值,都会报出带行号的错误,所以拼写错误不会悄悄改变构建结果。`[mac.dmg]` 决定 `.dmg` 打开时的窗口样子:`background`(PNG,2x 图片标 144 dpi;其点数尺寸就是窗口尺寸)、`icon_size`,以及 `app_position` / `applications_position`(`[x, y]`,从窗口左上角到图标中心的点数)。不设置时镜像以 540×380 的窗口打开,底色为浅色,并画有从应用指向 Applications 的箭头。另外还有用于额外 `Info.plist` 字符串的 `[mac.info]`,以及供安装包使用的 `[windows]`(发布者、安装范围、网址)和 `[linux]`(菜单分类、说明、维护者、Debian 依赖、Arch 依赖、关键字)。
## 发布构建
@@ -92,6 +92,7 @@ viva package # build/Notes.app
viva package --format app,zip,dmg
viva package --target windows --format nsis,zip
viva package --target linux --format tar.gz,deb
+viva package --target linux --format tar.gz,pkgbuild --pkgbuild-url https://example.com/downloads/notes-1.0.0-linux-x86_64.tar.gz
viva package --program ./notes-signed # 打包你自己构建或签名的可执行文件
```
@@ -99,7 +100,9 @@ viva package --program ./notes-signed # 打包你自己构建或签名
| --- | --- |
| macOS | `.app`、可复现的 `.zip`、`.dmg` |
| Windows | NSIS 安装程序(需要 `makensis`),或 `.zip` |
-| Linux | 带安装脚本的 `tar.gz`,或 `.deb` |
+| Linux | 带安装脚本的 `tar.gz`、`.deb`,或 Arch 二进制包用的 `PKGBUILD` 与 `.SRCINFO` |
+
+`PKGBUILD` 把程序装到 `/opt/-bin`,链接到 `/usr/bin`,并安装菜单项、图标和文档类型;它不构建任何东西,所以写它不需要 Arch 机器。它的源是那个 `tar.gz`:默认 makepkg 在 `PKGBUILD` 旁边找它,`--pkgbuild-url` 则给出已发布归档的地址(写成 `tar.gz::`,保留文件名)。每个架构写到各自的目录,因为一个目录只有一个 `PKGBUILD`。除每个 Viva 应用都需要的 Vulkan、XCB 和 Cairo 包之外,应用还需要别的库时,用 `[linux] arch_depends` 列出。
**签名是可选的。** 什么都不配置就什么都不签:macOS 会阻止下载的应用包,Windows SmartScreen 会对安装程序给出警告。`--program` 的存在就是为了让你自己先构建、签名,再打包这个确定的可执行文件。
diff --git a/website/content/guides/zh/getting-started.md b/website/content/guides/zh/getting-started.md
index a52e04e2..5ab36bfc 100644
--- a/website/content/guides/zh/getting-started.md
+++ b/website/content/guides/zh/getting-started.md
@@ -22,7 +22,7 @@ go mod init example.com/hello
go get github.com/vivaui/viva
```
-`go get` 不带版本号时取最新的正式版本,目前就是 `v0.6.1`。要固定版本就写明:`go get github.com/vivaui/viva@v0.6.1`。Viva 还没有到 1.0,所以小版本之间 `go.mod` 的 API 仍可能变化;升级前看一眼发版说明。
+`go get` 不带版本号时取最新的正式版本,目前就是 `v0.6.3`。要固定版本就写明:`go get github.com/vivaui/viva@v0.6.3`。Viva 还没有到 1.0,所以小版本之间 `go.mod` 的 API 仍可能变化;升级前看一眼发版说明。
如果你是在 Viva 仓库里改框架本身、而不是使用它,改用 `replace` 指向检出目录:
diff --git a/website/content/guides/zh/testing.md b/website/content/guides/zh/testing.md
index eede3472..163d2861 100644
--- a/website/content/guides/zh/testing.md
+++ b/website/content/guides/zh/testing.md
@@ -74,6 +74,33 @@ func TestClickCounts(t *testing.T) {
测试按用户和读屏器认识控件的名字(`Hit.Label`)找控件,而不是按坐标。页面自己为元素命名,在它的树里是 `page/~k1/#0` 这样的路径;`c.Part("word")` 给元素起一个你自己的名字,测试和无头驱动可以按路径定位。
+## headless.Session
+
+`headless.Open` 把那个 driver 包成一个 session:按可访问名称查找控件,并拥有自己的时钟。`Now`、`Advance` 和 `Frame` 控制时间与绘制,`Transitioning` 说明是否还有东西在动,`Access` 返回最后一帧的无障碍树,`Notifications` 返回应用发出的通知:
+
+```go
+session, err := headless.Open(config, nil, headless.Options{})
+if err != nil {
+ t.Fatal(err)
+}
+defer session.Close()
+
+node, ok := session.AccessByRole(gfx.RoleCheckbox, "Auto update")
+if !ok || !node.Checked {
+ t.Fatalf("checkbox = %+v", node)
+}
+session.ClickLabel("Install")
+session.Advance(200 * time.Millisecond)
+if session.Transitioning() {
+ t.Error("the progress bar is still moving")
+}
+if got := session.Notifications(); len(got) != 1 || got[0].Title != "Installed" {
+ t.Errorf("notifications = %+v", got)
+}
+```
+
+`Access` 报告辅助技术看到的内容:每个控件的角色、边界、名称、值、描述、状态、数值范围和 live region,按阅读顺序排列。它和平台适配器交给系统的是同一棵树,只是不含平台。
+
## 能编译却做错事的写法
页面可能能编译却做错事:循环里每一行都有同名 `Button`、行又没加 Key 时,上方新增一行后,点击可能落到别的行上。`viva vet` 不运行程序就能找出它:
diff --git a/website/content/guides/zh/windows.md b/website/content/guides/zh/windows.md
index 4b10f7da..419d0504 100644
--- a/website/content/guides/zh/windows.md
+++ b/website/content/guides/zh/windows.md
@@ -38,6 +38,10 @@ Windows 使用整个显示器而不是任务栏工作区,并保存之前的位
无头应用的 `Window.SetFullscreen` 把工作排进 driver 的下一次 pump,不需要 worker,也不轮询。它的命令协议还提供 `fullscreen on|off` 和 `fullscreenmonitor x y width height`。
+## 改变内容尺寸
+
+调用 `window.SetSize(width, height)` 请求窗口管理器把内容区域改成给定的逻辑像素尺寸。调用是异步的,可以从任意 goroutine 发起;`Config.OnResize` 报告实际获得的尺寸,它可能被窗口最小尺寸、屏幕工作区或平台上限(宽高必须在 1 到 65535 之间)夹住。最大化、最小化或全屏的窗口会忽略这个请求。`OpenContentWindow` 返回的 `ContentWindow` 转发同一个调用,无头应用会在 driver 的下一次 pump 应用它。不支持该能力的宿主返回一个包装了 `app.ErrUnsupported` 的错误。
+
## 完整示例
下面的程序打开一个自定义标题栏窗口,其中的“Settings”按钮会打开第二个窗口,并且在有未保存的笔记时拒绝关闭。顶部条带是一个普通的 `ui.Row`,用 `app.TitlebarInsets` 留出内边距,所以标题不会压到窗口按钮下面;确认框是 `ui.ModalWith`,只在有关闭请求挂起时才声明: