Harden native builds and tab state
- commit
- 0747243ff4ff497ca7284c085a5ce89379132225
- author
- brice · · 2026-05-05 17:40 UTC
- parent
- 17b7e39
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +228 -54
M .github/workflows/aur.yml +2 -2
| @@ -81,9 +81,9 @@ jobs: | |||
| 81 | 81 | 'build() {' \ | |
| 82 | 82 | ' cd dumber' \ | |
| 83 | 83 | ' export CGO_ENABLED=0' \ | |
| 84 | - | ' # Do not set -buildmode=pie globally: the build also compiles js/wasm' \ | |
| 85 | - | ' # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm.' \ | |
| 84 | + | ' # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.' \ | |
| 86 | 85 | ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \ | |
| 86 | + | ' export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"' \ | |
| 87 | 87 | ' make build' \ | |
| 88 | 88 | '}' \ | |
| 89 | 89 | '' \ | |
| @@ -81,9 +81,9 @@ jobs: | |||||
| 81 | 'build() {' \ | 81 | 'build() {' \ | ||
| 82 | ' cd dumber' \ | 82 | ' cd dumber' \ | ||
| 83 | ' export CGO_ENABLED=0' \ | 83 | ' export CGO_ENABLED=0' \ | ||
| 84 | - | ' # Do not set -buildmode=pie globally: the build also compiles js/wasm' \ | 84 | + | ' # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.' \ |
| 85 | - | ' # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm.' \ | |||
| 86 | ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \ | 85 | ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \ | ||
| 86 | + | ' export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"' \ | |||
| 87 | ' make build' \ | 87 | ' make build' \ | ||
| 88 | '}' \ | 88 | '}' \ | ||
| 89 | '' \ | 89 | '' \ | ||
M Makefile +9 -3
| @@ -22,6 +22,12 @@ VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0 | |||
| 22 | 22 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | |
| 23 | 23 | BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| 24 | 24 | ||
| 25 | + | # Go flags. NATIVE_GOFLAGS can add native-only hardening such as | |
| 26 | + | # -buildmode=pie without breaking js/wasm systemviews builds. | |
| 27 | + | GOFLAGS?=-mod=mod | |
| 28 | + | NATIVE_GOFLAGS?=$(GOFLAGS) | |
| 29 | + | WASM_GOFLAGS?=$(GOFLAGS) | |
| 30 | + | ||
| 25 | 31 | # Linker flags | |
| 26 | 32 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | |
| 27 | 33 | ||
| @@ -42,7 +48,7 @@ help: ## Show this help message | |||
| 42 | 48 | build: build-systemviews ## Build the application (pure Go, no CGO) | |
| 43 | 49 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 44 | 50 | @mkdir -p $(DIST_DIR) | |
| 45 | - | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 51 | + | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 46 | 52 | @rm -f $(DIST_DIR)/cef-helper | |
| 47 | 53 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 48 | 54 | ||
| @@ -56,14 +62,14 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |||
| 56 | 62 | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | |
| 57 | 63 | @mkdir -p assets/systemviews | |
| 58 | 64 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |
| 59 | - | GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 65 | + | GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 60 | 66 | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | |
| 61 | 67 | @echo "Systemviews build complete" | |
| 62 | 68 | ||
| 63 | 69 | build-quick: ## Build quickly for backend development | |
| 64 | 70 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |
| 65 | 71 | @mkdir -p $(DIST_DIR) | |
| 66 | - | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 72 | + | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 67 | 73 | @rm -f $(DIST_DIR)/cef-helper | |
| 68 | 74 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 69 | 75 | ||
| @@ -22,6 +22,12 @@ VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0 | |||||
| 22 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | 22 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | ||
| 23 | BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | 23 | BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | ||
| 24 | 24 | ||||
| 25 | + | # Go flags. NATIVE_GOFLAGS can add native-only hardening such as | |||
| 26 | + | # -buildmode=pie without breaking js/wasm systemviews builds. | |||
| 27 | + | GOFLAGS?=-mod=mod | |||
| 28 | + | NATIVE_GOFLAGS?=$(GOFLAGS) | |||
| 29 | + | WASM_GOFLAGS?=$(GOFLAGS) | |||
| 30 | + | ||||
| 25 | # Linker flags | 31 | # Linker flags | ||
| 26 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | 32 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | ||
| 27 | 33 | ||||
| @@ -42,7 +48,7 @@ help: ## Show this help message | |||||
| 42 | build: build-systemviews ## Build the application (pure Go, no CGO) | 48 | build: build-systemviews ## Build the application (pure Go, no CGO) | ||
| 43 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | 49 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | ||
| 44 | @mkdir -p $(DIST_DIR) | 50 | @mkdir -p $(DIST_DIR) | ||
| 45 | - | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 51 | + | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 46 | @rm -f $(DIST_DIR)/cef-helper | 52 | @rm -f $(DIST_DIR)/cef-helper | ||
| 47 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 53 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 48 | 54 | ||||
| @@ -56,14 +62,14 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |||||
| 56 | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | 62 | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | ||
| 57 | @mkdir -p assets/systemviews | 63 | @mkdir -p assets/systemviews | ||
| 58 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | 64 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | ||
| 59 | - | GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | 65 | + | GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews |
| 60 | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | 66 | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | ||
| 61 | @echo "Systemviews build complete" | 67 | @echo "Systemviews build complete" | ||
| 62 | 68 | ||||
| 63 | build-quick: ## Build quickly for backend development | 69 | build-quick: ## Build quickly for backend development | ||
| 64 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | 70 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | ||
| 65 | @mkdir -p $(DIST_DIR) | 71 | @mkdir -p $(DIST_DIR) | ||
| 66 | - | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 72 | + | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 67 | @rm -f $(DIST_DIR)/cef-helper | 73 | @rm -f $(DIST_DIR)/cef-helper | ||
| 68 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 74 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 69 | 75 | ||||
M aur/dumber-browser-git/PKGBUILD +2 -2
| @@ -36,9 +36,9 @@ pkgver() { | |||
| 36 | 36 | build() { | |
| 37 | 37 | cd dumber | |
| 38 | 38 | export CGO_ENABLED=0 | |
| 39 | - | # Do not set -buildmode=pie globally: the build also compiles js/wasm | |
| 40 | - | # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm. | |
| 39 | + | # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds. | |
| 41 | 40 | export GOFLAGS="-trimpath -mod=readonly -modcacherw" | |
| 41 | + | export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie" | |
| 42 | 42 | make build | |
| 43 | 43 | } | |
| 44 | 44 | ||
| @@ -36,9 +36,9 @@ pkgver() { | |||||
| 36 | build() { | 36 | build() { | ||
| 37 | cd dumber | 37 | cd dumber | ||
| 38 | export CGO_ENABLED=0 | 38 | export CGO_ENABLED=0 | ||
| 39 | - | # Do not set -buildmode=pie globally: the build also compiles js/wasm | 39 | + | # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds. |
| 40 | - | # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm. | |||
| 41 | export GOFLAGS="-trimpath -mod=readonly -modcacherw" | 40 | export GOFLAGS="-trimpath -mod=readonly -modcacherw" | ||
| 41 | + | export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie" | |||
| 42 | make build | 42 | make build | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
M internal/domain/entity/session_state.go +1 -1
| @@ -154,7 +154,7 @@ type SessionInfo struct { | |||
| 154 | 154 | // CountPanes returns the total number of panes in the session state. | |
| 155 | 155 | func (s *SessionState) CountPanes() int { | |
| 156 | 156 | // v2: count across windows | |
| 157 | - | if len(s.Windows) > 0 { | |
| 157 | + | if s.Version >= SessionStateVersion { | |
| 158 | 158 | count := 0 | |
| 159 | 159 | for _, w := range s.Windows { | |
| 160 | 160 | for _, tab := range w.Tabs { | |
| @@ -154,7 +154,7 @@ type SessionInfo struct { | |||||
| 154 | // CountPanes returns the total number of panes in the session state. | 154 | // CountPanes returns the total number of panes in the session state. | ||
| 155 | func (s *SessionState) CountPanes() int { | 155 | func (s *SessionState) CountPanes() int { | ||
| 156 | // v2: count across windows | 156 | // v2: count across windows | ||
| 157 | - | if len(s.Windows) > 0 { | 157 | + | if s.Version >= SessionStateVersion { |
| 158 | count := 0 | 158 | count := 0 | ||
| 159 | for _, w := range s.Windows { | 159 | for _, w := range s.Windows { | ||
| 160 | for _, tab := range w.Tabs { | 160 | for _, tab := range w.Tabs { | ||
M internal/domain/entity/session_state_test.go +11 -0
| @@ -609,6 +609,17 @@ func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) { | |||
| 609 | 609 | assert.Equal(t, 0, state.ActiveWindowIndex) | |
| 610 | 610 | } | |
| 611 | 611 | ||
| 612 | + | func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) { | |
| 613 | + | state := &entity.SessionState{ | |
| 614 | + | Version: entity.SessionStateVersion, | |
| 615 | + | Tabs: []entity.TabSnapshot{{ | |
| 616 | + | Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}}, | |
| 617 | + | }}, | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | assert.Equal(t, 0, state.CountPanes()) | |
| 621 | + | } | |
| 622 | + | ||
| 612 | 623 | func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) { | |
| 613 | 624 | pane := entity.NewPane("p1") | |
| 614 | 625 | pane.URI = "https://x.com" | |
| @@ -609,6 +609,17 @@ func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) { | |||||
| 609 | assert.Equal(t, 0, state.ActiveWindowIndex) | 609 | assert.Equal(t, 0, state.ActiveWindowIndex) | ||
| 610 | } | 610 | } | ||
| 611 | 611 | ||||
| 612 | + | func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) { | |||
| 613 | + | state := &entity.SessionState{ | |||
| 614 | + | Version: entity.SessionStateVersion, | |||
| 615 | + | Tabs: []entity.TabSnapshot{{ | |||
| 616 | + | Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}}, | |||
| 617 | + | }}, | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | assert.Equal(t, 0, state.CountPanes()) | |||
| 621 | + | } | |||
| 622 | + | ||||
| 612 | func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) { | 623 | func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) { | ||
| 613 | pane := entity.NewPane("p1") | 624 | pane := entity.NewPane("p1") | ||
| 614 | pane.URI = "https://x.com" | 625 | pane.URI = "https://x.com" | ||
M internal/domain/entity/tab.go +36 -4
| @@ -2,6 +2,7 @@ package entity | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "sync" | |
| 5 | 6 | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -51,6 +52,8 @@ type TabList struct { | |||
| 51 | 52 | Tabs []*Tab | |
| 52 | 53 | ActiveTabID TabID | |
| 53 | 54 | PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching | |
| 55 | + | ||
| 56 | + | mu sync.RWMutex | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | // NewTabList creates an empty tab list. | |
| @@ -62,6 +65,9 @@ func NewTabList() *TabList { | |||
| 62 | 65 | ||
| 63 | 66 | // Add appends a tab to the list. | |
| 64 | 67 | func (tl *TabList) Add(tab *Tab) { | |
| 68 | + | tl.mu.Lock() | |
| 69 | + | defer tl.mu.Unlock() | |
| 70 | + | ||
| 65 | 71 | tab.Position = len(tl.Tabs) | |
| 66 | 72 | tl.Tabs = append(tl.Tabs, tab) | |
| 67 | 73 | if tl.ActiveTabID == "" { | |
| @@ -71,6 +77,9 @@ func (tl *TabList) Add(tab *Tab) { | |||
| 71 | 77 | ||
| 72 | 78 | // Remove removes a tab by ID and reindexes positions. | |
| 73 | 79 | func (tl *TabList) Remove(id TabID) bool { | |
| 80 | + | tl.mu.Lock() | |
| 81 | + | defer tl.mu.Unlock() | |
| 82 | + | ||
| 74 | 83 | for i, tab := range tl.Tabs { | |
| 75 | 84 | if tab.ID == id { | |
| 76 | 85 | tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...) | |
| @@ -99,6 +108,9 @@ func (tl *TabList) Remove(id TabID) bool { | |||
| 99 | 108 | ||
| 100 | 109 | // Find returns a tab by ID. | |
| 101 | 110 | func (tl *TabList) Find(id TabID) *Tab { | |
| 111 | + | tl.mu.RLock() | |
| 112 | + | defer tl.mu.RUnlock() | |
| 113 | + | ||
| 102 | 114 | for _, tab := range tl.Tabs { | |
| 103 | 115 | if tab.ID == id { | |
| 104 | 116 | return tab | |
| @@ -114,11 +126,17 @@ func (tl *TabList) ActiveTab() *Tab { | |||
| 114 | 126 | ||
| 115 | 127 | // Count returns the number of tabs. | |
| 116 | 128 | func (tl *TabList) Count() int { | |
| 129 | + | tl.mu.RLock() | |
| 130 | + | defer tl.mu.RUnlock() | |
| 131 | + | ||
| 117 | 132 | return len(tl.Tabs) | |
| 118 | 133 | } | |
| 119 | 134 | ||
| 120 | 135 | // SetActive sets the active tab and updates the previous active tab. | |
| 121 | 136 | func (tl *TabList) SetActive(id TabID) { | |
| 137 | + | tl.mu.Lock() | |
| 138 | + | defer tl.mu.Unlock() | |
| 139 | + | ||
| 122 | 140 | if id != tl.ActiveTabID && tl.ActiveTabID != "" { | |
| 123 | 141 | tl.PreviousActiveTabID = tl.ActiveTabID | |
| 124 | 142 | } | |
| @@ -127,6 +145,9 @@ func (tl *TabList) SetActive(id TabID) { | |||
| 127 | 145 | ||
| 128 | 146 | // TabAt returns the tab at the given 0-based index. | |
| 129 | 147 | func (tl *TabList) TabAt(index int) *Tab { | |
| 148 | + | tl.mu.RLock() | |
| 149 | + | defer tl.mu.RUnlock() | |
| 150 | + | ||
| 130 | 151 | if index < 0 || index >= len(tl.Tabs) { | |
| 131 | 152 | return nil | |
| 132 | 153 | } | |
| @@ -135,6 +156,9 @@ func (tl *TabList) TabAt(index int) *Tab { | |||
| 135 | 156 | ||
| 136 | 157 | // Move moves a tab to a new position. | |
| 137 | 158 | func (tl *TabList) Move(id TabID, newPos int) bool { | |
| 159 | + | tl.mu.Lock() | |
| 160 | + | defer tl.mu.Unlock() | |
| 161 | + | ||
| 138 | 162 | if newPos < 0 || newPos >= len(tl.Tabs) { | |
| 139 | 163 | return false | |
| 140 | 164 | } | |
| @@ -169,6 +193,9 @@ func (tl *TabList) Snapshot() *TabList { | |||
| 169 | 193 | if tl == nil { | |
| 170 | 194 | return nil | |
| 171 | 195 | } | |
| 196 | + | tl.mu.RLock() | |
| 197 | + | defer tl.mu.RUnlock() | |
| 198 | + | ||
| 172 | 199 | tabs := make([]*Tab, len(tl.Tabs)) | |
| 173 | 200 | copy(tabs, tl.Tabs) | |
| 174 | 201 | return &TabList{ | |
| @@ -181,13 +208,18 @@ func (tl *TabList) Snapshot() *TabList { | |||
| 181 | 208 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | |
| 182 | 209 | // This modifies in-place so existing references to this TabList remain valid. | |
| 183 | 210 | func (tl *TabList) ReplaceFrom(other *TabList) { | |
| 184 | - | if other == nil { | |
| 211 | + | snapshot := other.Snapshot() | |
| 212 | + | ||
| 213 | + | tl.mu.Lock() | |
| 214 | + | defer tl.mu.Unlock() | |
| 215 | + | ||
| 216 | + | if snapshot == nil { | |
| 185 | 217 | tl.Tabs = make([]*Tab, 0) | |
| 186 | 218 | tl.ActiveTabID = "" | |
| 187 | 219 | tl.PreviousActiveTabID = "" | |
| 188 | 220 | return | |
| 189 | 221 | } | |
| 190 | - | tl.Tabs = other.Tabs | |
| 191 | - | tl.ActiveTabID = other.ActiveTabID | |
| 192 | - | tl.PreviousActiveTabID = other.PreviousActiveTabID | |
| 222 | + | tl.Tabs = snapshot.Tabs | |
| 223 | + | tl.ActiveTabID = snapshot.ActiveTabID | |
| 224 | + | tl.PreviousActiveTabID = snapshot.PreviousActiveTabID | |
| 193 | 225 | } | |
| @@ -2,6 +2,7 @@ package entity | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "sync" | |||
| 5 | "time" | 6 | "time" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| @@ -51,6 +52,8 @@ type TabList struct { | |||||
| 51 | Tabs []*Tab | 52 | Tabs []*Tab | ||
| 52 | ActiveTabID TabID | 53 | ActiveTabID TabID | ||
| 53 | PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching | 54 | PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching | ||
| 55 | + | ||||
| 56 | + | mu sync.RWMutex | |||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | // NewTabList creates an empty tab list. | 59 | // NewTabList creates an empty tab list. | ||
| @@ -62,6 +65,9 @@ func NewTabList() *TabList { | |||||
| 62 | 65 | ||||
| 63 | // Add appends a tab to the list. | 66 | // Add appends a tab to the list. | ||
| 64 | func (tl *TabList) Add(tab *Tab) { | 67 | func (tl *TabList) Add(tab *Tab) { | ||
| 68 | + | tl.mu.Lock() | |||
| 69 | + | defer tl.mu.Unlock() | |||
| 70 | + | ||||
| 65 | tab.Position = len(tl.Tabs) | 71 | tab.Position = len(tl.Tabs) | ||
| 66 | tl.Tabs = append(tl.Tabs, tab) | 72 | tl.Tabs = append(tl.Tabs, tab) | ||
| 67 | if tl.ActiveTabID == "" { | 73 | if tl.ActiveTabID == "" { | ||
| @@ -71,6 +77,9 @@ func (tl *TabList) Add(tab *Tab) { | |||||
| 71 | 77 | ||||
| 72 | // Remove removes a tab by ID and reindexes positions. | 78 | // Remove removes a tab by ID and reindexes positions. | ||
| 73 | func (tl *TabList) Remove(id TabID) bool { | 79 | func (tl *TabList) Remove(id TabID) bool { | ||
| 80 | + | tl.mu.Lock() | |||
| 81 | + | defer tl.mu.Unlock() | |||
| 82 | + | ||||
| 74 | for i, tab := range tl.Tabs { | 83 | for i, tab := range tl.Tabs { | ||
| 75 | if tab.ID == id { | 84 | if tab.ID == id { | ||
| 76 | tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...) | 85 | tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...) | ||
| @@ -99,6 +108,9 @@ func (tl *TabList) Remove(id TabID) bool { | |||||
| 99 | 108 | ||||
| 100 | // Find returns a tab by ID. | 109 | // Find returns a tab by ID. | ||
| 101 | func (tl *TabList) Find(id TabID) *Tab { | 110 | func (tl *TabList) Find(id TabID) *Tab { | ||
| 111 | + | tl.mu.RLock() | |||
| 112 | + | defer tl.mu.RUnlock() | |||
| 113 | + | ||||
| 102 | for _, tab := range tl.Tabs { | 114 | for _, tab := range tl.Tabs { | ||
| 103 | if tab.ID == id { | 115 | if tab.ID == id { | ||
| 104 | return tab | 116 | return tab | ||
| @@ -114,11 +126,17 @@ func (tl *TabList) ActiveTab() *Tab { | |||||
| 114 | 126 | ||||
| 115 | // Count returns the number of tabs. | 127 | // Count returns the number of tabs. | ||
| 116 | func (tl *TabList) Count() int { | 128 | func (tl *TabList) Count() int { | ||
| 129 | + | tl.mu.RLock() | |||
| 130 | + | defer tl.mu.RUnlock() | |||
| 131 | + | ||||
| 117 | return len(tl.Tabs) | 132 | return len(tl.Tabs) | ||
| 118 | } | 133 | } | ||
| 119 | 134 | ||||
| 120 | // SetActive sets the active tab and updates the previous active tab. | 135 | // SetActive sets the active tab and updates the previous active tab. | ||
| 121 | func (tl *TabList) SetActive(id TabID) { | 136 | func (tl *TabList) SetActive(id TabID) { | ||
| 137 | + | tl.mu.Lock() | |||
| 138 | + | defer tl.mu.Unlock() | |||
| 139 | + | ||||
| 122 | if id != tl.ActiveTabID && tl.ActiveTabID != "" { | 140 | if id != tl.ActiveTabID && tl.ActiveTabID != "" { | ||
| 123 | tl.PreviousActiveTabID = tl.ActiveTabID | 141 | tl.PreviousActiveTabID = tl.ActiveTabID | ||
| 124 | } | 142 | } | ||
| @@ -127,6 +145,9 @@ func (tl *TabList) SetActive(id TabID) { | |||||
| 127 | 145 | ||||
| 128 | // TabAt returns the tab at the given 0-based index. | 146 | // TabAt returns the tab at the given 0-based index. | ||
| 129 | func (tl *TabList) TabAt(index int) *Tab { | 147 | func (tl *TabList) TabAt(index int) *Tab { | ||
| 148 | + | tl.mu.RLock() | |||
| 149 | + | defer tl.mu.RUnlock() | |||
| 150 | + | ||||
| 130 | if index < 0 || index >= len(tl.Tabs) { | 151 | if index < 0 || index >= len(tl.Tabs) { | ||
| 131 | return nil | 152 | return nil | ||
| 132 | } | 153 | } | ||
| @@ -135,6 +156,9 @@ func (tl *TabList) TabAt(index int) *Tab { | |||||
| 135 | 156 | ||||
| 136 | // Move moves a tab to a new position. | 157 | // Move moves a tab to a new position. | ||
| 137 | func (tl *TabList) Move(id TabID, newPos int) bool { | 158 | func (tl *TabList) Move(id TabID, newPos int) bool { | ||
| 159 | + | tl.mu.Lock() | |||
| 160 | + | defer tl.mu.Unlock() | |||
| 161 | + | ||||
| 138 | if newPos < 0 || newPos >= len(tl.Tabs) { | 162 | if newPos < 0 || newPos >= len(tl.Tabs) { | ||
| 139 | return false | 163 | return false | ||
| 140 | } | 164 | } | ||
| @@ -169,6 +193,9 @@ func (tl *TabList) Snapshot() *TabList { | |||||
| 169 | if tl == nil { | 193 | if tl == nil { | ||
| 170 | return nil | 194 | return nil | ||
| 171 | } | 195 | } | ||
| 196 | + | tl.mu.RLock() | |||
| 197 | + | defer tl.mu.RUnlock() | |||
| 198 | + | ||||
| 172 | tabs := make([]*Tab, len(tl.Tabs)) | 199 | tabs := make([]*Tab, len(tl.Tabs)) | ||
| 173 | copy(tabs, tl.Tabs) | 200 | copy(tabs, tl.Tabs) | ||
| 174 | return &TabList{ | 201 | return &TabList{ | ||
| @@ -181,13 +208,18 @@ func (tl *TabList) Snapshot() *TabList { | |||||
| 181 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | 208 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | ||
| 182 | // This modifies in-place so existing references to this TabList remain valid. | 209 | // This modifies in-place so existing references to this TabList remain valid. | ||
| 183 | func (tl *TabList) ReplaceFrom(other *TabList) { | 210 | func (tl *TabList) ReplaceFrom(other *TabList) { | ||
| 184 | - | if other == nil { | 211 | + | snapshot := other.Snapshot() |
| 212 | + | ||||
| 213 | + | tl.mu.Lock() | |||
| 214 | + | defer tl.mu.Unlock() | |||
| 215 | + | ||||
| 216 | + | if snapshot == nil { | |||
| 185 | tl.Tabs = make([]*Tab, 0) | 217 | tl.Tabs = make([]*Tab, 0) | ||
| 186 | tl.ActiveTabID = "" | 218 | tl.ActiveTabID = "" | ||
| 187 | tl.PreviousActiveTabID = "" | 219 | tl.PreviousActiveTabID = "" | ||
| 188 | return | 220 | return | ||
| 189 | } | 221 | } | ||
| 190 | - | tl.Tabs = other.Tabs | 222 | + | tl.Tabs = snapshot.Tabs |
| 191 | - | tl.ActiveTabID = other.ActiveTabID | 223 | + | tl.ActiveTabID = snapshot.ActiveTabID |
| 192 | - | tl.PreviousActiveTabID = other.PreviousActiveTabID | 224 | + | tl.PreviousActiveTabID = snapshot.PreviousActiveTabID |
| 193 | } | 225 | } | ||
M internal/ui/app_browser_launch_test.go +48 -6
| @@ -1297,6 +1297,34 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAl | |||
| 1297 | 1297 | assertWindowTabBarAutoHidden(t, mainWindow, true) | |
| 1298 | 1298 | } | |
| 1299 | 1299 | ||
| 1300 | + | func TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) { | |
| 1301 | + | gtkApp := requireGTKDisplayApp(t) | |
| 1302 | + | defer gtkApp.Unref() | |
| 1303 | + | ||
| 1304 | + | tabBar := component.NewTabBar() | |
| 1305 | + | if tabBar == nil { | |
| 1306 | + | t.Fatal("tab bar creation failed") | |
| 1307 | + | } | |
| 1308 | + | ||
| 1309 | + | tabBar.SetAutoHidden(true) | |
| 1310 | + | tabBar.SetVisible(false) | |
| 1311 | + | tabBar.SetVisible(true) | |
| 1312 | + | ||
| 1313 | + | box := tabBar.Box() | |
| 1314 | + | if !box.GetVisible() { | |
| 1315 | + | t.Fatal("tab bar should stay mounted when explicitly visible") | |
| 1316 | + | } | |
| 1317 | + | if got := box.GetOpacity(); got != 0.0 { | |
| 1318 | + | t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got) | |
| 1319 | + | } | |
| 1320 | + | if got := box.GetCanTarget(); got { | |
| 1321 | + | t.Fatalf("tab bar can target = %v, want false while auto-hidden", got) | |
| 1322 | + | } | |
| 1323 | + | if got := box.GetFocusable(); got { | |
| 1324 | + | t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got) | |
| 1325 | + | } | |
| 1326 | + | } | |
| 1327 | + | ||
| 1300 | 1328 | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | |
| 1301 | 1329 | gtkApp := requireGTKDisplayApp(t) | |
| 1302 | 1330 | defer gtkApp.Unref() | |
| @@ -2474,7 +2502,7 @@ func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) { | |||
| 2474 | 2502 | } | |
| 2475 | 2503 | } | |
| 2476 | 2504 | ||
| 2477 | - | func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) { | |
| 2505 | + | func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) { | |
| 2478 | 2506 | gtkApp := requireGTKDisplayApp(t) | |
| 2479 | 2507 | defer gtkApp.Unref() | |
| 2480 | 2508 | ||
| @@ -2484,19 +2512,33 @@ func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) { | |||
| 2484 | 2512 | } | |
| 2485 | 2513 | defer mw.Destroy() | |
| 2486 | 2514 | ||
| 2487 | - | // Top should never have the bottom inset class | |
| 2515 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |
| 2516 | + | t.Fatal("top-tab-bar content area: should NOT have inset at window creation") | |
| 2517 | + | } | |
| 2488 | 2518 | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2489 | 2519 | t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class") | |
| 2490 | 2520 | } | |
| 2491 | 2521 | ||
| 2492 | - | // Even after SetTabBarContentInsetVisible(true), top must not get bottom inset | |
| 2493 | 2522 | mw.SetTabBarContentInsetVisible(true) | |
| 2523 | + | if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |
| 2524 | + | t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)") | |
| 2525 | + | } | |
| 2494 | 2526 | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2495 | - | t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(true)") | |
| 2527 | + | t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)") | |
| 2528 | + | } | |
| 2529 | + | if !mw.HasTabBarContentInset() { | |
| 2530 | + | t.Fatal("HasTabBarContentInset should return true for top inset") | |
| 2496 | 2531 | } | |
| 2497 | 2532 | ||
| 2498 | - | // HasTabBarContentInset must return false for top | |
| 2533 | + | mw.SetTabBarContentInsetVisible(false) | |
| 2534 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |
| 2535 | + | t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)") | |
| 2536 | + | } | |
| 2499 | 2537 | if mw.HasTabBarContentInset() { | |
| 2500 | - | t.Fatal("HasTabBarContentInset should return false for top tab bar position") | |
| 2538 | + | t.Fatal("HasTabBarContentInset should return false after removing top inset") | |
| 2539 | + | } | |
| 2540 | + | ||
| 2541 | + | if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) { | |
| 2542 | + | t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay") | |
| 2501 | 2543 | } | |
| 2502 | 2544 | } | |
| @@ -1297,6 +1297,34 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAl | |||||
| 1297 | assertWindowTabBarAutoHidden(t, mainWindow, true) | 1297 | assertWindowTabBarAutoHidden(t, mainWindow, true) | ||
| 1298 | } | 1298 | } | ||
| 1299 | 1299 | ||||
| 1300 | + | func TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) { | |||
| 1301 | + | gtkApp := requireGTKDisplayApp(t) | |||
| 1302 | + | defer gtkApp.Unref() | |||
| 1303 | + | ||||
| 1304 | + | tabBar := component.NewTabBar() | |||
| 1305 | + | if tabBar == nil { | |||
| 1306 | + | t.Fatal("tab bar creation failed") | |||
| 1307 | + | } | |||
| 1308 | + | ||||
| 1309 | + | tabBar.SetAutoHidden(true) | |||
| 1310 | + | tabBar.SetVisible(false) | |||
| 1311 | + | tabBar.SetVisible(true) | |||
| 1312 | + | ||||
| 1313 | + | box := tabBar.Box() | |||
| 1314 | + | if !box.GetVisible() { | |||
| 1315 | + | t.Fatal("tab bar should stay mounted when explicitly visible") | |||
| 1316 | + | } | |||
| 1317 | + | if got := box.GetOpacity(); got != 0.0 { | |||
| 1318 | + | t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got) | |||
| 1319 | + | } | |||
| 1320 | + | if got := box.GetCanTarget(); got { | |||
| 1321 | + | t.Fatalf("tab bar can target = %v, want false while auto-hidden", got) | |||
| 1322 | + | } | |||
| 1323 | + | if got := box.GetFocusable(); got { | |||
| 1324 | + | t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got) | |||
| 1325 | + | } | |||
| 1326 | + | } | |||
| 1327 | + | ||||
| 1300 | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | 1328 | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | ||
| 1301 | gtkApp := requireGTKDisplayApp(t) | 1329 | gtkApp := requireGTKDisplayApp(t) | ||
| 1302 | defer gtkApp.Unref() | 1330 | defer gtkApp.Unref() | ||
| @@ -2474,7 +2502,7 @@ func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) { | |||||
| 2474 | } | 2502 | } | ||
| 2475 | } | 2503 | } | ||
| 2476 | 2504 | ||||
| 2477 | - | func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) { | 2505 | + | func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) { |
| 2478 | gtkApp := requireGTKDisplayApp(t) | 2506 | gtkApp := requireGTKDisplayApp(t) | ||
| 2479 | defer gtkApp.Unref() | 2507 | defer gtkApp.Unref() | ||
| 2480 | 2508 | ||||
| @@ -2484,19 +2512,33 @@ func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) { | |||||
| 2484 | } | 2512 | } | ||
| 2485 | defer mw.Destroy() | 2513 | defer mw.Destroy() | ||
| 2486 | 2514 | ||||
| 2487 | - | // Top should never have the bottom inset class | 2515 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { |
| 2516 | + | t.Fatal("top-tab-bar content area: should NOT have inset at window creation") | |||
| 2517 | + | } | |||
| 2488 | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | 2518 | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | ||
| 2489 | t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class") | 2519 | t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class") | ||
| 2490 | } | 2520 | } | ||
| 2491 | 2521 | ||||
| 2492 | - | // Even after SetTabBarContentInsetVisible(true), top must not get bottom inset | |||
| 2493 | mw.SetTabBarContentInsetVisible(true) | 2522 | mw.SetTabBarContentInsetVisible(true) | ||
| 2523 | + | if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |||
| 2524 | + | t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)") | |||
| 2525 | + | } | |||
| 2494 | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | 2526 | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | ||
| 2495 | - | t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(true)") | 2527 | + | t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)") |
| 2528 | + | } | |||
| 2529 | + | if !mw.HasTabBarContentInset() { | |||
| 2530 | + | t.Fatal("HasTabBarContentInset should return true for top inset") | |||
| 2496 | } | 2531 | } | ||
| 2497 | 2532 | ||||
| 2498 | - | // HasTabBarContentInset must return false for top | 2533 | + | mw.SetTabBarContentInsetVisible(false) |
| 2534 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |||
| 2535 | + | t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)") | |||
| 2536 | + | } | |||
| 2499 | if mw.HasTabBarContentInset() { | 2537 | if mw.HasTabBarContentInset() { | ||
| 2500 | - | t.Fatal("HasTabBarContentInset should return false for top tab bar position") | 2538 | + | t.Fatal("HasTabBarContentInset should return false after removing top inset") |
| 2539 | + | } | |||
| 2540 | + | ||||
| 2541 | + | if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) { | |||
| 2542 | + | t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay") | |||
| 2501 | } | 2543 | } | ||
| 2502 | } | 2544 | } | ||
M internal/ui/component/tab_bar.go +16 -17
| @@ -13,6 +13,7 @@ type TabBar struct { | |||
| 13 | 13 | buttons map[entity.TabID]*TabButton | |
| 14 | 14 | ||
| 15 | 15 | explicitlyVisible bool | |
| 16 | + | autoHidden bool | |
| 16 | 17 | ||
| 17 | 18 | // Active tab tracking | |
| 18 | 19 | activeTabID entity.TabID | |
| @@ -166,14 +167,7 @@ func (tb *TabBar) SetVisible(visible bool) { | |||
| 166 | 167 | return | |
| 167 | 168 | } | |
| 168 | 169 | tb.explicitlyVisible = visible | |
| 169 | - | tb.box.SetVisible(visible) | |
| 170 | - | if !visible { | |
| 171 | - | tb.box.SetFocusable(false) | |
| 172 | - | tb.box.SetCanTarget(false) | |
| 173 | - | } else { | |
| 174 | - | tb.box.SetFocusable(true) | |
| 175 | - | tb.box.SetCanTarget(true) | |
| 176 | - | } | |
| 170 | + | tb.applyVisibilityState() | |
| 177 | 171 | } | |
| 178 | 172 | ||
| 179 | 173 | // SetAutoHidden hides the tab bar visually while keeping the overlay widget | |
| @@ -185,19 +179,24 @@ func (tb *TabBar) SetAutoHidden(hidden bool) { | |||
| 185 | 179 | return | |
| 186 | 180 | } | |
| 187 | 181 | ||
| 188 | - | // Fullscreen and other explicit hide states must win over auto-hide updates. | |
| 189 | - | tb.box.SetVisible(tb.explicitlyVisible) | |
| 182 | + | tb.autoHidden = hidden | |
| 183 | + | tb.applyVisibilityState() | |
| 184 | + | } | |
| 190 | 185 | ||
| 191 | - | if hidden { | |
| 192 | - | tb.box.SetOpacity(0.0) | |
| 193 | - | tb.box.SetCanTarget(false) | |
| 194 | - | tb.box.SetFocusable(false) | |
| 186 | + | func (tb *TabBar) applyVisibilityState() { | |
| 187 | + | if tb == nil || tb.box == nil { | |
| 195 | 188 | return | |
| 196 | 189 | } | |
| 197 | 190 | ||
| 198 | - | tb.box.SetOpacity(1.0) | |
| 199 | - | tb.box.SetCanTarget(tb.explicitlyVisible) | |
| 200 | - | tb.box.SetFocusable(tb.explicitlyVisible) | |
| 191 | + | interactive := tb.explicitlyVisible && !tb.autoHidden | |
| 192 | + | tb.box.SetVisible(tb.explicitlyVisible) | |
| 193 | + | if interactive { | |
| 194 | + | tb.box.SetOpacity(1.0) | |
| 195 | + | } else { | |
| 196 | + | tb.box.SetOpacity(0.0) | |
| 197 | + | } | |
| 198 | + | tb.box.SetCanTarget(interactive) | |
| 199 | + | tb.box.SetFocusable(interactive) | |
| 201 | 200 | } | |
| 202 | 201 | ||
| 203 | 202 | // Destroy cleans up all tab bar resources. | |
| @@ -13,6 +13,7 @@ type TabBar struct { | |||||
| 13 | buttons map[entity.TabID]*TabButton | 13 | buttons map[entity.TabID]*TabButton | ||
| 14 | 14 | ||||
| 15 | explicitlyVisible bool | 15 | explicitlyVisible bool | ||
| 16 | + | autoHidden bool | |||
| 16 | 17 | ||||
| 17 | // Active tab tracking | 18 | // Active tab tracking | ||
| 18 | activeTabID entity.TabID | 19 | activeTabID entity.TabID | ||
| @@ -166,14 +167,7 @@ func (tb *TabBar) SetVisible(visible bool) { | |||||
| 166 | return | 167 | return | ||
| 167 | } | 168 | } | ||
| 168 | tb.explicitlyVisible = visible | 169 | tb.explicitlyVisible = visible | ||
| 169 | - | tb.box.SetVisible(visible) | 170 | + | tb.applyVisibilityState() |
| 170 | - | if !visible { | |||
| 171 | - | tb.box.SetFocusable(false) | |||
| 172 | - | tb.box.SetCanTarget(false) | |||
| 173 | - | } else { | |||
| 174 | - | tb.box.SetFocusable(true) | |||
| 175 | - | tb.box.SetCanTarget(true) | |||
| 176 | - | } | |||
| 177 | } | 171 | } | ||
| 178 | 172 | ||||
| 179 | // SetAutoHidden hides the tab bar visually while keeping the overlay widget | 173 | // SetAutoHidden hides the tab bar visually while keeping the overlay widget | ||
| @@ -185,19 +179,24 @@ func (tb *TabBar) SetAutoHidden(hidden bool) { | |||||
| 185 | return | 179 | return | ||
| 186 | } | 180 | } | ||
| 187 | 181 | ||||
| 188 | - | // Fullscreen and other explicit hide states must win over auto-hide updates. | 182 | + | tb.autoHidden = hidden |
| 189 | - | tb.box.SetVisible(tb.explicitlyVisible) | 183 | + | tb.applyVisibilityState() |
| 184 | + | } | |||
| 190 | 185 | ||||
| 191 | - | if hidden { | 186 | + | func (tb *TabBar) applyVisibilityState() { |
| 192 | - | tb.box.SetOpacity(0.0) | 187 | + | if tb == nil || tb.box == nil { |
| 193 | - | tb.box.SetCanTarget(false) | |||
| 194 | - | tb.box.SetFocusable(false) | |||
| 195 | return | 188 | return | ||
| 196 | } | 189 | } | ||
| 197 | 190 | ||||
| 198 | - | tb.box.SetOpacity(1.0) | 191 | + | interactive := tb.explicitlyVisible && !tb.autoHidden |
| 199 | - | tb.box.SetCanTarget(tb.explicitlyVisible) | 192 | + | tb.box.SetVisible(tb.explicitlyVisible) |
| 200 | - | tb.box.SetFocusable(tb.explicitlyVisible) | 193 | + | if interactive { |
| 194 | + | tb.box.SetOpacity(1.0) | |||
| 195 | + | } else { | |||
| 196 | + | tb.box.SetOpacity(0.0) | |||
| 197 | + | } | |||
| 198 | + | tb.box.SetCanTarget(interactive) | |||
| 199 | + | tb.box.SetFocusable(interactive) | |||
| 201 | } | 200 | } | ||
| 202 | 201 | ||||
| 203 | // Destroy cleans up all tab bar resources. | 202 | // Destroy cleans up all tab bar resources. | ||
M internal/ui/coordinator/navigation.go +2 -2
| @@ -84,8 +84,6 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, | |||
| 84 | 84 | return err | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | c.trackNavigationOrigin(ctx, paneID, url) | |
| 88 | - | ||
| 89 | 87 | paneIDStr := string(paneID) | |
| 90 | 88 | ||
| 91 | 89 | if c.navigateUC != nil { | |
| @@ -99,6 +97,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, | |||
| 99 | 97 | log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed") | |
| 100 | 98 | return err | |
| 101 | 99 | } | |
| 100 | + | c.trackNavigationOrigin(ctx, paneID, url) | |
| 102 | 101 | log.Debug(). | |
| 103 | 102 | Str("url", url). | |
| 104 | 103 | Str("pane_id", paneIDStr). | |
| @@ -114,6 +113,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, | |||
| 114 | 113 | return err | |
| 115 | 114 | } | |
| 116 | 115 | ||
| 116 | + | c.trackNavigationOrigin(ctx, paneID, url) | |
| 117 | 117 | log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)") | |
| 118 | 118 | return nil | |
| 119 | 119 | } | |
| @@ -84,8 +84,6 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, | |||||
| 84 | return err | 84 | return err | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | - | c.trackNavigationOrigin(ctx, paneID, url) | |||
| 88 | - | ||||
| 89 | paneIDStr := string(paneID) | 87 | paneIDStr := string(paneID) | ||
| 90 | 88 | ||||
| 91 | if c.navigateUC != nil { | 89 | if c.navigateUC != nil { | ||
| @@ -99,6 +97,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, | |||||
| 99 | log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed") | 97 | log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed") | ||
| 100 | return err | 98 | return err | ||
| 101 | } | 99 | } | ||
| 100 | + | c.trackNavigationOrigin(ctx, paneID, url) | |||
| 102 | log.Debug(). | 101 | log.Debug(). | ||
| 103 | Str("url", url). | 102 | Str("url", url). | ||
| 104 | Str("pane_id", paneIDStr). | 103 | Str("pane_id", paneIDStr). | ||
| @@ -114,6 +113,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, | |||||
| 114 | return err | 113 | return err | ||
| 115 | } | 114 | } | ||
| 116 | 115 | ||||
| 116 | + | c.trackNavigationOrigin(ctx, paneID, url) | |||
| 117 | log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)") | 117 | log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)") | ||
| 118 | return nil | 118 | return nil | ||
| 119 | } | 119 | } | ||
M internal/ui/coordinator/navigation_webview_targeted_test.go +68 -0
| @@ -9,6 +9,16 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | type mockDevToolsWebView struct { | |
| 13 | + | *mocks.MockWebView | |
| 14 | + | *mocks.MockDevToolsOpener | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | type mockPrinterWebView struct { | |
| 18 | + | *mocks.MockWebView | |
| 19 | + | *mocks.MockPrinter | |
| 20 | + | } | |
| 21 | + | ||
| 12 | 22 | // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that | |
| 13 | 23 | // the explicit WebView-targeted navigation methods call the correct methods on the | |
| 14 | 24 | // provided WebView rather than resolving through the content coordinator. | |
| @@ -48,6 +58,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi | |||
| 48 | 58 | t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) { | |
| 49 | 59 | testGoForwardWebViewCallsGoForward(t, ctx) | |
| 50 | 60 | }) | |
| 61 | + | t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) { | |
| 62 | + | testOpenDevToolsWebViewNilWebView(t, ctx) | |
| 63 | + | }) | |
| 64 | + | t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) { | |
| 65 | + | testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx) | |
| 66 | + | }) | |
| 67 | + | t.Run("PrintWebView with nil webview returns error", func(t *testing.T) { | |
| 68 | + | testPrintWebViewNilWebView(t, ctx) | |
| 69 | + | }) | |
| 70 | + | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | |
| 71 | + | testPrintWebViewTargetsProvidedWebView(t, ctx) | |
| 72 | + | }) | |
| 51 | 73 | } | |
| 52 | 74 | ||
| 53 | 75 | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| @@ -166,3 +188,49 @@ func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) { | |||
| 166 | 188 | t.Fatalf("unexpected error: %v", err) | |
| 167 | 189 | } | |
| 168 | 190 | } | |
| 191 | + | ||
| 192 | + | func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 193 | + | t.Helper() | |
| 194 | + | c := &NavigationCoordinator{} | |
| 195 | + | err := c.OpenDevToolsWebView(ctx, nil) | |
| 196 | + | if err == nil { | |
| 197 | + | t.Fatal("expected error for nil webview, got nil") | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 202 | + | t.Helper() | |
| 203 | + | base := mocks.NewMockWebView(t) | |
| 204 | + | opener := mocks.NewMockDevToolsOpener(t) | |
| 205 | + | wv := &mockDevToolsWebView{MockWebView: base, MockDevToolsOpener: opener} | |
| 206 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 207 | + | opener.EXPECT().OpenDevTools().Return().Once() | |
| 208 | + | c := &NavigationCoordinator{} | |
| 209 | + | err := c.OpenDevToolsWebView(ctx, wv) | |
| 210 | + | if err != nil { | |
| 211 | + | t.Fatalf("unexpected error: %v", err) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 216 | + | t.Helper() | |
| 217 | + | c := &NavigationCoordinator{} | |
| 218 | + | err := c.PrintWebView(ctx, nil) | |
| 219 | + | if err == nil { | |
| 220 | + | t.Fatal("expected error for nil webview, got nil") | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 225 | + | t.Helper() | |
| 226 | + | base := mocks.NewMockWebView(t) | |
| 227 | + | printer := mocks.NewMockPrinter(t) | |
| 228 | + | wv := &mockPrinterWebView{MockWebView: base, MockPrinter: printer} | |
| 229 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 230 | + | printer.EXPECT().PrintPage().Return().Once() | |
| 231 | + | c := &NavigationCoordinator{} | |
| 232 | + | err := c.PrintWebView(ctx, wv) | |
| 233 | + | if err != nil { | |
| 234 | + | t.Fatalf("unexpected error: %v", err) | |
| 235 | + | } | |
| 236 | + | } | |
| @@ -9,6 +9,16 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | type mockDevToolsWebView struct { | |||
| 13 | + | *mocks.MockWebView | |||
| 14 | + | *mocks.MockDevToolsOpener | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | type mockPrinterWebView struct { | |||
| 18 | + | *mocks.MockWebView | |||
| 19 | + | *mocks.MockPrinter | |||
| 20 | + | } | |||
| 21 | + | ||||
| 12 | // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that | 22 | // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that | ||
| 13 | // the explicit WebView-targeted navigation methods call the correct methods on the | 23 | // the explicit WebView-targeted navigation methods call the correct methods on the | ||
| 14 | // provided WebView rather than resolving through the content coordinator. | 24 | // provided WebView rather than resolving through the content coordinator. | ||
| @@ -48,6 +58,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi | |||||
| 48 | t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) { | 58 | t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) { | ||
| 49 | testGoForwardWebViewCallsGoForward(t, ctx) | 59 | testGoForwardWebViewCallsGoForward(t, ctx) | ||
| 50 | }) | 60 | }) | ||
| 61 | + | t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) { | |||
| 62 | + | testOpenDevToolsWebViewNilWebView(t, ctx) | |||
| 63 | + | }) | |||
| 64 | + | t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) { | |||
| 65 | + | testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx) | |||
| 66 | + | }) | |||
| 67 | + | t.Run("PrintWebView with nil webview returns error", func(t *testing.T) { | |||
| 68 | + | testPrintWebViewNilWebView(t, ctx) | |||
| 69 | + | }) | |||
| 70 | + | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | |||
| 71 | + | testPrintWebViewTargetsProvidedWebView(t, ctx) | |||
| 72 | + | }) | |||
| 51 | } | 73 | } | ||
| 52 | 74 | ||||
| 53 | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | 75 | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | ||
| @@ -166,3 +188,49 @@ func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) { | |||||
| 166 | t.Fatalf("unexpected error: %v", err) | 188 | t.Fatalf("unexpected error: %v", err) | ||
| 167 | } | 189 | } | ||
| 168 | } | 190 | } | ||
| 191 | + | ||||
| 192 | + | func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) { | |||
| 193 | + | t.Helper() | |||
| 194 | + | c := &NavigationCoordinator{} | |||
| 195 | + | err := c.OpenDevToolsWebView(ctx, nil) | |||
| 196 | + | if err == nil { | |||
| 197 | + | t.Fatal("expected error for nil webview, got nil") | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |||
| 202 | + | t.Helper() | |||
| 203 | + | base := mocks.NewMockWebView(t) | |||
| 204 | + | opener := mocks.NewMockDevToolsOpener(t) | |||
| 205 | + | wv := &mockDevToolsWebView{MockWebView: base, MockDevToolsOpener: opener} | |||
| 206 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 207 | + | opener.EXPECT().OpenDevTools().Return().Once() | |||
| 208 | + | c := &NavigationCoordinator{} | |||
| 209 | + | err := c.OpenDevToolsWebView(ctx, wv) | |||
| 210 | + | if err != nil { | |||
| 211 | + | t.Fatalf("unexpected error: %v", err) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) { | |||
| 216 | + | t.Helper() | |||
| 217 | + | c := &NavigationCoordinator{} | |||
| 218 | + | err := c.PrintWebView(ctx, nil) | |||
| 219 | + | if err == nil { | |||
| 220 | + | t.Fatal("expected error for nil webview, got nil") | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |||
| 225 | + | t.Helper() | |||
| 226 | + | base := mocks.NewMockWebView(t) | |||
| 227 | + | printer := mocks.NewMockPrinter(t) | |||
| 228 | + | wv := &mockPrinterWebView{MockWebView: base, MockPrinter: printer} | |||
| 229 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |||
| 230 | + | printer.EXPECT().PrintPage().Return().Once() | |||
| 231 | + | c := &NavigationCoordinator{} | |||
| 232 | + | err := c.PrintWebView(ctx, wv) | |||
| 233 | + | if err != nil { | |||
| 234 | + | t.Fatalf("unexpected error: %v", err) | |||
| 235 | + | } | |||
| 236 | + | } | |||
M internal/ui/theme/css.go +5 -1
| @@ -144,8 +144,12 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo | |||
| 144 | 144 | // Uses em units for scalable UI. | |
| 145 | 145 | func generateTabBarCSS(p Palette) string { | |
| 146 | 146 | return `/* Tab bar styling */ | |
| 147 | - | /* Content area inset for bottom-positioned tab bar — uses the same 2em token as | |
| 147 | + | /* Content area inset for overlaid tab bars — uses the same 2em token as | |
| 148 | 148 | .tab-bar min-height and remains static to avoid OSR vertical reallocations. */ | |
| 149 | + | .content-area-tabbar-inset-top { | |
| 150 | + | padding-top: 2em; | |
| 151 | + | } | |
| 152 | + | ||
| 149 | 153 | .content-area-tabbar-inset-bottom { | |
| 150 | 154 | padding-bottom: 2em; | |
| 151 | 155 | } | |
| @@ -144,8 +144,12 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo | |||||
| 144 | // Uses em units for scalable UI. | 144 | // Uses em units for scalable UI. | ||
| 145 | func generateTabBarCSS(p Palette) string { | 145 | func generateTabBarCSS(p Palette) string { | ||
| 146 | return `/* Tab bar styling */ | 146 | return `/* Tab bar styling */ | ||
| 147 | - | /* Content area inset for bottom-positioned tab bar — uses the same 2em token as | 147 | + | /* Content area inset for overlaid tab bars — uses the same 2em token as |
| 148 | .tab-bar min-height and remains static to avoid OSR vertical reallocations. */ | 148 | .tab-bar min-height and remains static to avoid OSR vertical reallocations. */ | ||
| 149 | + | .content-area-tabbar-inset-top { | |||
| 150 | + | padding-top: 2em; | |||
| 151 | + | } | |||
| 152 | + | ||||
| 149 | .content-area-tabbar-inset-bottom { | 153 | .content-area-tabbar-inset-bottom { | ||
| 150 | padding-bottom: 2em; | 154 | padding-bottom: 2em; | ||
| 151 | } | 155 | } | ||
M internal/ui/theme/css_test.go +7 -1
| @@ -185,9 +185,15 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi | |||
| 185 | 185 | assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;") | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | - | func TestGenerateCSS_ContentAreaTabBarInsetBottom(t *testing.T) { | |
| 188 | + | func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { | |
| 189 | 189 | css := GenerateCSS(DefaultDarkPalette()) | |
| 190 | 190 | ||
| 191 | + | if !strings.Contains(css, ".content-area-tabbar-inset-top") { | |
| 192 | + | t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS") | |
| 193 | + | } | |
| 194 | + | if !strings.Contains(css, "padding-top: 2em;") { | |
| 195 | + | t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block") | |
| 196 | + | } | |
| 191 | 197 | if !strings.Contains(css, ".content-area-tabbar-inset-bottom") { | |
| 192 | 198 | t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS") | |
| 193 | 199 | } | |
| @@ -185,9 +185,15 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi | |||||
| 185 | assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;") | 185 | assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;") | ||
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | - | func TestGenerateCSS_ContentAreaTabBarInsetBottom(t *testing.T) { | 188 | + | func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) { |
| 189 | css := GenerateCSS(DefaultDarkPalette()) | 189 | css := GenerateCSS(DefaultDarkPalette()) | ||
| 190 | 190 | ||||
| 191 | + | if !strings.Contains(css, ".content-area-tabbar-inset-top") { | |||
| 192 | + | t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS") | |||
| 193 | + | } | |||
| 194 | + | if !strings.Contains(css, "padding-top: 2em;") { | |||
| 195 | + | t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block") | |||
| 196 | + | } | |||
| 191 | if !strings.Contains(css, ".content-area-tabbar-inset-bottom") { | 197 | if !strings.Contains(css, ".content-area-tabbar-inset-bottom") { | ||
| 192 | t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS") | 198 | t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS") | ||
| 193 | } | 199 | } | ||
M internal/ui/window/main_window.go +21 -15
| @@ -117,9 +117,8 @@ func (mw *MainWindow) assembleLayout() { | |||
| 117 | 117 | mw.contentOverlay.AddOverlay(tabBarWidget) | |
| 118 | 118 | mw.contentOverlay.SetClipOverlay(tabBarWidget, false) | |
| 119 | 119 | // Keep the tab bar out of measurement so showing/hiding it never changes the | |
| 120 | - | // WebView content allocation; the content area reserves a stable inset for | |
| 121 | - | // bottom-position bars while the overlay remains non-measured to avoid | |
| 122 | - | // WebView allocation changes. | |
| 120 | + | // WebView content allocation; the content area reserves a stable inset while | |
| 121 | + | // the overlay remains non-measured to avoid WebView allocation changes. | |
| 123 | 122 | mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false) | |
| 124 | 123 | ||
| 125 | 124 | mw.logger.Debug(). | |
| @@ -201,31 +200,38 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |||
| 201 | 200 | return mw.contentOverlay | |
| 202 | 201 | } | |
| 203 | 202 | ||
| 204 | - | // SetTabBarContentInsetVisible adds or removes the bottom tab bar inset CSS class | |
| 205 | - | // on the content area. Only effective when tabBarPosition is "bottom". | |
| 206 | - | // Avoids duplicate add/remove if already in the desired state. | |
| 203 | + | // SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class | |
| 204 | + | // on the content area. Avoids duplicate add/remove if already in the desired state. | |
| 207 | 205 | func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) { | |
| 208 | - | if mw.tabBarPosition != "bottom" || mw.contentArea == nil { | |
| 206 | + | if mw.contentArea == nil { | |
| 209 | 207 | return | |
| 210 | 208 | } | |
| 209 | + | class := mw.tabBarContentInsetClass() | |
| 211 | 210 | if visible { | |
| 212 | - | if !mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") { | |
| 213 | - | mw.contentArea.AddCssClass("content-area-tabbar-inset-bottom") | |
| 211 | + | if !mw.contentArea.HasCssClass(class) { | |
| 212 | + | mw.contentArea.AddCssClass(class) | |
| 214 | 213 | } | |
| 215 | 214 | } else { | |
| 216 | - | if mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") { | |
| 217 | - | mw.contentArea.RemoveCssClass("content-area-tabbar-inset-bottom") | |
| 215 | + | if mw.contentArea.HasCssClass(class) { | |
| 216 | + | mw.contentArea.RemoveCssClass(class) | |
| 218 | 217 | } | |
| 219 | 218 | } | |
| 220 | 219 | } | |
| 221 | 220 | ||
| 222 | - | // HasTabBarContentInset returns whether the bottom tab bar inset CSS class | |
| 223 | - | // is currently applied to the content area. | |
| 221 | + | // HasTabBarContentInset returns whether the tab bar inset CSS class is currently | |
| 222 | + | // applied to the content area. | |
| 224 | 223 | func (mw *MainWindow) HasTabBarContentInset() bool { | |
| 225 | - | if mw.tabBarPosition != "bottom" || mw.contentArea == nil { | |
| 224 | + | if mw.contentArea == nil { | |
| 226 | 225 | return false | |
| 227 | 226 | } | |
| 228 | - | return mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") | |
| 227 | + | return mw.contentArea.HasCssClass(mw.tabBarContentInsetClass()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (mw *MainWindow) tabBarContentInsetClass() string { | |
| 231 | + | if mw.tabBarPosition == "bottom" { | |
| 232 | + | return "content-area-tabbar-inset-bottom" | |
| 233 | + | } | |
| 234 | + | return "content-area-tabbar-inset-top" | |
| 229 | 235 | } | |
| 230 | 236 | ||
| 231 | 237 | // AddOverlay adds a widget as an overlay above the content area. | |
| @@ -117,9 +117,8 @@ func (mw *MainWindow) assembleLayout() { | |||||
| 117 | mw.contentOverlay.AddOverlay(tabBarWidget) | 117 | mw.contentOverlay.AddOverlay(tabBarWidget) | ||
| 118 | mw.contentOverlay.SetClipOverlay(tabBarWidget, false) | 118 | mw.contentOverlay.SetClipOverlay(tabBarWidget, false) | ||
| 119 | // Keep the tab bar out of measurement so showing/hiding it never changes the | 119 | // Keep the tab bar out of measurement so showing/hiding it never changes the | ||
| 120 | - | // WebView content allocation; the content area reserves a stable inset for | 120 | + | // WebView content allocation; the content area reserves a stable inset while |
| 121 | - | // bottom-position bars while the overlay remains non-measured to avoid | 121 | + | // the overlay remains non-measured to avoid WebView allocation changes. |
| 122 | - | // WebView allocation changes. | |||
| 123 | mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false) | 122 | mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false) | ||
| 124 | 123 | ||||
| 125 | mw.logger.Debug(). | 124 | mw.logger.Debug(). | ||
| @@ -201,31 +200,38 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |||||
| 201 | return mw.contentOverlay | 200 | return mw.contentOverlay | ||
| 202 | } | 201 | } | ||
| 203 | 202 | ||||
| 204 | - | // SetTabBarContentInsetVisible adds or removes the bottom tab bar inset CSS class | 203 | + | // SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class |
| 205 | - | // on the content area. Only effective when tabBarPosition is "bottom". | 204 | + | // on the content area. Avoids duplicate add/remove if already in the desired state. |
| 206 | - | // Avoids duplicate add/remove if already in the desired state. | |||
| 207 | func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) { | 205 | func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) { | ||
| 208 | - | if mw.tabBarPosition != "bottom" || mw.contentArea == nil { | 206 | + | if mw.contentArea == nil { |
| 209 | return | 207 | return | ||
| 210 | } | 208 | } | ||
| 209 | + | class := mw.tabBarContentInsetClass() | |||
| 211 | if visible { | 210 | if visible { | ||
| 212 | - | if !mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") { | 211 | + | if !mw.contentArea.HasCssClass(class) { |
| 213 | - | mw.contentArea.AddCssClass("content-area-tabbar-inset-bottom") | 212 | + | mw.contentArea.AddCssClass(class) |
| 214 | } | 213 | } | ||
| 215 | } else { | 214 | } else { | ||
| 216 | - | if mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") { | 215 | + | if mw.contentArea.HasCssClass(class) { |
| 217 | - | mw.contentArea.RemoveCssClass("content-area-tabbar-inset-bottom") | 216 | + | mw.contentArea.RemoveCssClass(class) |
| 218 | } | 217 | } | ||
| 219 | } | 218 | } | ||
| 220 | } | 219 | } | ||
| 221 | 220 | ||||
| 222 | - | // HasTabBarContentInset returns whether the bottom tab bar inset CSS class | 221 | + | // HasTabBarContentInset returns whether the tab bar inset CSS class is currently |
| 223 | - | // is currently applied to the content area. | 222 | + | // applied to the content area. |
| 224 | func (mw *MainWindow) HasTabBarContentInset() bool { | 223 | func (mw *MainWindow) HasTabBarContentInset() bool { | ||
| 225 | - | if mw.tabBarPosition != "bottom" || mw.contentArea == nil { | 224 | + | if mw.contentArea == nil { |
| 226 | return false | 225 | return false | ||
| 227 | } | 226 | } | ||
| 228 | - | return mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") | 227 | + | return mw.contentArea.HasCssClass(mw.tabBarContentInsetClass()) |
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (mw *MainWindow) tabBarContentInsetClass() string { | |||
| 231 | + | if mw.tabBarPosition == "bottom" { | |||
| 232 | + | return "content-area-tabbar-inset-bottom" | |||
| 233 | + | } | |||
| 234 | + | return "content-area-tabbar-inset-top" | |||
| 229 | } | 235 | } | ||
| 230 | 236 | ||||
| 231 | // AddOverlay adds a widget as an overlay above the content area. | 237 | // AddOverlay adds a widget as an overlay above the content area. | ||