├── .builds └── linux.yml ├── LICENSE ├── README.md ├── async └── loader.go ├── chat.go ├── debug └── debug.go ├── example ├── async │ └── main.go ├── carousel │ └── main.go ├── kitchen │ ├── appwidget │ │ ├── apptheme │ │ │ ├── doc.go │ │ │ ├── room.go │ │ │ └── theme.go │ │ ├── doc.go │ │ └── room.go │ ├── gen │ │ └── message.go │ ├── main.go │ ├── model │ │ └── model.go │ └── ui │ │ ├── benchmark_test.go │ │ ├── icons.go │ │ ├── room.go │ │ ├── row-tracker.go │ │ ├── ui.go │ │ └── util.go ├── ninepatch │ └── main.go └── unconfigured │ └── main.go ├── go.mod ├── go.sum ├── layout ├── background.go ├── gutter.go ├── reverse.go ├── rounded.go ├── row.go └── vertical-margin.go ├── list ├── assets │ ├── dataflow-diagram.odt │ └── dataflow-diagram.png ├── async.go ├── async_test.go ├── compact.go ├── compact_test.go ├── element.go ├── element_test.go ├── list.test ├── manager.go ├── manager_test.go ├── slice_test.go └── synthesizer.go ├── ninepatch ├── decode.go ├── grid.go ├── ninepatch.go └── ninepatch_test.go ├── profile └── profile.go ├── res ├── 9-Patch │ ├── iap_hotdog_asset.png │ └── iap_platocookie_asset_2.png └── res.go ├── row-manager.go └── widget ├── doc.go ├── image.go ├── material ├── bubble.go ├── doc.go ├── image.go ├── message.go ├── row.go └── separator.go ├── message.go ├── plato ├── message.go ├── plato.go └── row.go ├── row.go └── userinfo.go /.builds/linux.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Unlicense OR MIT 2 | image: debian/testing 3 | packages: 4 | - curl 5 | - pkg-config 6 | - libwayland-dev 7 | - libx11-dev 8 | - libx11-xcb-dev 9 | - libxkbcommon-dev 10 | - libxkbcommon-x11-dev 11 | - libgles2-mesa-dev 12 | - libegl1-mesa-dev 13 | - libffi-dev 14 | - libxcursor-dev 15 | - libvulkan-dev 16 | sources: 17 | - https://git.sr.ht/~gioverse/chat 18 | environment: 19 | GOFLAGS: -mod=readonly 20 | PATH: /usr/bin:/home/build/go/bin:/home/build/.local/bin 21 | github_mirror: git@github.com:gioverse/chat 22 | secrets: 23 | - 5c30aa7d-653b-4c63-a2c7-c2a7a80a7aa2 24 | tasks: 25 | - install_go: | 26 | mkdir -p ~/.local/bin && \ 27 | curl --tlsv1.2 -o ~/.local/bin/gover https://git.sr.ht/~whereswaldon/gover/blob/main/gover && \ 28 | chmod +x ~/.local/bin/gover && \ 29 | gover latest 30 | - check_gofmt: | 31 | cd chat 32 | test -z "$(gofmt -s -l .)" 33 | - check_sign_off: | 34 | set +x -e 35 | cd chat 36 | for hash in $(git log -n 10 --format="%H"); do 37 | message=$(git log -1 --format=%B $hash) 38 | if [[ ! "$message" =~ "Signed-off-by: " ]]; then 39 | echo "Missing 'Signed-off-by' in commit $hash" 40 | exit 1 41 | fi 42 | done 43 | - test: | 44 | cd chat 45 | go test -race ./... 46 | - mirror: | 47 | # mirror to github 48 | ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && \ 49 | cd chat && \ 50 | git push --mirror "$github_mirror" || echo "failed mirroring" 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This project is provided under the terms of the UNLICENSE or 2 | the MIT license denoted by the following SPDX identifier: 3 | 4 | SPDX-License-Identifier: Unlicense OR MIT 5 | 6 | You may use the project under the terms of either license. 7 | 8 | Both licenses are reproduced below. 9 | 10 | ---- 11 | The MIT License (MIT) 12 | 13 | Copyright (c) 2019 The Gio authors 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in 23 | all copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 31 | THE SOFTWARE. 32 | --- 33 | 34 | 35 | 36 | --- 37 | The UNLICENSE 38 | 39 | This is free and unencumbered software released into the public domain. 40 | 41 | Anyone is free to copy, modify, publish, use, compile, sell, or 42 | distribute this software, either in source code form or as a compiled 43 | binary, for any purpose, commercial or non-commercial, and by any 44 | means. 45 | 46 | In jurisdictions that recognize copyright laws, the author or authors 47 | of this software dedicate any and all copyright interest in the 48 | software to the public domain. We make this dedication for the benefit 49 | of the public at large and to the detriment of our heirs and 50 | successors. We intend this dedication to be an overt act of 51 | relinquishment in perpetuity of all present and future rights to this 52 | software under copyright law. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 55 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 56 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 57 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 58 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 59 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 60 | OTHER DEALINGS IN THE SOFTWARE. 61 | 62 | For more information, please refer to 63 | --- 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## chat 2 | 3 | A framework for building chat interfaces with [gioui.org](https://gioui.org). 4 | 5 | The canonical copy of this repository is hosted [on Sourcehut](https://git.sr.ht/~gioverse/chat). We also have: 6 | 7 | - [An issue tracker.](https://todo.sr.ht/~gioverse/chat) 8 | - [A mailing list.](https://lists.sr.ht/~gioverse/chat) 9 | 10 | ## Status 11 | 12 | Work in progress. Expect breaking API changes often. 13 | 14 | ## Parts 15 | 16 | This repo contains many packages with different goals: 17 | 18 | - `list`: This package implements logic to allow a Gio list to scroll over arbitrarily large quantities of elements while keeping a fixed number in RAM. 19 | - `layout`: This package exposes some layout types and functions helpful for building chat interfaces. 20 | - `ninepatch`: A ninepatch image decoder. 21 | - `widget`: This package exposes the state types for some helpful chat widgets. 22 | - `widget/material`: This package exposes some material-design-themed components for building chat interfaces. 23 | - `example/kitchen`: An example of using all of this parts of this module together to build a chat interface. 24 | - `example/ninepatch`: An example of the ninepatch decoder. 25 | - `example/unconfigured`: A demonstration of the default behavior of the `list.Manager` if no custom hooks are provided to it. 26 | - `profile`: Simple profiling for Gio programs. 27 | 28 | ## Usage 29 | 30 | See the `./example` directory for applications showcasing how to use the current 31 | API. 32 | 33 | In particular `./example/kitchen` tries to exercise the full range of this 34 | module's features. 35 | 36 | ### List 37 | 38 | The `list` package API deserves some discussion. `list.Manager` handles the complex task of maintaining a sliding window of list elements atop an arbitrarily-long (maybe infinite) underlying list of content. This means that `list.Manager` handles all of the following: 39 | 40 | - Requesting list content from its source of truth, 41 | - Maintaining the proper ordering and deduplication of that content, 42 | - Discarding the element data furthest away from the current list viewport when the list grows too large, 43 | - Resolving dynamic attributes of list content, 44 | - Injecting widgets between list elements (such as unread markers or date separators in chat), 45 | - Allocating and persisting state for the heterogeneous list contents, 46 | - And, laying out widgets for each element in the managed list. 47 | 48 | `list.Manager` is able to accomplish all of the above in a generic way by reqiring a set of "hooks" provided by your application. These hooks supply application specific intelligence about the concrete types of your data, the way that your data relates to itself, and the way that your list elements should be presented to the user. 49 | 50 | The required hooks are: 51 | 52 | - `Loader`: a function that the `list.Manager` can invoke to load more elements from the source of truth for the list data. This function is expected to block during the load, and the parameters provided to it indicate the direction and relative position of the requested content within the source of truth. 53 | - `Comparator`: a function used to sort list elements. It is provided two elements and returns whether the first sorts before the second. 54 | - `Sythesizer`: a function that can transform the list elements from within the state management goroutine. Many applications may have list elements with dynamic properties. Those properties require and API call or database interaction to resolve, and you don't want to perform such blocking I/O from the layout goroutine. This hook provides a place to perform blocking queries and other data transformations without blocking the layout goroutine. In particular, this hook can return zero or more elements, meaning that it can choose to hide list elements or to insert other elements around them prior to layout. 55 | - `Invalidator`: a function that can invalidate the window. This is necessary so that the `list.Manager` can ensure that Gio draws a new frame once it finishes a state update. 56 | - `Allocator`: a function that accepts a `list.Element` and returns the appropriate state type for it. The returned state type will be persisted by the `list.Manager`. For instance, a `list.Element` that renders a button would return `*widget.Clickable` from this hook, so that it has somewhere to store its click state across frames. 57 | - `Presenter`: a function that accepts a `list.Element`, some state from the `Allocator`, and returns a Gio `layout.Widget` that will lay out the list element as a widget. 58 | 59 | As much work as possible is performed in a background state management goroutine so that the layout goroutine has no reason to block. 60 | 61 | Here's a diagram showing how the various hooks work together: 62 | 63 | ![diagram](https://git.sr.ht/~gioverse/chat/blob/main/list/assets/dataflow-diagram.png) 64 | 65 | For a relatively simple implementation of using all of the hooks together to build something useful, see `./example/carousel/`. 66 | 67 | ## License 68 | 69 | Dual Unlicense/MIT, same as Gio 70 | -------------------------------------------------------------------------------- /async/loader.go: -------------------------------------------------------------------------------- 1 | // Loader adapted from Egon's https://github.com/egonelbre/expgio. 2 | package async 3 | 4 | import ( 5 | "context" 6 | "runtime" 7 | "sync" 8 | "sync/atomic" 9 | 10 | "gioui.org/layout" 11 | ) 12 | 13 | // Tag pointer used to identify a unique resource. 14 | type Tag interface{} 15 | 16 | // LoadFunc function that performs the blocking load. 17 | type LoadFunc func(ctx context.Context) interface{} 18 | 19 | // Resource is an async entity that can be in various states and potentially 20 | // contain a value. 21 | type Resource struct { 22 | // State reports current state for this resource. 23 | State State 24 | // Value for the resource. Nil if not ready. 25 | Value interface{} 26 | } 27 | 28 | // State that an async Resource can be in. 29 | type State byte 30 | 31 | const ( 32 | Queued State = iota 33 | Loading 34 | Loaded 35 | ) 36 | 37 | // Loader is an asynchronously loaded resource. 38 | // Start and poll a resource with Schedule method. 39 | // Track frames with Frame method to detect stale data. 40 | // Respond to updates in event loop by selecting on Updated channel. 41 | type Loader struct { 42 | // Scheduler provides scheduling behaviour. Defaults to a sized worker pool. 43 | // The caller can provide a scheduler that implements the best strategy for 44 | // the their usecase. 45 | Scheduler Scheduler 46 | // MaxLoaded specifies the maximum number of resources to load before 47 | // de-allocating old resources. 48 | MaxLoaded int 49 | // active frame being layed out. 50 | // Access must be synchronized with atomics. 51 | active int64 52 | // finished frames that have been layed out. 53 | // Access must be synchronized with atomics. 54 | finished int64 55 | // update chan reports that a resource's status has changed. 56 | // Useful for invalidating the window. 57 | updated chan struct{} 58 | // init allows Loader to have a useful zero value by lazily allocating on 59 | // first use. 60 | init sync.Once 61 | // loader contains the queue and lookup map. 62 | loader 63 | // cancel can, if non-nil, shut down the background worker loop for the loader. 64 | cancel func() 65 | } 66 | 67 | // Scheduler schedules work according to some strategy. 68 | // Implementations can implement the best way to distribute work for a given 69 | // application. 70 | // 71 | // TODO(jfm): context cancellation. 72 | type Scheduler interface { 73 | // Schedule a piece of work. This method is allowed to block. 74 | Schedule(func()) 75 | } 76 | 77 | // FixedWorkerPool implements a simple fixed-size worker pool that lets go 78 | // runtime schedule work atop some number of goroutines. 79 | // 80 | // This pool will minimize goroutine latency at the cost of maintaining the 81 | // configured number of goroutines throughout the lifetime of the pool. 82 | type FixedWorkerPool struct { 83 | // Workers specifies the number of concurrent workers in this pool. 84 | Workers int 85 | // queue of work. Unbuffered so it will block if worker pull is at capacity. 86 | queue chan func() 87 | // once time initialization. 88 | sync.Once 89 | } 90 | 91 | // Schedule work to be executed by the available workers. This is a blocking 92 | // call if all workers are busy. 93 | func (p *FixedWorkerPool) Schedule(work func()) { 94 | p.Once.Do(func() { 95 | p.queue = make(chan func()) 96 | if p.Workers <= 0 { 97 | p.Workers = runtime.NumCPU() 98 | } 99 | for ii := 0; ii < p.Workers; ii++ { 100 | go func() { 101 | for w := range p.queue { 102 | if w != nil { 103 | w() 104 | } 105 | } 106 | }() 107 | } 108 | }) 109 | p.queue <- work 110 | } 111 | 112 | // DynamicWorkerPool implements a simple dynamic-sized worker pool that spins up 113 | // a new worker per unit of work, until the maximum number of workers has been 114 | // reached. 115 | // 116 | // This pool will minimize idle memory as goroutines will die off once complete, 117 | // but will incur the latency cost, such that it is, of spinning up goroutines 118 | // on-the-fly. 119 | // 120 | // Additionally, ordering of work is inconsistent with highly dynamic layouts. 121 | type DynamicWorkerPool struct { 122 | // Workers specifies the maximum allowed number of concurrent workers in 123 | // this pool. Defaults to NumCPU. 124 | Workers int64 125 | // count is a semaphore queue that limits the number of workers at any 126 | // given time. The size of the buffer for the channel provides the limit. 127 | count chan struct{} 128 | // queue of work. Unbuffered so it will block if worker pool is at capacity. 129 | queue chan func() 130 | // once time initialization. 131 | sync.Once 132 | } 133 | 134 | // Schedule work to be executed by the available workers. This is a blocking 135 | // call if all workers are busy. 136 | // 137 | // Workers are limited by a buffer of semaphores. 138 | // Each worker holds a semaphore for the duration of it's life and returns it 139 | // before exiting. 140 | func (p *DynamicWorkerPool) Schedule(work func()) { 141 | p.Once.Do(func() { 142 | if p.Workers <= 0 { 143 | p.Workers = int64(runtime.NumCPU()) 144 | } 145 | p.queue = make(chan func()) 146 | p.count = make(chan struct{}, p.Workers) 147 | for ii := 0; ii < int(p.Workers); ii++ { 148 | p.count <- struct{}{} 149 | } 150 | go func() { 151 | for w := range p.queue { 152 | w := w 153 | if w != nil { 154 | sem := <-p.count 155 | go func() { 156 | w() 157 | p.count <- sem 158 | }() 159 | } 160 | } 161 | }() 162 | }) 163 | p.queue <- work 164 | } 165 | 166 | // loader wraps up state that needs to be synchronized together. 167 | type loader struct { 168 | // mu is the primary mutex used to synchronize. 169 | mu sync.Mutex 170 | // refresh sleeps the loop, ensuring we only try to process the queue when 171 | // something has actually changed. 172 | refresh sync.Cond 173 | // lookup is a map of async resources mapped to a unique tag similar to 174 | // gio router api. Tag value must be a hashable type. 175 | lookup map[Tag]*resource 176 | // queue of resources to process in sequence. 177 | queue []*resource 178 | } 179 | 180 | // Updated returns a channel that reports whether loader has been updated. 181 | // Integrate this into gio event loop to, for example, invalidate the window. 182 | // 183 | // case <-loader.Updated(): 184 | // w.Invalidate() 185 | func (l *Loader) Updated() <-chan struct{} { 186 | l.init.Do(l.initialize) 187 | return l.updated 188 | } 189 | 190 | // Frame wraps a widget and tracks frame updates. 191 | // 192 | // Typically you should wrap your entire UI so that each frame is counted. 193 | // However, it is sufficient to wrap only the widget that expects to use the 194 | // loader during it's layout. 195 | // 196 | // A frame is currently updating if activeFrame < finishedFrame. 197 | func (l *Loader) Frame(gtx layout.Context, w layout.Widget) layout.Dimensions { 198 | atomic.AddInt64(&l.active, 1) 199 | dim := w(gtx) 200 | atomic.StoreInt64(&l.finished, atomic.LoadInt64(&l.active)) 201 | l.refresh.Signal() 202 | return dim 203 | } 204 | 205 | // DefaultMaxLoaded is used when no max is specified. 206 | const DefaultMaxLoaded = 10 207 | 208 | // Schedule a resource to be loaded asynchronously, returning a resource that 209 | // will hold the loaded value at some point. 210 | // 211 | // Schedule should be called per frame and the state of the resource checked 212 | // accordingly. 213 | // 214 | // The first call will queue up the load, subsequent calls will poll for the 215 | // status. 216 | func (l *Loader) Schedule(tag Tag, load LoadFunc) Resource { 217 | l.init.Do(l.initialize) 218 | return l.loader.establish(tag, load, atomic.LoadInt64(&l.active)) 219 | } 220 | 221 | func (l *Loader) initialize() { 222 | if l.MaxLoaded == 0 { 223 | l.MaxLoaded = DefaultMaxLoaded 224 | } 225 | l.updated = make(chan struct{}, 1) 226 | l.loader.lookup = make(map[Tag]*resource) 227 | l.loader.refresh.L = &l.loader.mu 228 | if l.Scheduler == nil { 229 | l.Scheduler = &FixedWorkerPool{Workers: l.MaxLoaded} 230 | } 231 | // TODO(jfm): expose context in the public api so that loads can be 232 | // cancelled by it. 233 | // Egon's example ran this at the top of the event loop. By placing it 234 | // here we achieve useful zero-value but since the context is not 235 | // exposed, it's useless. 236 | ctx, cancel := context.WithCancel(context.Background()) 237 | l.cancel = cancel 238 | go l.run(ctx) 239 | } 240 | 241 | // LoaderStats tracks some stats about the loader. 242 | type LoaderStats struct { 243 | Lookup int 244 | Queued int 245 | } 246 | 247 | // Stats reports runtime data about this loader. 248 | func (l *loader) Stats() LoaderStats { 249 | l.mu.Lock() 250 | defer l.mu.Unlock() 251 | return LoaderStats{ 252 | Lookup: len(l.lookup), 253 | Queued: len(l.queue), 254 | } 255 | } 256 | 257 | // update signals to the outside world that some resource has experienced a 258 | // state change. 259 | // 260 | // This is particularly useful for invaliding the window, forcing a re-layout 261 | // immediately. 262 | func (l *Loader) update() { 263 | select { 264 | case l.updated <- struct{}{}: 265 | default: 266 | } 267 | } 268 | 269 | // Shutdown ends the background processing of the loader, if any is happening. 270 | func (l *Loader) Shutdown() { 271 | if l.cancel != nil { 272 | l.cancel() 273 | } 274 | } 275 | 276 | // run the persistent processing goroutine that performs the blocking operations. 277 | func (l *Loader) run(ctx context.Context) { 278 | go func() { 279 | <-ctx.Done() 280 | l.refresh.Signal() 281 | }() 282 | defer close(l.updated) 283 | 284 | loader := &l.loader 285 | 286 | loader.mu.Lock() 287 | defer loader.mu.Unlock() 288 | 289 | firstIteration := true 290 | for { 291 | if !firstIteration { 292 | // Wait to be woken up by a change. Three conditions which provoke this: 293 | // 1. a new frame layout 294 | // 2. scheduling a _new_ resource 295 | // 3. context cancellation 296 | // Each iteration synchronizes access to the map and queue. 297 | loader.refresh.Wait() 298 | if ctx.Err() != nil { 299 | return 300 | } 301 | } 302 | firstIteration = false 303 | loader.purge(atomic.LoadInt64(&l.finished), l.MaxLoaded) 304 | for r := loader.next(); r != nil; r = loader.next() { 305 | r := r 306 | if l.isOld(r) { 307 | loader.remove(r) 308 | continue 309 | } 310 | loader.mu.Unlock() 311 | l.update() 312 | l.Scheduler.Schedule(func() { 313 | r.Load(ctx, func(_ State) { 314 | l.update() 315 | }) 316 | }) 317 | loader.mu.Lock() 318 | } 319 | } 320 | } 321 | 322 | // isOld reports whether the resource is old. 323 | // Old is defined as being last used in a frame prior to the current frame. 324 | // For example, a resource is "old" if the last frame it was used was frame 3 325 | // and the current frame is frame 10. 326 | func (l *Loader) isOld(r *resource) bool { 327 | return atomic.LoadInt64(&r.frame) < atomic.LoadInt64(&l.finished) 328 | } 329 | 330 | // establish a resource for the given tag and load function. 331 | // If the resource does not already exist it is first allocated. 332 | // A copy of the state and a reference to the value are returned. 333 | func (l *loader) establish(tag Tag, load LoadFunc, activeFrame int64) Resource { 334 | l.mu.Lock() 335 | r, ok := l.lookup[tag] 336 | if !ok { 337 | r = &resource{ 338 | tag: tag, 339 | load: load, 340 | state: Queued, 341 | value: nil, 342 | } 343 | l.lookup[tag] = r 344 | l.queue = append(l.queue, r) 345 | l.refresh.Signal() 346 | } 347 | l.mu.Unlock() 348 | // Set the resource frame to that of the currently active frame. 349 | // This "freshens" the resource, indicating that it has recently been 350 | // accessed. 351 | atomic.StoreInt64(&r.frame, activeFrame) 352 | state, value := r.Get() 353 | return Resource{ 354 | State: state, 355 | Value: value, 356 | } 357 | } 358 | 359 | // next selects the next resource off the queue. 360 | // Only call this when lock has been acquired. 361 | func (l *loader) next() *resource { 362 | if len(l.queue) == 0 { 363 | return nil 364 | } 365 | r := l.queue[0] 366 | l.queue = l.queue[1:] 367 | return r 368 | } 369 | 370 | // purge removes stale data such that it gets garbage collected. 371 | // 372 | // A resource is purged if it is old and the maximum number of resources has 373 | // been exhausted. 374 | // 375 | // Only call this when lock has been acquired. 376 | func (l *loader) purge(activeFrame int64, max int) { 377 | for _, r := range l.lookup { 378 | if len(l.lookup) < max { 379 | break 380 | } 381 | if isOld := atomic.LoadInt64(&r.frame) < activeFrame; isOld { 382 | l.remove(r) 383 | } 384 | } 385 | } 386 | 387 | // remove the resource from the local storage and let it be garbage collected. 388 | func (l *loader) remove(r *resource) { 389 | delete(l.lookup, r.tag) 390 | } 391 | 392 | // resource records data about a loading value. 393 | // state and value are synchronized by the mutex, tag and load are set once 394 | // during allocation, and frame is synchronized via atomic operations. 395 | type resource struct { 396 | sync.Mutex 397 | // frame wherein this data is valid. 398 | // Access must be synchronized with atomics. 399 | frame int64 400 | // state of the resource for this frame. 401 | // Access is synchronized by mutex, use Get method. 402 | state State 403 | // value for the resource, if acquired. 404 | // Access is synchronized by mutex, use Get method. 405 | value interface{} 406 | // tag of the resource. 407 | // Used to uniquely identify the resource stored in a map. 408 | // Must be a hashable value. 409 | // Unsynchronized field, do not modify. 410 | tag Tag 411 | // load function for the resource. 412 | // Used to perform the blocking load of the value, like a network call or 413 | // disk operation. 414 | // Unsynchronized field, do not modify. 415 | load LoadFunc 416 | } 417 | 418 | // Load the value for the resource using the configured closure. 419 | // State changes occur during load sequence, invoking onChange callback per 420 | // state change. 421 | func (r *resource) Load(ctx context.Context, onChange func(State)) { 422 | r.Set(Loading, nil) 423 | onChange(r.state) 424 | v := r.load(ctx) 425 | r.Set(Loaded, v) 426 | onChange(r.state) 427 | } 428 | 429 | // Get the state and value for the resource. 430 | func (r *resource) Get() (State, interface{}) { 431 | r.Lock() 432 | defer r.Unlock() 433 | return r.state, r.value 434 | } 435 | 436 | // Set the state and value for the resource. 437 | func (r *resource) Set(s State, v interface{}) { 438 | r.Lock() 439 | r.state = s 440 | r.value = v 441 | r.Unlock() 442 | } 443 | -------------------------------------------------------------------------------- /chat.go: -------------------------------------------------------------------------------- 1 | // Package chat implements chat components for [gio](https://gioui.org). 2 | package chat 3 | -------------------------------------------------------------------------------- /debug/debug.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package debug provides tools for debugging Gio layout code. 3 | */ 4 | package debug 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "image/color" 11 | "io" 12 | "os" 13 | "runtime" 14 | 15 | "gioui.org/layout" 16 | "gioui.org/unit" 17 | "gioui.org/widget" 18 | ) 19 | 20 | type ( 21 | C = layout.Context 22 | D = layout.Dimensions 23 | ) 24 | 25 | // Outline traces a small black outline around the provided widget. 26 | func Outline(gtx C, w func(gtx C) D) D { 27 | return widget.Border{ 28 | Color: color.NRGBA{A: 255}, 29 | Width: unit.Dp(1), 30 | }.Layout(gtx, w) 31 | } 32 | 33 | // Gtx will draw a thin, translucent, red rectangle at the gtx's max constraints and a 34 | // similar thin, blue rectangle at the minimum constraints. It is best used deferred, to ensure 35 | // the rectangles appear atop other widget content. 36 | func Gtx(gtx C) { 37 | widget.Border{Color: color.NRGBA{R: 255, A: 128}, Width: unit.Dp(1)}.Layout(gtx, func(gtx C) D { 38 | return D{Size: gtx.Constraints.Max} 39 | }) 40 | widget.Border{Color: color.NRGBA{B: 255, A: 128}, Width: unit.Dp(1)}.Layout(gtx, func(gtx C) D { 41 | return D{Size: gtx.Constraints.Min} 42 | }) 43 | } 44 | 45 | // Sz draws a thing, translucent, green rectangle at the size of the given widget. It is designed 46 | // to easily wrap invocations of layout and pass through the dimensions. 47 | func Sz(gtx C, dims D) D { 48 | widget.Border{Color: color.NRGBA{G: 255, A: 128}, Width: unit.Dp(1)}.Layout(gtx, func(gtx C) D { 49 | return dims 50 | }) 51 | return dims 52 | } 53 | 54 | // Dump logs the input as formatting JSON on stderr. 55 | func Dump(v interface{}) { 56 | b, _ := json.MarshalIndent(v, "", " ") 57 | b = append(b, []byte("\n")...) 58 | io.Copy(os.Stderr, bytes.NewBuffer(b)) 59 | } 60 | 61 | // Caller returns the function nFrames above it on the call stack. 62 | // Passing 3 as nFrames will return the details of the function 63 | // invoking the function in which caller was invoked. This can help 64 | // determine which of several code paths were taken to reach a 65 | // particular place in the code. 66 | func Caller(nFrames int) string { 67 | fpcs := make([]uintptr, 1) 68 | n := runtime.Callers(nFrames, fpcs) 69 | if n == 0 { 70 | return "NO CALLER" 71 | } 72 | 73 | caller := runtime.FuncForPC(fpcs[0] - 1) 74 | if caller == nil { 75 | return "MSG CALLER WAS NIL" 76 | } 77 | 78 | // Print the file name and line number 79 | file, line := caller.FileLine(fpcs[0] - 1) 80 | return fmt.Sprintf("%s:%d", file, line) 81 | } 82 | -------------------------------------------------------------------------------- /example/async/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "image" 11 | "image/color" 12 | "io" 13 | "io/fs" 14 | "log" 15 | "net/http" 16 | "os" 17 | "path/filepath" 18 | "runtime" 19 | "strconv" 20 | 21 | "gioui.org/app" 22 | "gioui.org/font/gofont" 23 | "gioui.org/io/system" 24 | "gioui.org/layout" 25 | "gioui.org/op" 26 | "gioui.org/op/clip" 27 | "gioui.org/op/paint" 28 | "gioui.org/unit" 29 | "gioui.org/widget" 30 | "gioui.org/widget/material" 31 | "git.sr.ht/~gioverse/chat/async" 32 | "git.sr.ht/~gioverse/chat/profile" 33 | chatwidget "git.sr.ht/~gioverse/chat/widget" 34 | 35 | _ "image/jpeg" 36 | _ "image/png" 37 | ) 38 | 39 | var ( 40 | th = material.NewTheme(gofont.Collection()) 41 | // strategy specifies which worker pool implementation to use: dynamic or 42 | // fixed. 43 | strategy string 44 | // workers specifies the maximum number of allowed workers in the worker 45 | // pool. 46 | workers int 47 | // noIO specifies to avoid downloading images or touching the disk. 48 | // Reduces blocking IO to let any blocking UI bubble up in the block profile. 49 | noIO bool 50 | // cache specifies whether to cache on disk or just hold images in memory. 51 | cache bool 52 | // purge the cache before layout. 53 | // Useful when you want to cache, but you want the cache to start empty. 54 | purge bool 55 | // profileOpt specifies what to profile. 56 | profileOpt string 57 | // cacheDir is the path to the cache. 58 | cacheDir = filepath.Join(os.TempDir(), "chat", "resources") 59 | ) 60 | 61 | func init() { 62 | flag.StringVar(&strategy, "strategy", "fixed", "worker pool strategy [fixed, dynamic]") 63 | flag.IntVar(&workers, "workers", runtime.NumCPU(), "maximum allowed workers in the worker pool") 64 | flag.BoolVar(&cache, "cache", true, "cache images to disk") 65 | flag.BoolVar(&purge, "purge", false, "purge the cache (deletes all entries)") 66 | flag.BoolVar(&noIO, "no-io", false, "run the loader without any IO") 67 | flag.StringVar(&profileOpt, "profile", "none", "create the provided kind of profile. Use one of [none, cpu, mem, block, goroutine, mutex, trace, gio]") 68 | flag.Parse() 69 | fmt.Printf("using %s worker pool with %d workers\n", strategy, workers) 70 | if purge { 71 | if err := os.RemoveAll(cacheDir); err != nil { 72 | log.Fatalf("purging cache: %v\n", err) 73 | } 74 | } 75 | } 76 | 77 | func main() { 78 | // Confgure the type of worker pool we want to use. 79 | // This will change how async work is executed. 80 | var sch async.Scheduler 81 | if strategy == "dynamic" { 82 | sch = &async.DynamicWorkerPool{ 83 | Workers: int64(workers), 84 | } 85 | } 86 | if strategy == "fixed" { 87 | sch = &async.FixedWorkerPool{ 88 | Workers: workers, 89 | } 90 | } 91 | ui := NewUI(sch) 92 | go func() { 93 | w := app.NewWindow( 94 | app.Title("Loader"), 95 | ) 96 | if err := ui.Run(w); err != nil { 97 | fmt.Fprintln(os.Stderr, err) 98 | os.Exit(1) 99 | } 100 | os.Exit(0) 101 | }() 102 | app.Main() 103 | } 104 | 105 | // UI holds state for, and lays out, the UI. 106 | type UI struct { 107 | // Loader api is designed to be useful as a zero value. So declaring it on 108 | // the UI is sufficient to start using it. 109 | async.Loader 110 | // reels executes the demonstration, laying out as many async widgets as 111 | // the viewport will allow. 112 | reels Reels 113 | } 114 | 115 | // NewUI allocates a UI with some number of reels. 116 | func NewUI(s async.Scheduler) UI { 117 | return UI{ 118 | // MaxLoaded of '0' indicates to only keep state that is currently in 119 | // view. 120 | Loader: async.Loader{ 121 | MaxLoaded: 0, 122 | Scheduler: s, 123 | }, 124 | } 125 | } 126 | 127 | // Run handles window events and renders the application. 128 | func (ui *UI) Run(w *app.Window) error { 129 | profiler := profile.Opt(profileOpt).NewProfiler() 130 | profiler.Start() 131 | var ops op.Ops 132 | for { 133 | select { 134 | case <-ui.Loader.Updated(): 135 | fmt.Printf("%#v\n", ui.Loader.Stats()) 136 | w.Invalidate() 137 | case e := <-w.Events(): 138 | switch e := e.(type) { 139 | case system.DestroyEvent: 140 | profiler.Stop() 141 | return e.Err 142 | case system.FrameEvent: 143 | gtx := layout.NewContext(&ops, e) 144 | profiler.Record(gtx) 145 | ui.Layout(gtx) 146 | e.Frame(&ops) 147 | } 148 | } 149 | } 150 | } 151 | 152 | type ( 153 | C = layout.Context 154 | D = layout.Dimensions 155 | ) 156 | 157 | // Layout the UI. Notice the async.Loader wraps each frame. It does this in 158 | // order to count frames and in-turn, detect stale data. 159 | func (ui *UI) Layout(gtx C) D { 160 | return ui.Loader.Frame(gtx, func(gtx C) D { 161 | return ui.reels.Layout(gtx, &ui.Loader) 162 | }) 163 | } 164 | 165 | // Reels lays out an infinitely growing list of Reel, thus forming a 2D grid of 166 | // square widgets that fill the entire viewport. Reels are grown as the view 167 | // expands. 168 | type Reels struct { 169 | // items is a list of Reel too layout. 170 | items []Reel 171 | // list state. 172 | list widget.List 173 | } 174 | 175 | // Layout the reels vertically, performing async operations on the provided 176 | // loader. 177 | func (reels *Reels) Layout(gtx C, loader *async.Loader) D { 178 | reels.list.Axis = layout.Vertical 179 | return material.List(th, &reels.list).Layout(gtx, reels.Len(), func(gtx C, ii int) D { 180 | if ii == len(reels.items) { 181 | reels.Grow() 182 | } 183 | return reels.items[ii].Layout(gtx, loader) 184 | }) 185 | } 186 | 187 | // Len reports the number of reels. Ensures at least one reel is available. 188 | func (reels *Reels) Len() int { 189 | if len(reels.items) == 0 { 190 | reels.Grow() 191 | } 192 | return len(reels.items) + 1 193 | } 194 | 195 | // Grow a reel. 196 | func (reels *Reels) Grow() { 197 | reels.items = append(reels.items, Reel{index: len(reels.items)}) 198 | } 199 | 200 | // Reel lays out an infinitely growing number of square widgets in a scrollable 201 | // list for demonstration. Reel grows as the view expands. 202 | // 203 | // index and count together form the ID of the Reel for `async.Loader` lookups, 204 | // since the logical Reel is stateless (function of it's position in the grid). 205 | type Reel struct { 206 | // index of this reel in the Reels list, for ID purposes. 207 | index int 208 | // count is the number of widgets in the Reel. 209 | count int 210 | // list state. 211 | list layout.List 212 | // cache the image ops mapped to reel item IDs. 213 | cache map[string]*chatwidget.CachedImage 214 | } 215 | 216 | // Len reports the number of widgets in the real. Ensures at least one widget 217 | // is available. 218 | func (reel *Reel) Len() int { 219 | if reel.count == 0 { 220 | reel.count++ 221 | } 222 | return reel.count + 1 223 | } 224 | 225 | // Layout a Reel. Reel is represented by a colored square. Green when Queued, 226 | // Red when Loading, and the downloaded image when Loaded. 227 | func (reel *Reel) Layout(gtx C, loader *async.Loader) D { 228 | if reel.cache == nil { 229 | reel.cache = make(map[string]*chatwidget.CachedImage) 230 | } 231 | return reel.list.Layout(gtx, reel.Len(), 232 | func(gtx C, ii int) D { 233 | if ii == reel.count { 234 | reel.count++ 235 | } 236 | return layout.UniformInset(unit.Dp(8)).Layout(gtx, 237 | func(gtx C) D { 238 | px := gtx.Dp(unit.Dp(64)) 239 | size := image.Point{X: px, Y: px} 240 | gtx.Constraints = layout.Exact(size) 241 | 242 | id := strconv.Itoa(reel.index) + ":" + strconv.Itoa(ii) 243 | 244 | // Schedule the resource. 245 | // 246 | // This returns an promise-like structure that can be in 247 | // various states: queued, loading and loaded. 248 | // If loaded, the contained value can be inspected and used. 249 | // 250 | // Calling schedule like this keeps the resource warm. 251 | // If this part of the layout falls out of view, the resource 252 | // becomes cold and gets garbage collected. 253 | // 254 | // You can leak the resource by taking a pointer to it or its 255 | // value to ensure it doesn't get garbage collected. 256 | r := loader.Schedule(id, func(ctx context.Context) interface{} { 257 | img, err := fetch(id, fmt.Sprintf(unsplash, 64, 64)) 258 | if err != nil { 259 | log.Printf("error fetching image: %v", err) 260 | return nil 261 | } 262 | return img 263 | }) 264 | 265 | // Switch on the various possible states of the resource. 266 | // Here we choose to layout colored squares, green for queued, 267 | // red for loading. When loaded we type assert the value for 268 | // and image, cache it and finally present it. 269 | switch r.State { 270 | case async.Queued: 271 | col := color.NRGBA{R: 0xFF, G: 0xC0, B: 0xC0, A: 0xFF} 272 | paint.FillShape(gtx.Ops, col, clip.Rect{Max: size}.Op()) 273 | layout.Center.Layout(gtx, material.Body1(th, id).Layout) 274 | 275 | case async.Loading: 276 | col := color.NRGBA{R: 0xC0, G: 0xFF, B: 0xC0, A: 0xFF} 277 | paint.FillShape(gtx.Ops, col, clip.Rect{Max: size}.Op()) 278 | layout.Center.Layout(gtx, material.Body1(th, id).Layout) 279 | 280 | case async.Loaded: 281 | col := color.NRGBA{R: 0xF0, G: 0xF0, B: 0xF0, A: 0xFF} 282 | paint.FillShape(gtx.Ops, col, clip.Rect{Max: size}.Op()) 283 | 284 | m, ok := r.Value.(image.Image) 285 | if ok && m != nil { 286 | im, ok := reel.cache[id] 287 | if !ok { 288 | im = &chatwidget.CachedImage{} 289 | reel.cache[id] = im 290 | } 291 | im.Cache(m) 292 | layout.Center.Layout(gtx, func(gtx C) D { 293 | return widget.Image{ 294 | Src: im.Op(), 295 | Fit: widget.Contain, 296 | }.Layout(gtx) 297 | }) 298 | } else { 299 | layout.Center.Layout(gtx, material.Body1(th, id).Layout) 300 | } 301 | } 302 | 303 | return D{Size: size} 304 | }) 305 | }) 306 | } 307 | 308 | // unsplash endpoint that returns random nature images for the given dimensions. 309 | const unsplash = "https://source.unsplash.com/random/%dx%d?nature" 310 | 311 | // fetch image for the given id. 312 | // Image is initially downloaded from the provided url and stored on disk. 313 | // If flag `cache` is false, downloads will not be stored on disk. 314 | func fetch(id, u string) (image.Image, error) { 315 | if noIO { 316 | return nil, nil 317 | } 318 | path := filepath.Join(cacheDir, id) 319 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 320 | return nil, fmt.Errorf("preparing resource directory: %w", err) 321 | } 322 | var ( 323 | src io.Reader 324 | ) 325 | if cache { 326 | if info, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) || info.Size() == 0 { 327 | if info != nil && info.Size() == 0 { 328 | if err := os.Remove(path); err != nil { 329 | return nil, fmt.Errorf("removing corrupt image file for %q: %w", id, err) 330 | } 331 | } 332 | if err := func() (err error) { 333 | defer func() { 334 | if err != nil { 335 | if e := os.Remove(path); e != nil { 336 | err = e 337 | } 338 | } 339 | }() 340 | r, err := http.Get(u) 341 | if err != nil { 342 | return fmt.Errorf("GET: %w", err) 343 | } 344 | defer r.Body.Close() 345 | if r.StatusCode != http.StatusOK { 346 | return fmt.Errorf("GET: %s", r.Status) 347 | } 348 | f, err := os.Create(path) 349 | if err != nil { 350 | return fmt.Errorf("creating resource file: %w", err) 351 | } 352 | defer f.Close() 353 | if _, err := io.Copy(f, r.Body); err != nil { 354 | return fmt.Errorf("downloading resource to disk: %w", err) 355 | } 356 | return nil 357 | }(); err != nil { 358 | return nil, err 359 | } 360 | } 361 | f, err := os.Open(path) 362 | if err != nil { 363 | return nil, fmt.Errorf("opening resource file: %w", err) 364 | } 365 | defer f.Close() 366 | src = f 367 | } else { 368 | r, err := http.Get(u) 369 | if err != nil { 370 | return nil, fmt.Errorf("GET: %w", err) 371 | } 372 | defer r.Body.Close() 373 | if r.StatusCode != http.StatusOK { 374 | return nil, fmt.Errorf("GET: %s", r.Status) 375 | } 376 | src = r.Body 377 | } 378 | img, _, err := image.Decode(src) 379 | if err != nil { 380 | return nil, fmt.Errorf("decoding image: %w", err) 381 | } 382 | return img, nil 383 | } 384 | -------------------------------------------------------------------------------- /example/carousel/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "image" 8 | _ "image/jpeg" 9 | _ "image/png" 10 | "log" 11 | "net/http" 12 | "os" 13 | "runtime" 14 | "strconv" 15 | "time" 16 | 17 | "gioui.org/app" 18 | "gioui.org/io/system" 19 | "gioui.org/layout" 20 | "gioui.org/op" 21 | "gioui.org/op/paint" 22 | "gioui.org/unit" 23 | "gioui.org/widget" 24 | "gioui.org/widget/material" 25 | "git.sr.ht/~gioverse/chat/list" 26 | 27 | "gioui.org/font/gofont" 28 | ) 29 | 30 | const defaultSize = 800 31 | 32 | func main() { 33 | go func() { 34 | w := app.NewWindow( 35 | app.Size(unit.Dp(defaultSize), unit.Dp(defaultSize+10)), 36 | app.Title("Carousel"), 37 | ) 38 | if err := loop(w); err != nil { 39 | log.Fatal(err) 40 | } 41 | os.Exit(0) 42 | }() 43 | app.Main() 44 | } 45 | 46 | // imageElement is a list element that displays a single image. 47 | type imageElement struct { 48 | serial string 49 | img image.Image 50 | } 51 | 52 | func (i imageElement) Serial() list.Serial { 53 | return list.Serial(i.serial) 54 | } 55 | 56 | // loader creates new elements in a given direction by pulling a new image from unsplash.com. 57 | func loader(dir list.Direction, relativeTo list.Serial) ([]list.Element, bool) { 58 | var newSerial int 59 | if relativeTo == list.NoSerial { 60 | newSerial = 0 61 | } else { 62 | newSerial, _ = strconv.Atoi(string(relativeTo)) 63 | switch dir { 64 | case list.Before: 65 | newSerial-- 66 | case list.After: 67 | newSerial++ 68 | } 69 | } 70 | r, err := http.Get(fmt.Sprintf("https://source.unsplash.com/random/%dx%d?nature", defaultSize, defaultSize)) 71 | if err != nil { 72 | log.Printf("fetching image data: %v", err) 73 | return nil, true 74 | } 75 | defer r.Body.Close() 76 | img, _, err := image.Decode(r.Body) 77 | if err != nil { 78 | log.Printf("decoding image: %v", err) 79 | return nil, true 80 | } 81 | return []list.Element{ 82 | imageElement{ 83 | serial: fmt.Sprintf("%d", newSerial), 84 | img: img, 85 | }, 86 | }, true 87 | } 88 | 89 | // comparator returns whether a sorts before b. 90 | func comparator(a, b list.Element) bool { 91 | asIntA, _ := strconv.Atoi(string(a.Serial())) 92 | asIntB, _ := strconv.Atoi(string(b.Serial())) 93 | return asIntA < asIntB 94 | } 95 | 96 | // allocator creates paint.PaintOps to hold image state for an element. 97 | func allocator(e list.Element) interface{} { 98 | element := e.(imageElement) 99 | return paint.NewImageOp(element.img) 100 | } 101 | 102 | // present lays out the image contained within an element. 103 | func present(e list.Element, state interface{}) layout.Widget { 104 | paintOp := state.(paint.ImageOp) 105 | return widget.Image{ 106 | Fit: widget.Contain, 107 | Src: paintOp, 108 | }.Layout 109 | } 110 | 111 | // synthesizer transforms elements, though no transformation is necessary for this use-case. 112 | func synthesizer(previous, current, next list.Element) []list.Element { 113 | return []list.Element{current} 114 | } 115 | 116 | func loop(w *app.Window) error { 117 | th := material.NewTheme(gofont.Collection()) 118 | l := widget.List{} 119 | hooks := list.Hooks{ 120 | Loader: loader, 121 | Comparator: comparator, 122 | Synthesizer: synthesizer, 123 | Allocator: allocator, 124 | Presenter: present, 125 | Invalidator: w.Invalidate, 126 | } 127 | m := list.NewManager(10, hooks) 128 | var ops op.Ops 129 | t := time.NewTicker(time.Second) 130 | var stats runtime.MemStats 131 | for { 132 | select { 133 | case e := <-w.Events(): 134 | switch e := e.(type) { 135 | case system.DestroyEvent: 136 | return e.Err 137 | case system.FrameEvent: 138 | gtx := layout.NewContext(&ops, e) 139 | material.List(th, &l).Layout(gtx, m.UpdatedLen(&l.List), m.Layout) 140 | e.Frame(gtx.Ops) 141 | } 142 | case <-t.C: 143 | runtime.ReadMemStats(&stats) 144 | log.Println("Bytes heap-allocated:", stats.Alloc) 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /example/kitchen/appwidget/apptheme/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package apptheme provides domain-specific material design chat widgets. 3 | */ 4 | package apptheme 5 | -------------------------------------------------------------------------------- /example/kitchen/appwidget/apptheme/room.go: -------------------------------------------------------------------------------- 1 | package apptheme 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "time" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/unit" 10 | "gioui.org/widget" 11 | "gioui.org/widget/material" 12 | "gioui.org/x/component" 13 | "git.sr.ht/~gioverse/chat/example/kitchen/appwidget" 14 | chatlayout "git.sr.ht/~gioverse/chat/layout" 15 | matchat "git.sr.ht/~gioverse/chat/widget/material" 16 | ) 17 | 18 | // RoomStyle lays out a room select card. 19 | type RoomStyle struct { 20 | *appwidget.Room 21 | Image matchat.Image 22 | Name material.LabelStyle 23 | Summary material.LabelStyle 24 | TimeStamp material.LabelStyle 25 | Indicator color.NRGBA 26 | Overlay color.NRGBA 27 | } 28 | 29 | // RoomConfig configures room item display. 30 | type RoomConfig struct { 31 | // Name of the room as raw text. 32 | Name string 33 | // Image of the room. 34 | Image image.Image 35 | // Content of the latest message as raw text. 36 | Content string 37 | // SentAt timestamp of the latest message. 38 | SentAt time.Time 39 | } 40 | 41 | // Room creates a style type that can lay out the data for a room. 42 | func Room(th *material.Theme, interact *appwidget.Room, room *RoomConfig) RoomStyle { 43 | interact.Image.Cache(room.Image) 44 | return RoomStyle{ 45 | Room: interact, 46 | // TODO(jfm): name could use bold text. 47 | Name: material.Label(th, unit.Sp(14), room.Name), 48 | Summary: material.Label(th, unit.Sp(12), room.Content), 49 | TimeStamp: material.Label(th, unit.Sp(12), room.SentAt.Local().Format("15:04")), 50 | Image: matchat.Image{ 51 | Image: widget.Image{ 52 | Src: interact.Image.Op(), 53 | Fit: widget.Contain, 54 | }, 55 | Radii: unit.Dp(8), 56 | Height: unit.Dp(25), 57 | Width: unit.Dp(25), 58 | }, 59 | Indicator: th.ContrastBg, 60 | Overlay: component.WithAlpha(th.Fg, 50), 61 | } 62 | } 63 | 64 | func (room RoomStyle) Layout(gtx C) D { 65 | var ( 66 | surface = func(gtx C, w layout.Widget) D { return w(gtx) } 67 | dims layout.Dimensions 68 | ) 69 | if room.Active { 70 | surface = chatlayout.Background(room.Overlay).Layout 71 | defer func() { 72 | // Close-over the dimensions and layout the indicator atop everything 73 | // else. 74 | component.Rect{ 75 | Size: image.Point{ 76 | X: gtx.Dp(unit.Dp(3)), 77 | Y: dims.Size.Y, 78 | }, 79 | Color: room.Indicator, 80 | }.Layout(gtx) 81 | }() 82 | } 83 | dims = surface(gtx, func(gtx C) D { 84 | return material.Clickable(gtx, &room.Clickable, func(gtx C) D { 85 | return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D { 86 | return layout.Flex{ 87 | Axis: layout.Horizontal, 88 | Alignment: layout.Middle, 89 | }.Layout( 90 | gtx, 91 | layout.Rigid(func(gtx C) D { 92 | return room.Image.Layout(gtx) 93 | }), 94 | layout.Rigid(layout.Spacer{Width: unit.Dp(5)}.Layout), 95 | layout.Flexed(1, func(gtx C) D { 96 | return layout.Flex{ 97 | Axis: layout.Vertical, 98 | }.Layout( 99 | gtx, 100 | layout.Rigid(func(gtx C) D { 101 | return room.Name.Layout(gtx) 102 | }), 103 | layout.Rigid(layout.Spacer{Height: unit.Dp(5)}.Layout), 104 | layout.Rigid(func(gtx C) D { 105 | return component.TruncatingLabelStyle(room.Summary).Layout(gtx) 106 | }), 107 | ) 108 | }), 109 | layout.Rigid(layout.Spacer{Width: unit.Dp(5)}.Layout), 110 | layout.Rigid(func(gtx C) D { 111 | return room.TimeStamp.Layout(gtx) 112 | }), 113 | ) 114 | }) 115 | }) 116 | }) 117 | return dims 118 | } 119 | -------------------------------------------------------------------------------- /example/kitchen/appwidget/apptheme/theme.go: -------------------------------------------------------------------------------- 1 | package apptheme 2 | 3 | import ( 4 | "image/color" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/text" 8 | "gioui.org/unit" 9 | "gioui.org/widget/material" 10 | "github.com/lucasb-eyer/go-colorful" 11 | ) 12 | 13 | type ( 14 | C = layout.Context 15 | D = layout.Dimensions 16 | ) 17 | 18 | // Note: the values choosen are a best-guess heuristic, open to change. 19 | var ( 20 | DefaultMaxImageHeight = unit.Dp(400) 21 | DefaultMaxMessageWidth = unit.Dp(600) 22 | DefaultAvatarSize = unit.Dp(24) 23 | ) 24 | 25 | var ( 26 | Light = Palette{ 27 | Error: rgb(0xB00020), 28 | Surface: rgb(0xFFFFFF), 29 | Bg: rgb(0xDCDCDC), 30 | BgSecondary: rgb(0xEBEBEB), 31 | OnError: rgb(0xFFFFFF), 32 | OnSurface: rgb(0x000000), 33 | OnBg: rgb(0x000000), 34 | OnBgSecondary: rgb(0x000000), 35 | } 36 | Dark = Palette{ 37 | Error: rgb(0xB00020), 38 | Surface: rgb(0x222222), 39 | Bg: rgb(0x000000), 40 | BgSecondary: rgb(0x444444), 41 | OnError: rgb(0xFFFFFF), 42 | OnSurface: rgb(0xFFFFFF), 43 | OnBg: rgb(0xEEEEEE), 44 | OnBgSecondary: rgb(0xFFFFFF), 45 | } 46 | ) 47 | 48 | // ToNRGBA converts a colorful.Color to the nearest representable color.NRGBA. 49 | func ToNRGBA(c colorful.Color) color.NRGBA { 50 | r, g, b, a := c.RGBA() 51 | return color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)} 52 | } 53 | 54 | // Theme wraps the material.Theme with useful application-specific 55 | // theme information. 56 | type Theme struct { 57 | *material.Theme 58 | // UserColors tracks a mapping from chat username to the color 59 | // chosen to represent that user. 60 | UserColors map[string]UserColorData 61 | // AvatarSize specifies how large the avatar image should be. 62 | AvatarSize unit.Dp 63 | // Palette specifies semantic colors. 64 | Palette Palette 65 | } 66 | 67 | // Palette defines non-brand semantic colors. 68 | // 69 | // `On` colors define a color that is appropriate to display atop it's 70 | // counterpart. 71 | type Palette struct { 72 | // Error used to indicate errors. 73 | Error color.NRGBA 74 | OnError color.NRGBA 75 | // Surface affect surfaces of components, such as cards, sheets and menus. 76 | Surface color.NRGBA 77 | OnSurface color.NRGBA 78 | // Bg appears behind scrollable content. 79 | Bg color.NRGBA 80 | OnBg color.NRGBA 81 | // BgSecondary appears behind scrollable content. 82 | BgSecondary color.NRGBA 83 | OnBgSecondary color.NRGBA 84 | } 85 | 86 | // UserColorData tracks both a color and its luminance. 87 | type UserColorData struct { 88 | color.NRGBA 89 | Luminance float64 90 | } 91 | 92 | // NewTheme instantiates a theme using the provided fonts. 93 | func NewTheme(fonts []text.FontFace) *Theme { 94 | var ( 95 | base = material.NewTheme(fonts) 96 | ) 97 | th := Theme{ 98 | Theme: base, 99 | UserColors: make(map[string]UserColorData), 100 | AvatarSize: DefaultAvatarSize, 101 | } 102 | th.UsePalette(Light) 103 | return &th 104 | } 105 | 106 | // UsePalette changes to the specified palette. 107 | func (t *Theme) UsePalette(p Palette) { 108 | t.Palette = p 109 | t.Theme.Bg = t.Palette.Bg 110 | t.Theme.Fg = t.Palette.OnBg 111 | } 112 | 113 | // Toggle the active theme between pre-configured Light and Dark palettes. 114 | func (t *Theme) Toggle() { 115 | if t.Palette == Light { 116 | t.UsePalette(Dark) 117 | } else { 118 | t.UsePalette(Light) 119 | } 120 | } 121 | 122 | // UserColor returns a color for the provided username. It will choose a 123 | // new color if the username is new. 124 | func (t *Theme) UserColor(username string) UserColorData { 125 | if c, ok := t.UserColors[username]; ok { 126 | return c 127 | } 128 | c := colorful.FastHappyColor().Clamped() 129 | 130 | uc := UserColorData{ 131 | NRGBA: ToNRGBA(c), 132 | } 133 | uc.Luminance = (0.299*float64(uc.NRGBA.R) + 0.587*float64(uc.NRGBA.G) + 0.114*float64(uc.NRGBA.B)) / 255 134 | t.UserColors[username] = uc 135 | return uc 136 | } 137 | 138 | // LocalUserColor returns a color for the "local" user. 139 | // Local user color is defined as the theme's surface color and it's luminance. 140 | func (t *Theme) LocalUserColor() UserColorData { 141 | c := t.Palette.Surface 142 | return UserColorData{ 143 | NRGBA: c, 144 | Luminance: (0.299*float64(c.R) + 0.587*float64(c.G) + 0.114*float64(c.B)) / 255, 145 | } 146 | } 147 | 148 | // Contrast against a given luminance. 149 | // 150 | // Defaults to a color that contrasts the background color, if the threshold 151 | // is met, the background color itself is returned. 152 | // 153 | // Note this will depend on the specific palette in question, and may not be a 154 | // good generalization particularly for low-contrast palettes. 155 | func (t *Theme) Contrast(luminance float64) color.NRGBA { 156 | var ( 157 | contrast = luminance < 0.5 158 | ) 159 | if t.Palette == Dark { 160 | contrast = luminance > 0.5 161 | } 162 | if contrast { 163 | return t.Palette.Bg 164 | } 165 | return t.Palette.OnBg 166 | } 167 | 168 | func rgb(c uint32) color.NRGBA { 169 | return argb(0xff000000 | c) 170 | } 171 | 172 | func argb(c uint32) color.NRGBA { 173 | return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} 174 | } 175 | -------------------------------------------------------------------------------- /example/kitchen/appwidget/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package appwidget provides domain-specific stateful chat widgets. 3 | */ 4 | package appwidget 5 | -------------------------------------------------------------------------------- /example/kitchen/appwidget/room.go: -------------------------------------------------------------------------------- 1 | package appwidget 2 | 3 | import ( 4 | "gioui.org/widget" 5 | chatwidget "git.sr.ht/~gioverse/chat/widget" 6 | ) 7 | 8 | // Room selector state. 9 | type Room struct { 10 | widget.Clickable 11 | Image chatwidget.CachedImage 12 | Active bool 13 | } 14 | -------------------------------------------------------------------------------- /example/kitchen/gen/message.go: -------------------------------------------------------------------------------- 1 | // Package gen implements data generators for the chat kitchen. 2 | package gen 3 | 4 | import ( 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "math" 9 | "math/rand" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "git.sr.ht/~gioverse/chat/example/kitchen/model" 15 | matchat "git.sr.ht/~gioverse/chat/widget/material" 16 | lorem "github.com/drhodes/golorem" 17 | "github.com/lucasb-eyer/go-colorful" 18 | ) 19 | 20 | // inflection point in the theoretical message timeline. 21 | // Messages with serial before inflection are older, and messages after it 22 | // are newer. 23 | const inflection = math.MaxInt64 / 2 24 | 25 | type Generator struct { 26 | // FetchImage callback fetches an image of the given size. 27 | FetchImage func(image.Point) image.Image 28 | // old is the serial counter for old messages. 29 | old syncInt 30 | // new is the serial counter for new messages. 31 | new syncInt 32 | } 33 | 34 | // GenUsers generates some random number of users between min and max. 35 | func (g *Generator) GenUsers(min, max int) *model.Users { 36 | return GenUsers(min, max, g.FetchImage) 37 | } 38 | 39 | // GenRooms generates some random number of rooms between min and max. 40 | func (g *Generator) GenRooms(min, max int) *model.Rooms { 41 | return GenRooms(min, max, g.FetchImage) 42 | } 43 | 44 | // GenHistoryMessage generates an old message that theoretically exists at 45 | // some point in history. 46 | func (g *Generator) GenHistoricMessage(user *model.User) model.Message { 47 | var ( 48 | serial = g.old.Increment() 49 | at = time.Now().Add(time.Hour * time.Duration(serial) * -1) 50 | ) 51 | // If we generate an empty string, the message will render as an image 52 | // instead of an empty message. 53 | return GenMessage(user, lorem.Paragraph(0, 5), inflection-serial, at, g.FetchImage) 54 | } 55 | 56 | // GenNewMessage generates a new message ready to be sent to the data model. 57 | func (g *Generator) GenNewMessage(user *model.User, content string) model.Message { 58 | return GenMessage(user, content, inflection+g.new.Increment(), time.Now(), nil) 59 | } 60 | 61 | // GenMessage generates a message with sensible defaults. 62 | func GenMessage( 63 | user *model.User, 64 | content string, 65 | serial int, 66 | at time.Time, 67 | fetchImage func(image.Point) image.Image, 68 | ) model.Message { 69 | return model.Message{ 70 | SerialID: fmt.Sprintf("%05d", serial), 71 | Sender: user.Name, 72 | Content: content, 73 | SentAt: at, 74 | Avatar: user.Avatar, 75 | Image: func() string { 76 | if fetchImage == nil { 77 | return "" 78 | } 79 | if content != "" { 80 | return "" 81 | } 82 | sizes := []image.Point{ 83 | image.Pt(1792, 828), 84 | image.Pt(828, 1792), 85 | image.Pt(600, 600), 86 | image.Pt(300, 300), 87 | } 88 | sz := sizes[rand.Intn(len(sizes))] 89 | return fmt.Sprintf("https://source.unsplash.com/random/%dx%d?nature", sz.X, sz.Y) 90 | }(), 91 | Read: func() bool { 92 | return serial < inflection 93 | }(), 94 | Status: func() string { 95 | if rand.Int()%10 == 0 { 96 | return matchat.FailedToSend 97 | } 98 | return "" 99 | }(), 100 | } 101 | } 102 | 103 | // GenUsers will generate a random number of fake users. 104 | func GenUsers(min, max int, fetchImage func(image.Point) image.Image) *model.Users { 105 | var ( 106 | users model.Users 107 | ) 108 | for ii := rand.Intn(max-min) + min; ii > 0; ii-- { 109 | users.Add(model.User{ 110 | Name: lorem.Word(4, 15), 111 | Theme: func() model.Theme { 112 | if rand.Float32() > 0.7 { 113 | if rand.Float32() > 0.5 { 114 | return model.ThemePlatoCookie 115 | } 116 | return model.ThemeHotdog 117 | } 118 | return model.ThemeEmpty 119 | }(), 120 | Avatar: fmt.Sprintf("https://source.unsplash.com/random/%dx%d?nature", 64, 64), 121 | Color: func() color.NRGBA { 122 | return ToNRGBA(colorful.FastHappyColor().Clamped()) 123 | }(), 124 | }) 125 | } 126 | return &users 127 | } 128 | 129 | // GenRooms generates a random number of rooms between min and max. 130 | func GenRooms(min, max int, fetchImage func(image.Point) image.Image) *model.Rooms { 131 | var rooms model.Rooms 132 | for ii := rand.Intn(max-min) + min; ii > 0; ii-- { 133 | rooms.Add(model.Room{ 134 | Name: strings.Trim(lorem.Sentence(1, 5), "."), 135 | Image: func() image.Image { 136 | if fetchImage == nil { 137 | return nil 138 | } 139 | return fetchImage(image.Pt(64, 64)) 140 | }(), 141 | }) 142 | } 143 | return &rooms 144 | } 145 | 146 | // syncInt is a synchronized integer. 147 | type syncInt struct { 148 | v int 149 | sync.Mutex 150 | } 151 | 152 | // Increment and return a copy of the underlying value. 153 | func (si *syncInt) Increment() int { 154 | var v int 155 | si.Lock() 156 | si.v++ 157 | v = si.v 158 | si.Unlock() 159 | return v 160 | } 161 | 162 | // ToNRGBA converts a colorful.Color to the nearest representable color.NRGBA. 163 | func ToNRGBA(c colorful.Color) color.NRGBA { 164 | r, g, b, a := c.RGBA() 165 | return color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: uint8(a)} 166 | } 167 | -------------------------------------------------------------------------------- /example/kitchen/main.go: -------------------------------------------------------------------------------- 1 | // Package kitchen demonstrates the various chat components and features. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "math/rand" 8 | "os" 9 | "time" 10 | 11 | _ "image/jpeg" 12 | 13 | "gioui.org/app" 14 | "gioui.org/io/system" 15 | "gioui.org/layout" 16 | "gioui.org/op" 17 | "gioui.org/unit" 18 | "git.sr.ht/~gioverse/chat/example/kitchen/ui" 19 | "git.sr.ht/~gioverse/chat/profile" 20 | ) 21 | 22 | var ( 23 | config ui.Config 24 | // profileOpt specifies what to profile. 25 | profileOpt string 26 | ) 27 | 28 | func init() { 29 | rand.Seed(time.Now().UnixNano()) 30 | flag.StringVar(&config.Theme, "theme", "light", "theme to use {light,dark}") 31 | flag.StringVar(&profileOpt, "profile", "none", "create the provided kind of profile. Use one of [none, cpu, mem, block, goroutine, mutex, trace, gio]") 32 | flag.BoolVar(&config.UsePlato, "plato", false, "use Plato Team Inc themed widgets") 33 | flag.IntVar(&config.Latency, "latency", 1000, "maximum latency (in millis) to simulate") 34 | flag.IntVar(&config.LoadSize, "load-size", 30, "number of items to load at a time") 35 | flag.IntVar(&config.BufferSize, "buffer-size", 30, "number of elements to hold in memory before compacting") 36 | 37 | flag.Parse() 38 | } 39 | 40 | type ( 41 | C = layout.Context 42 | D = layout.Dimensions 43 | ) 44 | 45 | func main() { 46 | var ( 47 | // Instantiate the chat window. 48 | w = app.NewWindow( 49 | app.Title("Chat"), 50 | app.Size(unit.Dp(800), unit.Dp(600)), 51 | ) 52 | // Define an operation list for gio. 53 | ops op.Ops 54 | // Instantiate our UI state. 55 | ui = ui.NewUI(w.Invalidate, config) 56 | ) 57 | go func() { 58 | profiler := profile.Opt(profileOpt).NewProfiler() 59 | profiler.Start() 60 | // Event loop executes indefinitely, until the app is signalled to quit. 61 | // Integrate external services here. 62 | for { 63 | select { 64 | case <-ui.Loader.Updated(): 65 | w.Invalidate() 66 | case event := <-w.Events(): 67 | switch event := event.(type) { 68 | case system.DestroyEvent: 69 | profiler.Stop() 70 | if err := event.Err; err != nil { 71 | fmt.Printf("error: premature window close: %v\n", err) 72 | os.Exit(1) 73 | } 74 | os.Exit(0) 75 | case system.FrameEvent: 76 | gtx := layout.NewContext(&ops, event) 77 | profiler.Record(gtx) 78 | ui.Layout(gtx) 79 | event.Frame(&ops) 80 | } 81 | } 82 | } 83 | }() 84 | // Surrender main thread to OS. 85 | // Necessary for certain platforms. 86 | app.Main() 87 | } 88 | -------------------------------------------------------------------------------- /example/kitchen/model/model.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package model provides the domain-specific data models for this list. 3 | */ 4 | package model 5 | 6 | import ( 7 | "image" 8 | "image/color" 9 | "math/rand" 10 | "sync" 11 | "time" 12 | 13 | "git.sr.ht/~gioverse/chat/list" 14 | ) 15 | 16 | // Message represents a chat message. 17 | type Message struct { 18 | SerialID string 19 | Sender, Content, Status string 20 | SentAt time.Time 21 | Image string 22 | Avatar string 23 | Read bool 24 | } 25 | 26 | // Serial returns the unique identifier for this message. 27 | func (m Message) Serial() list.Serial { 28 | return list.Serial(m.SerialID) 29 | } 30 | 31 | // DateBoundary represents a change in the date during a chat. 32 | type DateBoundary struct { 33 | Date time.Time 34 | } 35 | 36 | // Serial returns the unique identifier of the message. 37 | func (d DateBoundary) Serial() list.Serial { 38 | return list.NoSerial 39 | } 40 | 41 | // UnreadBoundary represents the boundary between the last read message 42 | // in a chat and the next unread message. 43 | type UnreadBoundary struct{} 44 | 45 | // Serial returns the unique identifier for the boundary. 46 | func (u UnreadBoundary) Serial() list.Serial { 47 | return list.NoSerial 48 | } 49 | 50 | // Room is a unique conversation context. 51 | // Room can have any number of participants, and any number of messages. 52 | // Any participant of a room should be able to view the room, send messages to 53 | // and recieve messages from the other participants. 54 | type Room struct { 55 | // Image avatar for the room. 56 | Image image.Image 57 | // Name of the room. 58 | Name string 59 | // Latest message in the room, if any. 60 | Latest *Message 61 | // Composing is a set of users in this room currently composing a message. 62 | Composing sync.Map 63 | } 64 | 65 | // SetComposing sets the composing status of a user for this room. 66 | func (r *Room) SetComposing(user string, isComposing bool) { 67 | if isComposing { 68 | r.Composing.Store(user, struct{}{}) 69 | } 70 | if !isComposing { 71 | r.Composing.Delete(user) 72 | } 73 | } 74 | 75 | // User is a unique identity that can send messages and participate in rooms. 76 | type User struct { 77 | // Name of user. 78 | Name string 79 | // Avatar is url to the image of the user. 80 | Avatar string 81 | // Theme specifies the name of a 9patch theme to use for messages from this 82 | // user. If theme is specified it will be the preferred message surface. 83 | // Empty string indicates no theme. 84 | Theme Theme 85 | // Color to use for message bubbles of messages from this user. 86 | Color color.NRGBA 87 | } 88 | 89 | // Users structure manages a collection of user data. 90 | type Users struct { 91 | list []User 92 | index map[string]*User 93 | once sync.Once 94 | } 95 | 96 | // Add user to collection. 97 | func (us *Users) Add(u User) { 98 | us.once.Do(func() { 99 | us.index = map[string]*User{} 100 | }) 101 | us.list = append(us.list, u) 102 | us.index[u.Name] = &us.list[len(us.list)-1] 103 | } 104 | 105 | // List returns an ordered list of user data. 106 | func (us *Users) List() (list []*User) { 107 | list = make([]*User, len(us.list)) 108 | for ii := range us.list { 109 | list[ii] = &us.list[ii] 110 | } 111 | return list 112 | } 113 | 114 | // Lookup user by name. 115 | func (us *Users) Lookup(name string) (*User, bool) { 116 | v, ok := us.index[name] 117 | return v, ok 118 | } 119 | 120 | // Random returns a randomly selected user from the collection. 121 | // If there are no users, nil is returned. 122 | func (us *Users) Random() *User { 123 | if len(us.list) == 0 { 124 | return nil 125 | } 126 | return &us.list[rand.Intn(len(us.list)-1)] 127 | } 128 | 129 | // Theme enumerates the various 9patch themes. 130 | type Theme int 131 | 132 | const ( 133 | ThemeEmpty Theme = iota 134 | ThemePlatoCookie 135 | ThemeHotdog 136 | ) 137 | 138 | // Rooms structure manages a collection of rooms. 139 | type Rooms struct { 140 | list []Room 141 | index map[string]*Room 142 | once sync.Once 143 | } 144 | 145 | // Add room to collection. 146 | func (r *Rooms) Add(room Room) { 147 | r.once.Do(func() { 148 | r.index = map[string]*Room{} 149 | }) 150 | r.list = append(r.list, room) 151 | r.index[room.Name] = &r.list[len(r.list)-1] 152 | } 153 | 154 | // List returns an ordered list of room data. 155 | func (r *Rooms) List() (list []*Room) { 156 | list = make([]*Room, len(r.list)) 157 | for ii := range r.list { 158 | list[ii] = &r.list[ii] 159 | } 160 | return list 161 | } 162 | 163 | // Lookup room by name. 164 | func (r *Rooms) Lookup(name string) (*Room, bool) { 165 | v, ok := r.index[name] 166 | return v, ok 167 | } 168 | 169 | // Random returns a randomly selected room from the collection. 170 | // If there are no rooms, nil is returned. 171 | func (r *Rooms) Random() *Room { 172 | if len(r.list) == 0 { 173 | return nil 174 | } 175 | return &r.list[rand.Intn(len(r.list)-1)] 176 | } 177 | -------------------------------------------------------------------------------- /example/kitchen/ui/benchmark_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | "testing" 6 | 7 | "gioui.org/gpu/headless" 8 | "gioui.org/io/router" 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | "gioui.org/unit" 12 | ) 13 | 14 | func BenchmarkKitchen(b *testing.B) { 15 | const scale = 2 16 | sz := image.Point{X: 800 * scale, Y: 600 * scale} 17 | w, err := headless.NewWindow(sz.X, sz.Y) 18 | if err != nil { 19 | b.Error(err) 20 | } 21 | ui := NewUI(func() {}, Config{ 22 | Theme: "light", 23 | LoadSize: 10, 24 | BufferSize: 100, 25 | }) 26 | gtx := layout.Context{ 27 | Ops: new(op.Ops), 28 | Metric: unit.Metric{ 29 | PxPerDp: scale, 30 | PxPerSp: scale, 31 | }, 32 | Constraints: layout.Exact(sz), 33 | Queue: new(router.Router), 34 | } 35 | b.ResetTimer() 36 | for i := 0; i < b.N; i++ { 37 | gtx.Ops.Reset() 38 | ui.Layout(gtx) 39 | w.Frame(gtx.Ops) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /example/kitchen/ui/icons.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "gioui.org/widget" 5 | "golang.org/x/exp/shiny/materialdesign/icons" 6 | ) 7 | 8 | var NavBack *widget.Icon = func() *widget.Icon { 9 | icon, _ := widget.NewIcon(icons.NavigationArrowBack) 10 | return icon 11 | }() 12 | 13 | var Send *widget.Icon = func() *widget.Icon { 14 | icon, _ := widget.NewIcon(icons.ContentSend) 15 | return icon 16 | }() 17 | -------------------------------------------------------------------------------- /example/kitchen/ui/room.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "math/rand" 5 | "sync" 6 | 7 | "gioui.org/widget" 8 | "git.sr.ht/~gioverse/chat/example/kitchen/appwidget" 9 | "git.sr.ht/~gioverse/chat/example/kitchen/model" 10 | "git.sr.ht/~gioverse/chat/list" 11 | ) 12 | 13 | // Rooms contains a selectable list of rooms. 14 | type Rooms struct { 15 | active int 16 | changed bool 17 | List []Room 18 | sync.Mutex 19 | } 20 | 21 | // Room is a unique conversation context. 22 | // Note(jfm): Allocates model and interact, not sure about that. 23 | // Avoids the UI needing to allocate two lists (interact/model) for the 24 | // rooms. 25 | type Room struct { 26 | // Room model defines the backend data describing a room. 27 | *model.Room 28 | // Interact defines the interactive state for a room widget. 29 | Interact appwidget.Room 30 | // Messages implements what would be a backend data model. 31 | // This would be the facade to your business api. 32 | // This is the source of truth. 33 | // This type gets asked to create messages and queried for message history. 34 | Messages *RowTracker 35 | // ListState dynamically manages list state. 36 | // This lets us surf across a vast ocean of infinite messages, only ever 37 | // rendering what is actualy viewable. 38 | // The widget.List consumes this during layout. 39 | ListState *list.Manager 40 | // List implements the raw scrolling, adding scrollbars and responding 41 | // to mousewheel / touch fling gestures. 42 | List widget.List 43 | // Editor contains the edit buffer for composing messages. 44 | Editor widget.Editor 45 | sync.Mutex 46 | } 47 | 48 | // SetComposing sets the composing status for a user in this room. 49 | // Note: doesn't actually verify the user pertains to this room. 50 | func (r *Room) SetComposing(user string, isComposing bool) { 51 | r.Room.SetComposing(user, isComposing) 52 | } 53 | 54 | // Send attempts to send arbitrary content as a message from the specified user. 55 | func (r *Room) Send(user, content string) { 56 | row := r.Messages.Send(user, content) 57 | r.Lock() 58 | r.Room.Latest = &row 59 | r.Unlock() 60 | go r.ListState.Modify([]list.Element{row}, nil, nil) 61 | } 62 | 63 | // SendLocal attempts to send the contents of the edit buffer as a 64 | // to the model. 65 | // All of the work of this method is dispatched in a new goroutine 66 | // so that it can safely be called from layout code without blocking. 67 | func (r *Room) SendLocal(msg string) { 68 | go func() { 69 | r.Lock() 70 | row := r.Messages.Send(r.Messages.Local.Name, msg) 71 | r.Room.Latest = &row 72 | r.Unlock() 73 | r.ListState.Modify([]list.Element{row}, nil, nil) 74 | }() 75 | } 76 | 77 | // NewRow generates a new row in the Room's RowTracker and inserts it 78 | // into the list manager for the room. 79 | func (r *Room) NewRow() { 80 | row := r.Messages.NewRow() 81 | go r.ListState.Modify([]list.Element{row}, nil, nil) 82 | } 83 | 84 | // DeleteRow removes the row with the provided serial from both the 85 | // row tracker and the list manager for the room. 86 | func (r *Room) DeleteRow(serial list.Serial) { 87 | r.Messages.Delete(serial) 88 | go r.ListState.Modify(nil, nil, []list.Serial{serial}) 89 | } 90 | 91 | // Active returns the active room, empty if not rooms are available. 92 | func (r *Rooms) Active() *Room { 93 | r.Lock() 94 | defer r.Unlock() 95 | if len(r.List) == 0 { 96 | return &Room{} 97 | } 98 | return &r.List[r.active] 99 | } 100 | 101 | // Latest returns a copy of the latest message for the room. 102 | func (r *Room) Latest() model.Message { 103 | r.Lock() 104 | defer r.Unlock() 105 | if r.Room.Latest == nil { 106 | return model.Message{} 107 | } 108 | return *r.Room.Latest 109 | } 110 | 111 | // Select the room at the given index. 112 | // Index is bounded by [0, len(rooms)). 113 | func (r *Rooms) Select(index int) { 114 | r.Lock() 115 | defer r.Unlock() 116 | if index < 0 { 117 | index = 0 118 | } 119 | if index > len(r.List) { 120 | index = len(r.List) - 1 121 | } 122 | r.changed = true 123 | r.List[r.active].Interact.Active = false 124 | r.active = index 125 | r.List[r.active].Interact.Active = true 126 | } 127 | 128 | // Changed if the active room has changed since last call. 129 | func (r *Rooms) Changed() bool { 130 | r.Lock() 131 | defer r.Unlock() 132 | defer func() { r.changed = false }() 133 | return r.changed 134 | } 135 | 136 | // Index returns a pointer to a Room at the given index. 137 | // Index is bounded by [0, len(rooms)). 138 | func (r *Rooms) Index(index int) *Room { 139 | r.Lock() 140 | defer r.Unlock() 141 | if index < 0 { 142 | index = 0 143 | } 144 | if index > len(r.List) { 145 | index = len(r.List) - 1 146 | } 147 | return &r.List[index] 148 | } 149 | 150 | // Index returns a pointer to a random Room in the list. 151 | func (r *Rooms) Random() *Room { 152 | r.Lock() 153 | defer r.Unlock() 154 | return &r.List[rand.Intn(len(r.List)-1)] 155 | } 156 | -------------------------------------------------------------------------------- /example/kitchen/ui/row-tracker.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "sort" 7 | "sync" 8 | "time" 9 | 10 | "git.sr.ht/~gioverse/chat/example/kitchen/gen" 11 | "git.sr.ht/~gioverse/chat/example/kitchen/model" 12 | "git.sr.ht/~gioverse/chat/list" 13 | lorem "github.com/drhodes/golorem" 14 | ) 15 | 16 | // RowTracker is a stand-in for an application's data access logic. 17 | // It stores a set of chat messages and can load them on request. 18 | // It simulates network latency during the load operations for 19 | // realism. 20 | type RowTracker struct { 21 | // SimulateLatency is the maximum latency in milliseconds to 22 | // simulate on loads. 23 | SimulateLatency int 24 | sync.Mutex 25 | Rows []list.Element 26 | SerialToIndex map[list.Serial]int 27 | Users *model.Users 28 | Local *model.User 29 | Generator *gen.Generator 30 | // MaxLoads specifies the number of elements a given load in either 31 | // direction can return. 32 | MaxLoads int 33 | ScrollToEnd bool 34 | } 35 | 36 | // NewExampleData constructs a RowTracker populated with the provided 37 | // quantity of messages. 38 | func NewExampleData(users *model.Users, local *model.User, g *gen.Generator, size int) *RowTracker { 39 | rt := &RowTracker{ 40 | SerialToIndex: make(map[list.Serial]int), 41 | Generator: g, 42 | Local: local, 43 | Users: users, 44 | } 45 | for i := 0; i < size; i++ { 46 | rt.Add(g.GenHistoricMessage(rt.Users.Random())) 47 | } 48 | return rt 49 | } 50 | 51 | // SendMessage adds the message to the data model. 52 | // This is analogous to interacting with the backend api. 53 | func (rt *RowTracker) Send(user, content string) model.Message { 54 | u, ok := rt.Users.Lookup(user) 55 | if !ok { 56 | return model.Message{} 57 | } 58 | msg := rt.Generator.GenNewMessage(u, content) 59 | rt.Add(msg) 60 | return msg 61 | } 62 | 63 | // Add a list element as a row of data to track. 64 | func (rt *RowTracker) Add(r list.Element) { 65 | rt.Lock() 66 | rt.Rows = append(rt.Rows, r) 67 | rt.reindex() 68 | rt.Unlock() 69 | } 70 | 71 | // Latest returns the latest element, or nil. 72 | func (r *RowTracker) Latest() list.Element { 73 | r.Lock() 74 | final := len(r.Rows) - 1 75 | // Unlock because index will lock again. 76 | r.Unlock() 77 | return r.Index(final) 78 | } 79 | 80 | // Index returns the element at the given index, or nil. 81 | func (r *RowTracker) Index(ii int) list.Element { 82 | r.Lock() 83 | defer r.Unlock() 84 | if len(r.Rows) == 0 || len(r.Rows) < ii { 85 | return nil 86 | } 87 | if ii < 0 { 88 | return r.Rows[0] 89 | } 90 | return r.Rows[ii] 91 | } 92 | 93 | // NewRow generates a new row. 94 | func (r *RowTracker) NewRow() list.Element { 95 | el := r.Generator.GenNewMessage(r.Users.Random(), lorem.Paragraph(1, 4)) 96 | r.Add(el) 97 | return el 98 | } 99 | 100 | // Load simulates loading chat history from a database or API. It 101 | // sleeps for a random number of milliseconds and then returns 102 | // some messages. 103 | func (r *RowTracker) Load(dir list.Direction, relativeTo list.Serial) (loaded []list.Element, more bool) { 104 | if r.SimulateLatency > 0 { 105 | duration := time.Millisecond * time.Duration(rand.Intn(r.SimulateLatency)) 106 | log.Println("sleeping", duration) 107 | time.Sleep(duration) 108 | } 109 | r.Lock() 110 | defer r.Unlock() 111 | defer func() { 112 | // Ensure the slice we return is backed by different memory than the underlying 113 | // RowTracker's slice, to avoid data races when the RowTracker sorts its storage. 114 | loaded = dupSlice(loaded) 115 | }() 116 | numRows := len(r.Rows) 117 | if relativeTo == list.NoSerial { 118 | // If loading relative to nothing, likely the chat interface is empty. 119 | // We should load the most recent messages first in this case, regardless 120 | // of the direction parameter. 121 | if r.ScrollToEnd { 122 | return r.Rows[numRows-min(r.MaxLoads, numRows):], numRows > r.MaxLoads 123 | } else { 124 | var res int 125 | if numRows < r.MaxLoads { 126 | res = numRows 127 | } else { 128 | res = r.MaxLoads 129 | } 130 | return r.Rows[:res], numRows > r.MaxLoads 131 | } 132 | } 133 | idx := r.SerialToIndex[relativeTo] 134 | if dir == list.After { 135 | end := min(numRows, idx+r.MaxLoads) 136 | return r.Rows[idx+1 : end], end < len(r.Rows)-1 137 | } 138 | start := maximum(0, idx-r.MaxLoads) 139 | return r.Rows[start:idx], start > 0 140 | } 141 | 142 | // Delete removes the element with the provided serial from storage. 143 | func (r *RowTracker) Delete(serial list.Serial) { 144 | r.Lock() 145 | defer r.Unlock() 146 | idx := r.SerialToIndex[serial] 147 | sliceRemove(&r.Rows, idx) 148 | r.reindex() 149 | } 150 | 151 | func (r *RowTracker) reindex() { 152 | sort.Slice(r.Rows, func(i, j int) bool { 153 | return rowLessThan(r.Rows[i], r.Rows[j]) 154 | }) 155 | r.SerialToIndex = make(map[list.Serial]int) 156 | for i, row := range r.Rows { 157 | r.SerialToIndex[row.Serial()] = i 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /example/kitchen/ui/util.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "image/draw" 8 | _ "image/jpeg" 9 | _ "image/png" 10 | "io" 11 | "io/fs" 12 | "io/ioutil" 13 | "math/rand" 14 | "net/http" 15 | "os" 16 | "path/filepath" 17 | "strconv" 18 | 19 | "gioui.org/app" 20 | "git.sr.ht/~gioverse/chat/list" 21 | ) 22 | 23 | // randomImage returns a random image at the given size. 24 | // Downloads some number of random images from unplash and caches them on disk. 25 | // 26 | // TODO(jfm) [performance]: download images concurrently (parallel downloads, 27 | // async to the gui event loop). 28 | func randomImage(sz image.Point) (image.Image, error) { 29 | mkCacheDir := func(base string) string { 30 | return filepath.Join(base, "chat", fmt.Sprintf("%dx%d", sz.X, sz.Y)) 31 | } 32 | cache := mkCacheDir(os.TempDir()) 33 | if err := os.MkdirAll(cache, 0755); err != nil { 34 | if !errors.Is(err, os.ErrPermission) { 35 | return nil, fmt.Errorf("preparing cache directory: %w", err) 36 | } 37 | dir, err := app.DataDir() 38 | if err != nil { 39 | return nil, fmt.Errorf("failed finding application data dir: %w", err) 40 | } 41 | cache = mkCacheDir(dir) 42 | if err := os.MkdirAll(cache, 0755); err != nil { 43 | return nil, fmt.Errorf("preparing fallback cache directory: %w", err) 44 | } 45 | } 46 | entries, err := ioutil.ReadDir(cache) 47 | if err != nil { 48 | return nil, fmt.Errorf("reading cache entries: %w", err) 49 | } 50 | entries = filter(entries, isFile) 51 | if len(entries) == 0 { 52 | for ii := 0; ii < 10; ii++ { 53 | ii := ii 54 | if err := func() error { 55 | r, err := http.Get(fmt.Sprintf("https://source.unsplash.com/random/%dx%d?nature", sz.X, sz.Y)) 56 | if err != nil { 57 | return fmt.Errorf("fetching image data: %w", err) 58 | } 59 | defer r.Body.Close() 60 | imgf, err := os.Create(filepath.Join(cache, strconv.Itoa(ii))) 61 | if err != nil { 62 | return fmt.Errorf("creating image file on disk: %w", err) 63 | } 64 | defer imgf.Close() 65 | if _, err := io.Copy(imgf, r.Body); err != nil { 66 | return fmt.Errorf("downloading image: %w", err) 67 | } 68 | return nil 69 | }(); err != nil { 70 | return nil, fmt.Errorf("populating image cache: %w", err) 71 | } 72 | } 73 | return randomImage(sz) 74 | } 75 | selection := entries[rand.Intn(len(entries))] 76 | imgf, err := os.Open(filepath.Join(cache, selection.Name())) 77 | if err != nil { 78 | return nil, fmt.Errorf("opening image file: %w", err) 79 | } 80 | defer imgf.Close() 81 | img, _, err := image.Decode(imgf) 82 | if err != nil { 83 | return nil, fmt.Errorf("decoding image: %w", err) 84 | } 85 | // Copy the image into a GPU friendly format. 86 | dst := image.NewRGBA(image.Rectangle{ 87 | Max: img.Bounds().Size(), 88 | }) 89 | draw.Draw(dst, dst.Bounds(), img, img.Bounds().Min, draw.Src) 90 | 91 | return dst, nil 92 | } 93 | 94 | // isFile filters out non-file entries. 95 | func isFile(info fs.FileInfo) bool { 96 | return !info.IsDir() 97 | } 98 | 99 | func filter(list []fs.FileInfo, predicate func(fs.FileInfo) bool) (filtered []fs.FileInfo) { 100 | for _, item := range list { 101 | if predicate(item) { 102 | filtered = append(filtered, item) 103 | } 104 | } 105 | return filtered 106 | } 107 | 108 | // dupSlice returns a slice composed of the same elements in the same order, 109 | // but backed by a different array. 110 | func dupSlice(in []list.Element) []list.Element { 111 | out := make([]list.Element, len(in)) 112 | for i := range in { 113 | out[i] = in[i] 114 | } 115 | return out 116 | } 117 | 118 | // sliceRemove takes the given index of a slice and swaps it with the final 119 | // index in the slice, then shortens the slice by one element. This hides 120 | // the element at index from the slice, though it does not erase its data. 121 | func sliceRemove(s *[]list.Element, index int) { 122 | lastIndex := len(*s) - 1 123 | (*s)[index], (*s)[lastIndex] = (*s)[lastIndex], (*s)[index] 124 | *s = (*s)[:lastIndex] 125 | } 126 | 127 | func maximum(a, b int) int { 128 | if a > b { 129 | return a 130 | } 131 | return b 132 | } 133 | 134 | func min(a, b int) int { 135 | if a < b { 136 | return a 137 | } 138 | return b 139 | } 140 | 141 | // fetch image for the given id. 142 | // Image is initially downloaded from the provided url and stored on disk. 143 | func fetch(id, u string) (image.Image, error) { 144 | path := filepath.Join(os.TempDir(), "chat", "resources", id) 145 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 146 | return nil, fmt.Errorf("preparing resource directory: %w", err) 147 | } 148 | if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { 149 | if err := func() error { 150 | f, err := os.Create(path) 151 | if err != nil { 152 | return fmt.Errorf("creating resource file: %w", err) 153 | } 154 | defer f.Close() 155 | r, err := http.Get(u) 156 | if err != nil { 157 | return fmt.Errorf("GET: %w", err) 158 | } 159 | defer r.Body.Close() 160 | if r.StatusCode != http.StatusOK { 161 | return fmt.Errorf("GET: %s", r.Status) 162 | } 163 | if _, err := io.Copy(f, r.Body); err != nil { 164 | return fmt.Errorf("downloading resource to disk: %w", err) 165 | } 166 | return nil 167 | }(); err != nil { 168 | return nil, err 169 | } 170 | } 171 | f, err := os.Open(path) 172 | if err != nil { 173 | return nil, fmt.Errorf("opening resource file: %w", err) 174 | } 175 | defer f.Close() 176 | img, _, err := image.Decode(f) 177 | if err != nil { 178 | return nil, fmt.Errorf("decoding image: %w", err) 179 | } 180 | // Copy the image into a GPU friendly format. 181 | dst := image.NewRGBA(image.Rectangle{ 182 | Max: img.Bounds().Size(), 183 | }) 184 | draw.Draw(dst, dst.Bounds(), img, img.Bounds().Min, draw.Src) 185 | 186 | return dst, nil 187 | } 188 | -------------------------------------------------------------------------------- /example/ninepatch/main.go: -------------------------------------------------------------------------------- 1 | // Package example is a playground for toying with and showcasing 9-Patch. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "math" 10 | "os" 11 | 12 | "gioui.org/app" 13 | "gioui.org/font/gofont" 14 | "gioui.org/io/system" 15 | "gioui.org/layout" 16 | "gioui.org/op" 17 | "gioui.org/unit" 18 | "gioui.org/widget" 19 | "gioui.org/widget/material" 20 | "gioui.org/x/component" 21 | "git.sr.ht/~gioverse/chat/example/kitchen/appwidget/apptheme" 22 | "git.sr.ht/~gioverse/chat/ninepatch" 23 | "git.sr.ht/~gioverse/chat/res" 24 | lorem "github.com/drhodes/golorem" 25 | ) 26 | 27 | func main() { 28 | var ( 29 | // Instantiate the chat window. 30 | w = app.NewWindow( 31 | app.Title("9-Patch"), 32 | app.Size(unit.Dp(800), unit.Dp(600)), 33 | ) 34 | // Define an operation list for gio. 35 | ops op.Ops 36 | // Instantiate our UI state. 37 | ui = NewUI() 38 | ) 39 | 40 | go func() { 41 | // Event loop executes indefinitely, until the app is signalled to quit. 42 | // Integrate external services here. 43 | for event := range w.Events() { 44 | switch event := event.(type) { 45 | case system.DestroyEvent: 46 | if err := event.Err; err != nil { 47 | fmt.Printf("error: premature window close: %v\n", err) 48 | os.Exit(1) 49 | } 50 | os.Exit(0) 51 | case system.FrameEvent: 52 | ui.Layout(layout.NewContext(&ops, event)) 53 | event.Frame(&ops) 54 | } 55 | } 56 | }() 57 | // Surrender main thread to OS. 58 | // Necessary for certain platforms. 59 | app.Main() 60 | } 61 | 62 | type ( 63 | C = layout.Context 64 | D = layout.Dimensions 65 | ) 66 | 67 | // th is the active theme object. 68 | var ( 69 | fonts = gofont.Collection() 70 | th = apptheme.NewTheme(fonts) 71 | ) 72 | 73 | // UI manages the state for the entire application's UI. 74 | type UI struct { 75 | // Toggles patch visibility. 76 | Toggles []struct { 77 | Name string 78 | widget.Bool 79 | } 80 | // Messages available to the UI. 81 | Messages map[string]*FauxMessage 82 | // Visible messages to render (by name). 83 | Visible []string 84 | // Content controls the content dimensions. 85 | Content struct { 86 | Width widget.Float 87 | Height widget.Float 88 | } 89 | // Constraints controls the constraints to render the 9-Patch with. 90 | Constraints struct { 91 | X widget.Float 92 | Y widget.Float 93 | } 94 | // TextContent controls whether to simulate text content. 95 | TextContent widget.Bool 96 | // TextAmount controls the amount of text to display. 97 | TextAmount widget.Float 98 | // PxPerDp controls the px-dp ratio to simulate different screen densities. 99 | PxPerDp widget.Float 100 | // ControlContainer adds scrolling to the controls. 101 | ControlContainer widget.List 102 | // DemoContainer adds scrolling to the demo. 103 | DemoContainer widget.List 104 | } 105 | 106 | // NewUI constructs a UI and populates it with dummy data. 107 | func NewUI() *UI { 108 | return &UI{ 109 | Messages: map[string]*FauxMessage{ 110 | "platocookie": { 111 | Text: lorem.Sentence(1, 5), 112 | Surface: func() ninepatch.NinePatch { 113 | imgf, err := res.Resources.Open("9-Patch/iap_platocookie_asset_2.png") 114 | if err != nil { 115 | panic(fmt.Errorf("opening image: %w", err)) 116 | } 117 | defer imgf.Close() 118 | img, err := png.Decode(imgf) 119 | if err != nil { 120 | panic(fmt.Errorf("decoding png: %w", err)) 121 | } 122 | return ninepatch.DecodeNinePatch(img) 123 | }(), 124 | }, 125 | "hotdog": { 126 | Text: lorem.Sentence(1, 5), 127 | Surface: func() ninepatch.NinePatch { 128 | imgf, err := res.Resources.Open("9-Patch/iap_hotdog_asset.png") 129 | if err != nil { 130 | panic(fmt.Errorf("opening image: %w", err)) 131 | } 132 | defer imgf.Close() 133 | img, err := png.Decode(imgf) 134 | if err != nil { 135 | panic(fmt.Errorf("decoding png: %w", err)) 136 | } 137 | return ninepatch.DecodeNinePatch(img) 138 | }(), 139 | }, 140 | }, 141 | Toggles: []struct { 142 | Name string 143 | widget.Bool 144 | }{ 145 | {Name: "platocookie", Bool: widget.Bool{Value: true}}, 146 | {Name: "hotdog", Bool: widget.Bool{Value: false}}, 147 | }, 148 | Visible: make([]string, 2), 149 | Constraints: struct { 150 | X widget.Float 151 | Y widget.Float 152 | }{ 153 | X: widget.Float{Value: 250}, 154 | Y: widget.Float{Value: 250}, 155 | }, 156 | TextContent: widget.Bool{Value: true}, 157 | PxPerDp: widget.Float{Value: 1.0}, 158 | } 159 | } 160 | 161 | // Layout the application UI. 162 | func (ui *UI) Layout(gtx C) D { 163 | for ii := range ui.Toggles { 164 | t := ui.Toggles[ii] 165 | if t.Bool.Value { 166 | ui.Visible[ii] = t.Name 167 | } else { 168 | ui.Visible[ii] = "" 169 | } 170 | } 171 | if ui.TextAmount.Changed() { 172 | for _, msg := range ui.Messages { 173 | words := int(ui.TextAmount.Value) 174 | msg.Text = lorem.Sentence(words, words) 175 | } 176 | } 177 | if ui.Content.Width.Value > ui.Constraints.X.Value { 178 | ui.Content.Width.Value = ui.Constraints.X.Value 179 | } 180 | if ui.Content.Height.Value > ui.Constraints.Y.Value { 181 | ui.Content.Height.Value = ui.Constraints.Y.Value 182 | } 183 | return layout.Flex{ 184 | Axis: layout.Vertical, 185 | }.Layout(gtx, 186 | layout.Rigid(ui.layoutTitle), 187 | layout.Rigid(ui.layoutContent), 188 | ) 189 | } 190 | 191 | func (ui *UI) layoutTitle(gtx C) D { 192 | return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D { 193 | return layout.Center.Layout(gtx, func(gtx C) D { 194 | return material.H4(th.Theme, "9-Patch Demo").Layout(gtx) 195 | }) 196 | }) 197 | } 198 | 199 | // breakpoint specifies dp at which to switch layout from horizontal to vertical. 200 | // 450 is hopefully sane. 201 | const breakpoint = 450 202 | 203 | // layoutContent lays out the controls and demo area. 204 | // 205 | // It uses a horizontal layout for large constraints and a vertical layout 206 | // for small constraints. 207 | func (ui *UI) layoutContent(gtx C) D { 208 | var ( 209 | axis = layout.Vertical 210 | ) 211 | if gtx.Constraints.Max.X > gtx.Dp(unit.Dp(breakpoint)) { 212 | axis = layout.Horizontal 213 | } 214 | return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D { 215 | return layout.Flex{ 216 | Axis: axis, 217 | }.Layout(gtx, 218 | layout.Flexed(1, func(gtx C) D { 219 | gtx.Constraints.Max.X = gtx.Dp(unit.Dp(400)) 220 | ui.ControlContainer.Axis = layout.Vertical 221 | return material.List(th.Theme, &ui.ControlContainer).Layout(gtx, 1, func(gtx C, _ int) D { 222 | return layout.E.Layout(gtx, func(gtx C) D { 223 | return layout.UniformInset(unit.Dp(20)).Layout(gtx, ui.layoutControls) 224 | }) 225 | }) 226 | }), 227 | layout.Rigid(func(gtx C) D { 228 | if axis == layout.Horizontal { 229 | return D{} 230 | } 231 | return layout.Inset{ 232 | Top: unit.Dp(10), 233 | Bottom: unit.Dp(10), 234 | }.Layout(gtx, func(gtx C) D { 235 | return component.Divider(th.Theme).Layout(gtx) 236 | }) 237 | }), 238 | layout.Flexed(1, func(gtx C) D { 239 | ui.DemoContainer.Axis = layout.Vertical 240 | return material.List(th.Theme, &ui.DemoContainer).Layout(gtx, 1, func(gtx C, _ int) D { 241 | return layout.Center.Layout(gtx, func(gtx C) D { 242 | return layout.UniformInset(unit.Dp(20)).Layout(gtx, ui.layoutDemo) 243 | }) 244 | }) 245 | }), 246 | ) 247 | }) 248 | } 249 | 250 | func (ui *UI) layoutControls(gtx C) D { 251 | return layout.Flex{ 252 | Axis: layout.Vertical, 253 | }.Layout( 254 | gtx, 255 | layout.Rigid(func(gtx C) D { 256 | var items []layout.FlexChild 257 | for ii := range ui.Toggles { 258 | toggle := &ui.Toggles[ii] 259 | items = append(items, layout.Rigid(func(gtx C) D { 260 | return material.CheckBox(th.Theme, &toggle.Bool, toggle.Name).Layout(gtx) 261 | })) 262 | } 263 | return layout.Flex{ 264 | Axis: layout.Horizontal, 265 | Alignment: layout.Middle, 266 | Spacing: layout.SpaceSides, 267 | }.Layout(gtx, items...) 268 | }), 269 | // Layout constraint sliders. 270 | layout.Rigid(func(gtx C) D { 271 | px, dp := DP(gtx.Metric.PxPerDp, ui.Constraints.X.Value) 272 | return LabeledSliderStyle{ 273 | Label: material.Body1(th.Theme, fmt.Sprintf("X Constraint: %d (%f)", px, dp)), 274 | Slider: material.Slider(th.Theme, &ui.Constraints.X, 0, 700), 275 | }.Layout(gtx) 276 | }), 277 | layout.Rigid(func(gtx C) D { 278 | px, dp := DP(gtx.Metric.PxPerDp, ui.Constraints.Y.Value) 279 | return LabeledSliderStyle{ 280 | Label: material.Body1(th.Theme, fmt.Sprintf("Y Constraint: %d (%f)", px, dp)), 281 | Slider: material.Slider(th.Theme, &ui.Constraints.Y, 0, 700), 282 | }.Layout(gtx) 283 | }), 284 | // Layout content sliders. 285 | layout.Rigid(func(gtx C) D { 286 | px, dp := DP(gtx.Metric.PxPerDp, ui.Content.Width.Value) 287 | return LabeledSliderStyle{ 288 | Label: material.Body1(th.Theme, fmt.Sprintf("Content Width: %d (%f)", px, dp)), 289 | Slider: material.Slider(th.Theme, &ui.Content.Width, 0, 700), 290 | }.Layout(gtx) 291 | }), 292 | layout.Rigid(func(gtx C) D { 293 | px, dp := DP(gtx.Metric.PxPerDp, ui.Content.Height.Value) 294 | return LabeledSliderStyle{ 295 | Label: material.Body1(th.Theme, fmt.Sprintf("Content Height: %d (%f)", px, dp)), 296 | Slider: material.Slider(th.Theme, &ui.Content.Height, 0, 700), 297 | }.Layout(gtx) 298 | }), 299 | layout.Rigid(func(gtx C) D { 300 | return LabeledSliderStyle{ 301 | Label: material.Body1(th.Theme, fmt.Sprintf("PxPerDp: %.2f (default: %.2f)", ui.PxPerDp.Value, gtx.Metric.PxPerDp)), 302 | Slider: material.Slider(th.Theme, &ui.PxPerDp, 0.3, 20), 303 | }.Layout(gtx) 304 | }), 305 | layout.Rigid(func(gtx C) D { 306 | return LabeledSliderStyle{ 307 | Label: material.Body1(th.Theme, "Text Amount"), 308 | Slider: material.Slider(th.Theme, &ui.TextAmount, 0, 700), 309 | }.Layout(gtx) 310 | }), 311 | layout.Rigid(func(gtx C) D { 312 | return layout.Flex{ 313 | Axis: layout.Horizontal, 314 | Alignment: layout.Middle, 315 | }.Layout( 316 | gtx, 317 | layout.Rigid(func(gtx C) D { 318 | return material.Body1(th.Theme, "Show Text").Layout(gtx) 319 | }), 320 | layout.Rigid(func(gtx C) D { 321 | return D{Size: image.Point{X: gtx.Dp(unit.Dp(10))}} 322 | }), 323 | layout.Rigid(func(gtx C) D { 324 | return material.Switch(th.Theme, &ui.TextContent, "Show Text").Layout(gtx) 325 | }), 326 | ) 327 | }), 328 | ) 329 | } 330 | 331 | func (ui *UI) layoutDemo(gtx C) D { 332 | var items []layout.FlexChild 333 | for ii := range ui.Visible { 334 | msg, ok := ui.Messages[ui.Visible[ii]] 335 | if !ok { 336 | continue 337 | } 338 | items = append(items, layout.Flexed(1, func(gtx C) D { 339 | cs := >x.Constraints 340 | cs.Max.X = int(ui.Constraints.X.Value) 341 | cs.Max.Y = int(ui.Constraints.Y.Value) 342 | return widget.Border{ 343 | Color: color.NRGBA{A: 200}, 344 | Width: unit.Dp(1), 345 | }.Layout(gtx, func(gtx C) D { 346 | return layout.Stack{}.Layout( 347 | gtx, 348 | layout.Stacked(func(gtx C) D { 349 | return D{Size: gtx.Constraints.Max} 350 | }), 351 | layout.Expanded(func(gtx C) D { 352 | gtx.Constraints.Min.X = int(ui.Content.Width.Value) 353 | gtx.Constraints.Min.Y = int(ui.Content.Height.Value) 354 | gtx.Metric.PxPerDp = ui.PxPerDp.Value 355 | return NewMessage(th.Theme, msg, &ui.TextContent).Layout(gtx) 356 | }), 357 | ) 358 | }) 359 | })) 360 | } 361 | return layout.Flex{ 362 | Axis: layout.Vertical, 363 | Alignment: layout.Middle, 364 | }.Layout(gtx, items...) 365 | } 366 | 367 | // FauxMessage contains state needed to layout a fake message. 368 | type FauxMessage struct { 369 | Text string 370 | Surface ninepatch.NinePatch 371 | } 372 | 373 | // MessageStyle lays a message content atop a ninepatch surface. 374 | type MessageStyle struct { 375 | *FauxMessage 376 | Content layout.Widget 377 | } 378 | 379 | // NewMessage constructs a MessageStyle. 380 | func NewMessage(th *material.Theme, msg *FauxMessage, showText *widget.Bool) MessageStyle { 381 | content := func(gtx C) D { 382 | lb := material.Body1(th, msg.Text) 383 | lb.Color = th.ContrastFg 384 | return lb.Layout(gtx) 385 | } 386 | if !showText.Value { 387 | content = func(gtx C) D { 388 | return component.Rect{ 389 | Color: color.NRGBA{G: 200, A: 200}, 390 | Size: gtx.Constraints.Min, 391 | }.Layout(gtx) 392 | } 393 | } 394 | return MessageStyle{ 395 | FauxMessage: msg, 396 | Content: content, 397 | } 398 | } 399 | 400 | func (msg MessageStyle) Layout(gtx C) D { 401 | return msg.Surface.Layout(gtx, func(gtx C) D { 402 | return msg.Content(gtx) 403 | }) 404 | } 405 | 406 | // LabeledSliderStyle draws a slider with a label. 407 | type LabeledSliderStyle struct { 408 | Label material.LabelStyle 409 | Slider material.SliderStyle 410 | } 411 | 412 | func (slider LabeledSliderStyle) Layout(gtx C) D { 413 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 414 | return layout.Flex{ 415 | Axis: layout.Vertical, 416 | }.Layout( 417 | gtx, 418 | layout.Rigid(slider.Label.Layout), 419 | layout.Rigid(slider.Slider.Layout), 420 | ) 421 | } 422 | 423 | // DP helper computes the dp given some pixels and the ratio of pixels per dp. 424 | // 425 | // Note: This helper is for display purposes. Pixels are rounded for clarity, 426 | // therefore do not use results as "real" units in layout. 427 | func DP(pixelperdp float32, pixels float32) (px int, dp unit.Dp) { 428 | pixels = float32(math.Round(float64(pixels))) 429 | return int(pixels), unit.Dp(pixels / pixelperdp) 430 | } 431 | -------------------------------------------------------------------------------- /example/unconfigured/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | /* 6 | WARNING 7 | 8 | This example exists to display the default behavior of an 9 | unconfigured list.Manager. All real applications will need 10 | to supply a value for each of the hooks in list.Hooks, so 11 | this is not a good example for how to correctly use the 12 | list.Manager type. For actually good examples look at the 13 | other programs in the example folder. 14 | */ 15 | 16 | import ( 17 | "log" 18 | "os" 19 | 20 | "gioui.org/app" 21 | "gioui.org/io/system" 22 | "gioui.org/layout" 23 | "gioui.org/op" 24 | "gioui.org/widget" 25 | "gioui.org/widget/material" 26 | "git.sr.ht/~gioverse/chat/list" 27 | 28 | "gioui.org/font/gofont" 29 | ) 30 | 31 | func main() { 32 | go func() { 33 | w := app.NewWindow() 34 | if err := loop(w); err != nil { 35 | log.Fatal(err) 36 | } 37 | os.Exit(0) 38 | }() 39 | app.Main() 40 | } 41 | 42 | func loop(w *app.Window) error { 43 | th := material.NewTheme(gofont.Collection()) 44 | l := widget.List{List: layout.List{Axis: layout.Vertical}} 45 | m := list.NewManager(100, list.DefaultHooks(w, th)) 46 | var ops op.Ops 47 | for { 48 | e := <-w.Events() 49 | switch e := e.(type) { 50 | case system.DestroyEvent: 51 | return e.Err 52 | case system.FrameEvent: 53 | gtx := layout.NewContext(&ops, e) 54 | material.List(th, &l).Layout(gtx, m.UpdatedLen(&l.List), m.Layout) 55 | e.Frame(gtx.Ops) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module git.sr.ht/~gioverse/chat 2 | 3 | go 1.18 4 | 5 | require ( 6 | gioui.org v0.0.0-20220830130127-276b7eefdd65 7 | gioui.org/x v0.0.0-20220906202049-eecc69e4bc4c 8 | github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9 9 | github.com/lucasb-eyer/go-colorful v1.2.0 10 | github.com/pkg/profile v1.6.0 11 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 12 | ) 13 | 14 | require ( 15 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect 16 | gioui.org/shader v1.0.6 // indirect 17 | github.com/benoitkugler/textlayout v0.1.3 // indirect 18 | github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d // indirect 19 | github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b // indirect 20 | golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect 21 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect 22 | golang.org/x/text v0.3.7 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3 h1:djFprmHZgrSepsHAIRMp5UJn3PzsoTg9drI+BDmif5Q= 2 | gioui.org v0.0.0-20220830130127-276b7eefdd65 h1:mX+A86TwTyHZNqDxekUukiAmtYNUOq4CnrRZHxUrlo8= 3 | gioui.org v0.0.0-20220830130127-276b7eefdd65/go.mod h1:GN091SCcGAfHfQiSOetXx7Abdy+8nmONj0ZN63Xxf7w= 4 | gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 5 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= 6 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 7 | gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y= 8 | gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 9 | gioui.org/x v0.0.0-20220906202049-eecc69e4bc4c h1:58sMQoJ1YUYasBc3Hbe8wSPJnPOiGWtyh/ErbrAY/wc= 10 | gioui.org/x v0.0.0-20220906202049-eecc69e4bc4c/go.mod h1:uhVlN625ysZfFEbEdxt+wgyCGW2sh5p6SYs9dr+w+u4= 11 | github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE= 12 | github.com/benoitkugler/textlayout v0.0.5/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8= 13 | github.com/benoitkugler/textlayout v0.1.3 h1:Jv0E28xDkke3KrWle90yOLtBmZsUqXLBy70lZRfbKN0= 14 | github.com/benoitkugler/textlayout v0.1.3/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w= 15 | github.com/benoitkugler/textlayout-testdata v0.1.1 h1:AvFxBxpfrQd8v55qH59mZOJOQjtD6K2SFe9/HvnIbJk= 16 | github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9 h1:EQOZw/LCQ0SM4sNez3EhUf9gQalQrLrs4mPtmQa+d58= 17 | github.com/drhodes/golorem v0.0.0-20160418191928-ecccc744c2d9/go.mod h1:NsKVpF4h4j13Vm6Cx7Kf0V03aJKjfaStvm5rvK4+FyQ= 18 | github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d h1:ro1W5kY1pVBLHy4GokZUfr9cl7ewZhAiT5WsXqFDYE4= 19 | github.com/gioui/uax v0.2.1-0.20220819135011-cda973fac06d/go.mod h1:b6uGh9ySJPVQG/RdiI88bE5sUGDk6vzzRujv1BAeuJc= 20 | github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b h1:WINlj3ANt+CVrO2B4NGDHRlPvEWZPxjhb7z+JKypwXI= 21 | github.com/go-text/typesetting v0.0.0-20220411150340-35994bc27a7b/go.mod h1:ZNYu5saGoMOqtkVH5T8onTwhzenDUVszI+5WFHJRaxQ= 22 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 23 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 24 | github.com/pkg/profile v1.6.0 h1:hUDfIISABYI59DyeB3OTay/HxSRwTQ8rB/H83k6r5dM= 25 | github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= 26 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0= 27 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8= 28 | golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 29 | golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= 30 | golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU= 31 | golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= 32 | golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 33 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM= 36 | golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 38 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 39 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 40 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 41 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 42 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 43 | -------------------------------------------------------------------------------- /layout/background.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "image/color" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/x/component" 9 | ) 10 | 11 | // Background lays out a widget over a colored background. 12 | type Background color.NRGBA 13 | 14 | func (bg Background) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { 15 | macro := op.Record(gtx.Ops) 16 | dims := w(gtx) 17 | call := macro.Stop() 18 | return layout.Stack{}.Layout( 19 | gtx, 20 | layout.Expanded(component.Rect{ 21 | Size: dims.Size, 22 | Color: color.NRGBA(bg), 23 | }.Layout), 24 | layout.Stacked(func(gtx layout.Context) layout.Dimensions { 25 | call.Add(gtx.Ops) 26 | return dims 27 | }), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /layout/gutter.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | ) 7 | 8 | // GutterStyle configures a gutter on either side of a horizontal row of content. 9 | // Both sides can optionally display a widget atop the gutter space. 10 | type GutterStyle struct { 11 | LeftWidth unit.Dp 12 | RightWidth unit.Dp 13 | layout.Alignment 14 | } 15 | 16 | // Gutter returns a GutterStyle with a narrow left gutter and a wide right gutter. 17 | func Gutter() GutterStyle { 18 | return GutterStyle{ 19 | LeftWidth: unit.Dp(12), 20 | RightWidth: unit.Dp(60), 21 | Alignment: layout.Middle, 22 | } 23 | } 24 | 25 | // Layout the gutter with the left and right widgets laid out atop the gutter areas 26 | // and the center widget in the remaining space in between. Left or right may be 27 | // provided as nil to indicate that nothing should be displayed in the gutter. 28 | func (g GutterStyle) Layout(gtx layout.Context, left, center, right layout.Widget) layout.Dimensions { 29 | return layout.Flex{ 30 | Alignment: g.Alignment, 31 | }.Layout(gtx, 32 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 33 | return layoutGutterSide(gtx, g.LeftWidth, left) 34 | }), 35 | layout.Flexed(1, center), 36 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 37 | return layoutGutterSide(gtx, g.RightWidth, right) 38 | }), 39 | ) 40 | } 41 | 42 | // layoutGutterSide lays out a spacer with a given width, and stacks another 43 | // widget on top. 44 | func layoutGutterSide(gtx layout.Context, width unit.Dp, widget layout.Widget) layout.Dimensions { 45 | spacer := layout.Spacer{Width: width} 46 | if widget == nil { 47 | return spacer.Layout(gtx) 48 | } 49 | return layout.Stack{}.Layout(gtx, 50 | layout.Stacked(func(gtx layout.Context) layout.Dimensions { 51 | return layout.Spacer{Width: width}.Layout(gtx) 52 | }), 53 | layout.Expanded(func(gtx layout.Context) layout.Dimensions { 54 | return widget(gtx) 55 | }), 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /layout/reverse.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "gioui.org/layout" 5 | ) 6 | 7 | // Reverse the order of the provided flex children if the boolean is true. 8 | func Reverse(shouldReverse bool, items ...layout.FlexChild) []layout.FlexChild { 9 | if len(items) == 0 { 10 | return items 11 | } 12 | if shouldReverse { 13 | for ii := 0; ii < len(items)/2; ii++ { 14 | var ( 15 | head = ii 16 | tail = len(items) - 1 - ii 17 | ) 18 | if head == tail { 19 | break 20 | } 21 | items[head], items[tail] = items[tail], items[head] 22 | } 23 | return items 24 | } 25 | return items 26 | } 27 | -------------------------------------------------------------------------------- /layout/rounded.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/op/clip" 9 | "gioui.org/unit" 10 | ) 11 | 12 | // Rounded lays out a widget with rounded corners. 13 | type Rounded unit.Dp 14 | 15 | func (r Rounded) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { 16 | macro := op.Record(gtx.Ops) 17 | dims := w(gtx) 18 | call := macro.Stop() 19 | radii := gtx.Dp(unit.Dp(r)) 20 | defer clip.RRect{ 21 | Rect: image.Rectangle{Max: dims.Size}, 22 | NE: radii, 23 | NW: radii, 24 | SW: radii, 25 | SE: radii, 26 | }.Push(gtx.Ops).Pop() 27 | call.Add(gtx.Ops) 28 | return dims 29 | } 30 | -------------------------------------------------------------------------------- /layout/row.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "gioui.org/layout" 5 | ) 6 | 7 | type ( 8 | C = layout.Context 9 | D = layout.Dimensions 10 | ) 11 | 12 | // Row lays out a central widget with gutters either side. 13 | // The central widget can be arbitrarily aligned and gutters can have 14 | // supplimentary widgets stacked atop them. 15 | type Row struct { 16 | // Margin between rows. 17 | Margin VerticalMarginStyle 18 | // InternalMargin between internal rows. 19 | // Leave unset if you want to control spacing between RowChild individually. 20 | InternalMargin VerticalMarginStyle 21 | // Gutter handles the left-right gutters of the row that provides padding and 22 | // can contain other widgets. 23 | Gutter GutterStyle 24 | // Direction of widgets within this row. 25 | // Typically, non-local widgets are aligned W, and local widgets aligned E. 26 | Direction layout.Direction 27 | } 28 | 29 | // RowChild specifies a content widget and two gutter widgets either side. 30 | // RowChild is used to layout composite rows made up of any number of interal 31 | // rows. 32 | type RowChild struct { 33 | Left layout.Widget 34 | Content layout.Widget 35 | Right layout.Widget 36 | Unified bool 37 | } 38 | 39 | // FullRow returns a RowChild that lays out content with optional gutter widgets 40 | // either side. 41 | func FullRow(l, w, r layout.Widget) RowChild { 42 | return RowChild{ 43 | Left: l, 44 | Content: w, 45 | Right: r, 46 | } 47 | } 48 | 49 | // ContentRow returns a RowChild that lays out a content with no gutter widgets. 50 | func ContentRow(w layout.Widget) RowChild { 51 | return RowChild{Content: w} 52 | } 53 | 54 | // UnifiedRow ignores gutters, taking up all available space. 55 | func UnifiedRow(w layout.Widget) RowChild { 56 | return RowChild{Content: w, Unified: true} 57 | } 58 | 59 | // Layout the Row with any number of internal rows. 60 | func (r Row) Layout(gtx C, w ...RowChild) D { 61 | var ( 62 | // array is a stack allocated array to avoid heap allocation if number 63 | // of RowChild is small. 64 | // Otherwise, slice append will allocate as needed. 65 | // 16 is a magic number subject to change upon further analysis. 66 | array [16]layout.FlexChild 67 | // slice based on the stack allocated array. 68 | slice = array[0:0] 69 | ) 70 | content := func(ii int) layout.Widget { 71 | return func(gtx C) D { 72 | if w[ii].Content == nil { 73 | return D{} 74 | } 75 | return w[ii].Content(gtx) 76 | } 77 | } 78 | for ii := range w { 79 | ii := ii 80 | slice = append(slice, layout.Rigid(func(gtx C) D { 81 | return r.InternalMargin.Layout(gtx, func(gtx C) D { 82 | if w[ii].Unified { 83 | return content(ii)(gtx) 84 | } 85 | return r.Gutter.Layout(gtx, 86 | w[ii].Left, 87 | func(gtx C) D { 88 | return r.Direction.Layout(gtx, content(ii)) 89 | }, 90 | w[ii].Right, 91 | ) 92 | }) 93 | })) 94 | } 95 | return r.Margin.Layout(gtx, func(gtx C) D { 96 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, slice...) 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /layout/vertical-margin.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | ) 7 | 8 | // VerticalMarginStyle provides a simple API for insetting a widget equally 9 | // on its top and bottom edges. Consistently wrapping chat elements 10 | // in a single VerticalMarginStyle as their outermost layout type will 11 | // ensure that they are spaced evenly and no part of their content 12 | // crowds that of the message above and below. 13 | type VerticalMarginStyle struct { 14 | Size unit.Dp 15 | } 16 | 17 | // VerticalMargin configures a vertical margin with a sensible default 18 | // margin. 19 | func VerticalMargin() VerticalMarginStyle { 20 | return VerticalMarginStyle{ 21 | Size: unit.Dp(4), 22 | } 23 | } 24 | 25 | // Layout the provided widget within the margin and return their combined 26 | // dimensions. 27 | func (v VerticalMarginStyle) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { 28 | return layout.Inset{ 29 | Top: v.Size, 30 | Bottom: v.Size, 31 | }.Layout(gtx, w) 32 | } 33 | -------------------------------------------------------------------------------- /list/assets/dataflow-diagram.odt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioverse/chat/0e60eda7d46019fa9e8f3724121f9684017722c2/list/assets/dataflow-diagram.odt -------------------------------------------------------------------------------- /list/assets/dataflow-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioverse/chat/0e60eda7d46019fa9e8f3724121f9684017722c2/list/assets/dataflow-diagram.png -------------------------------------------------------------------------------- /list/async.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type updateType uint8 8 | 9 | const ( 10 | // pull indicates the results from a load request. The update pulled 11 | // data from the data store. 12 | pull updateType = iota 13 | // push indicates the results from an asynchronous insertion of 14 | // data. The application pushed data to the list. 15 | push 16 | ) 17 | 18 | func (u updateType) String() string { 19 | switch u { 20 | case pull: 21 | return "pull" 22 | case push: 23 | return "push" 24 | default: 25 | return "unknown" 26 | } 27 | } 28 | 29 | // stateUpdate contains a new slice of element data and a mapping from all of 30 | // the element serials to their respective indicies. This data structure is designed 31 | // to allow the UI code to quickly find and update any offsets and locations 32 | // within the new data. 33 | type stateUpdate struct { 34 | Synthesis 35 | // CompactedSerials is a slice of Serials that were compacted within this update. 36 | CompactedSerials []Serial 37 | // Ignore reports which directions (if any) the async backend currently 38 | // believes to have no new content. 39 | Ignore Direction 40 | Type updateType 41 | } 42 | 43 | func (s stateUpdate) String() string { 44 | return fmt.Sprintf("{Synthesis: %v, Compacted: %v, Ignore: %v, Source: %v}", s.Synthesis, s.CompactedSerials, s.Ignore, s.Source) 45 | } 46 | 47 | // viewport represents a range of elements visible within a list. 48 | type viewport struct { 49 | Start, End Serial 50 | } 51 | 52 | // asyncProcess runs a list.processor concurrently. 53 | // New elements are processed and compacted according to maxSize 54 | // on each loadRequest. Close the loadRequest channel to terminate 55 | // processing. 56 | func asyncProcess(maxSize int, hooks Hooks) (chan<- interface{}, chan viewport, <-chan []stateUpdate) { 57 | compact := NewCompact(maxSize, hooks.Comparator) 58 | var synthesis Synthesis 59 | reqChan := make(chan interface{}) 60 | updateChan := make(chan []stateUpdate, 1) 61 | viewports := make(chan viewport, 1) 62 | go func() { 63 | defer close(updateChan) 64 | var ( 65 | viewport viewport 66 | ignore Direction 67 | ) 68 | for { 69 | var ( 70 | su stateUpdate 71 | newElems []Element 72 | updateOnly []Element 73 | rmSerials []Serial 74 | ) 75 | select { 76 | case req, more := <-reqChan: 77 | if !more { 78 | return 79 | } 80 | switch req := req.(type) { 81 | case modificationRequest: 82 | su.Type = push 83 | newElems = req.NewOrUpdate 84 | rmSerials = req.Remove 85 | updateOnly = req.UpdateOnly 86 | 87 | /* 88 | Remove any elements that sort outside the boundaries of the 89 | current list. 90 | */ 91 | SliceFilter(&newElems, func(elem Element) bool { 92 | if len(synthesis.Source) == 0 { 93 | return true 94 | } 95 | sortsBefore := compact.Comparator(elem, synthesis.Source[0]) 96 | sortsAfter := compact.Comparator(synthesis.Source[len(synthesis.Source)-1], elem) 97 | // If this element sorts before the beginning of the list or after 98 | // the end of the list, it should not be inserted unless we are at 99 | // the appropriate end of the list. 100 | switch { 101 | case sortsBefore && ignore == Before: 102 | return true 103 | case sortsAfter && ignore == After: 104 | return true 105 | case sortsBefore || sortsAfter: 106 | return false 107 | default: 108 | return true 109 | } 110 | }) 111 | ignore = NoDirection 112 | case loadRequest: 113 | su.Type = pull 114 | viewport = req.viewport 115 | if ignore.Contains(req.Direction) { 116 | continue 117 | } 118 | 119 | // Find the serial of the element at either end of the list. 120 | var loadSerial Serial 121 | switch req.Direction { 122 | case Before: 123 | loadSerial = synthesis.SerialAt(0) 124 | case After: 125 | loadSerial = synthesis.SerialAt(len(synthesis.Source) - 1) 126 | } 127 | // Load new elements. 128 | var more bool 129 | newElems, more = hooks.Loader(req.Direction, loadSerial) 130 | // Track whether all new elements in a given direction have been 131 | // exhausted. 132 | if len(newElems) == 0 || !more { 133 | ignore.Add(req.Direction) 134 | } else { 135 | ignore = NoDirection 136 | } 137 | } 138 | } 139 | // Apply state updates. 140 | compact.Apply(newElems, updateOnly, rmSerials) 141 | 142 | // Update the viewport if there is a new one available. 143 | select { 144 | case viewport = <-viewports: 145 | default: 146 | } 147 | 148 | // Fetch new contents and list of compacted content. 149 | contents, compacted := compact.Compact(viewport.Start, viewport.End) 150 | su.CompactedSerials = compacted 151 | // Synthesize elements based on new contents. 152 | synthesis = Synthesize(contents, hooks.Synthesizer) 153 | su.Synthesis = synthesis 154 | su.Ignore = ignore 155 | 156 | // Try send update. If the widget is not being actively laid out, 157 | // we don't want to block. 158 | select { 159 | case updateChan <- []stateUpdate{su}: 160 | default: 161 | // Append latest update to the list. 162 | su := su 163 | pending := <-updateChan 164 | pending = append(pending, su) 165 | updateChan <- pending 166 | } 167 | 168 | hooks.Invalidator() 169 | } 170 | }() 171 | return reqChan, viewports, updateChan 172 | } 173 | -------------------------------------------------------------------------------- /list/async_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // define a set of elements that can be used across tests. 12 | var testElements = func() []Element { 13 | testElements := []Element{} 14 | for i := 0; i < 10; i++ { 15 | testElements = append(testElements, testElement{ 16 | serial: fmt.Sprintf("%03d", i), 17 | synthCount: 1, 18 | }) 19 | } 20 | return testElements 21 | }() 22 | 23 | func TestAsyncProcess(t *testing.T) { 24 | var nextLoad []Element 25 | var more bool 26 | var loadInvoked bool 27 | hooks := Hooks{ 28 | Invalidator: func() {}, 29 | Comparator: testComparator, 30 | Synthesizer: testSynthesizer, 31 | Loader: func(dir Direction, rt Serial) ([]Element, bool) { 32 | loadInvoked = true 33 | return nextLoad, more 34 | }, 35 | } 36 | size := 6 37 | reqs, _, updates := asyncProcess(size, hooks) 38 | 39 | type testcase struct { 40 | // description of what this test case is checking 41 | name string 42 | // a request for data 43 | input loadRequest 44 | // the data that will be returned by the data request (if the loader is executed) 45 | load []Element 46 | // whether the async logic should expect additional content in the direction of 47 | // the load. 48 | loadMore bool 49 | // should the testcase block waiting for an update on the update channel 50 | skipUpdate bool 51 | // the update to expect on the update channel 52 | expected stateUpdate 53 | // any conditions that should be checked after the rest of the logic 54 | extraChecks func() error 55 | } 56 | 57 | // Run each testcase sequentially on the same processor to check different 58 | // points during the lifecycle of its data. 59 | for _, tc := range []testcase{ 60 | { 61 | name: "initial load of data", 62 | input: loadRequest{ 63 | viewport: viewport{ 64 | Start: "", 65 | End: "", 66 | }, 67 | Direction: Before, 68 | }, 69 | load: testElements[7:], 70 | loadMore: true, 71 | expected: stateUpdate{ 72 | Synthesis: Synthesis{ 73 | Elements: testElements[7:], 74 | SerialToIndex: map[Serial]int{ 75 | testElements[7].Serial(): 0, 76 | testElements[8].Serial(): 1, 77 | testElements[9].Serial(): 2, 78 | }, 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "fetch content after (cannot succeed)", 84 | input: loadRequest{ 85 | viewport: viewport{ 86 | Start: "000", 87 | End: "003", 88 | }, 89 | Direction: After, 90 | }, 91 | load: nil, 92 | expected: stateUpdate{ 93 | Synthesis: Synthesis{ 94 | Elements: testElements[7:], 95 | SerialToIndex: map[Serial]int{ 96 | testElements[7].Serial(): 0, 97 | testElements[8].Serial(): 1, 98 | testElements[9].Serial(): 2, 99 | }, 100 | }, 101 | }, 102 | }, 103 | { 104 | name: "fetch content after again (should not attempt load)", 105 | input: loadRequest{ 106 | viewport: viewport{ 107 | Start: "000", 108 | End: "003", 109 | }, 110 | Direction: After, 111 | }, 112 | skipUpdate: true, 113 | extraChecks: func() error { 114 | if loadInvoked { 115 | return fmt.Errorf("should not have invoked load after a load in the same direction returned nothing") 116 | } 117 | return nil 118 | }, 119 | }, 120 | { 121 | name: "fetch content before", 122 | input: loadRequest{ 123 | viewport: viewport{ 124 | Start: "000", 125 | End: "003", 126 | }, 127 | Direction: Before, 128 | }, 129 | load: testElements[4:7], 130 | loadMore: true, 131 | expected: stateUpdate{ 132 | Synthesis: Synthesis{ 133 | Elements: testElements[4:], 134 | SerialToIndex: map[Serial]int{ 135 | testElements[4].Serial(): 0, 136 | testElements[5].Serial(): 1, 137 | testElements[6].Serial(): 2, 138 | testElements[7].Serial(): 3, 139 | testElements[8].Serial(): 4, 140 | testElements[9].Serial(): 5, 141 | }, 142 | }, 143 | }, 144 | }, 145 | { 146 | name: "fetch content after (cannot succeed, but should try anyway now that a different load succeeded)", 147 | input: loadRequest{ 148 | viewport: viewport{ 149 | Start: "000", 150 | End: "006", 151 | }, 152 | Direction: After, 153 | }, 154 | expected: stateUpdate{ 155 | Synthesis: Synthesis{ 156 | Elements: testElements[4:], 157 | SerialToIndex: map[Serial]int{ 158 | testElements[4].Serial(): 0, 159 | testElements[5].Serial(): 1, 160 | testElements[6].Serial(): 2, 161 | testElements[7].Serial(): 3, 162 | testElements[8].Serial(): 4, 163 | testElements[9].Serial(): 5, 164 | }, 165 | }, 166 | }, 167 | extraChecks: func() error { 168 | if !loadInvoked { 169 | return fmt.Errorf("should have invoked load") 170 | } 171 | return nil 172 | }, 173 | }, 174 | { 175 | name: "fetch content before (should compact the end)", 176 | input: loadRequest{ 177 | viewport: viewport{ 178 | Start: "005", 179 | End: "006", 180 | }, 181 | Direction: Before, 182 | }, 183 | load: testElements[1:4], 184 | loadMore: true, 185 | expected: stateUpdate{ 186 | Synthesis: Synthesis{ 187 | Elements: testElements[3:9], 188 | SerialToIndex: map[Serial]int{ 189 | testElements[3].Serial(): 0, 190 | testElements[4].Serial(): 1, 191 | testElements[5].Serial(): 2, 192 | testElements[6].Serial(): 3, 193 | testElements[7].Serial(): 4, 194 | testElements[8].Serial(): 5, 195 | }, 196 | }, 197 | CompactedSerials: []Serial{ 198 | testElements[1].Serial(), 199 | testElements[2].Serial(), 200 | testElements[9].Serial(), 201 | }, 202 | }, 203 | }, 204 | { 205 | name: "fetch content before (should compact the end a little more)", 206 | input: loadRequest{ 207 | viewport: viewport{ 208 | Start: "004", 209 | End: "005", 210 | }, 211 | Direction: Before, 212 | }, 213 | load: testElements[:3], 214 | loadMore: true, 215 | expected: stateUpdate{ 216 | Synthesis: Synthesis{ 217 | Elements: testElements[2:8], 218 | SerialToIndex: map[Serial]int{ 219 | testElements[2].Serial(): 0, 220 | testElements[3].Serial(): 1, 221 | testElements[4].Serial(): 2, 222 | testElements[5].Serial(): 3, 223 | testElements[6].Serial(): 4, 224 | testElements[7].Serial(): 5, 225 | }, 226 | }, 227 | CompactedSerials: []Serial{ 228 | testElements[0].Serial(), 229 | testElements[1].Serial(), 230 | testElements[8].Serial(), 231 | }, 232 | }, 233 | }, 234 | { 235 | name: "fetch content before (no more content)", 236 | input: loadRequest{ 237 | viewport: viewport{ 238 | Start: "002", 239 | End: "003", 240 | }, 241 | Direction: Before, 242 | }, 243 | load: nil, 244 | expected: stateUpdate{ 245 | Synthesis: Synthesis{ 246 | Elements: testElements[2:8], 247 | SerialToIndex: map[Serial]int{ 248 | testElements[2].Serial(): 0, 249 | testElements[3].Serial(): 1, 250 | testElements[4].Serial(): 2, 251 | testElements[5].Serial(): 3, 252 | testElements[6].Serial(): 4, 253 | testElements[7].Serial(): 5, 254 | }, 255 | }, 256 | }, 257 | }, 258 | { 259 | name: "fetch content before (should not attempt load)", 260 | input: loadRequest{ 261 | viewport: viewport{ 262 | Start: "000", 263 | End: "006", 264 | }, 265 | Direction: Before, 266 | }, 267 | skipUpdate: true, 268 | extraChecks: func() error { 269 | if loadInvoked { 270 | return fmt.Errorf("should not have invoked load after a load in the same direction returned nothing") 271 | } 272 | return nil 273 | }, 274 | }, 275 | } { 276 | t.Run(tc.name, func(t *testing.T) { 277 | // ensure that the next invocation of the loader will load this 278 | // testcase's data payload. 279 | nextLoad = tc.load 280 | more = tc.loadMore 281 | 282 | // request a load 283 | reqs <- tc.input 284 | 285 | // examine the update we get back 286 | if !tc.skipUpdate { 287 | update := <-updates 288 | if len(update) != 1 { 289 | t.Errorf("Expected 1 pending update, got %d", len(update)) 290 | } 291 | if !updatesEqual(update[0], tc.expected) { 292 | t.Errorf("Expected %v, got %v", tc.expected, update) 293 | } 294 | } 295 | if tc.extraChecks != nil { 296 | if err := tc.extraChecks(); err != nil { 297 | t.Error(err) 298 | } 299 | } 300 | loadInvoked = false 301 | }) 302 | } 303 | } 304 | 305 | func updatesEqual(a, b stateUpdate) bool { 306 | if !elementsEqual(a.Elements, b.Elements) { 307 | return false 308 | } 309 | if !serialsEqual(a.CompactedSerials, b.CompactedSerials) { 310 | return false 311 | } 312 | return reflect.DeepEqual(a.SerialToIndex, b.SerialToIndex) 313 | } 314 | 315 | // TestCanModifyWhenIdle ensures that updates are queued if the reading 316 | // side is idle (e.g. list manager is not currently being laid out). 317 | func TestCanModifyWhenIdle(t *testing.T) { 318 | hooks := testHooks 319 | hooks.Comparator = func(i, j Element) bool { 320 | // For the purposes of this test, the sort order doesn't matter, 321 | // and we do not want to trigger the logic that filters insertions 322 | // at the beginning or end of the list. 323 | return false 324 | } 325 | requests, viewports, updates := asyncProcess(4, hooks) 326 | 327 | viewports <- viewport{ 328 | Start: "0", 329 | End: "5", 330 | } 331 | 332 | // Update with some number of elements. 333 | // The manager is not being laid out so 334 | // we expect these to queue up. 335 | requests <- modificationRequest{ 336 | NewOrUpdate: []Element{testElement{serial: "1", synthCount: 0}}, 337 | UpdateOnly: []Element{}, 338 | Remove: []Serial{}, 339 | } 340 | requests <- modificationRequest{ 341 | NewOrUpdate: []Element{testElement{serial: "2", synthCount: 0}}, 342 | UpdateOnly: []Element{}, 343 | Remove: []Serial{}, 344 | } 345 | requests <- modificationRequest{ 346 | NewOrUpdate: []Element{testElement{serial: "3", synthCount: 0}}, 347 | UpdateOnly: []Element{}, 348 | Remove: []Serial{}, 349 | } 350 | requests <- modificationRequest{ 351 | NewOrUpdate: []Element{testElement{serial: "4", synthCount: 0}}, 352 | UpdateOnly: []Element{}, 353 | Remove: []Serial{}, 354 | } 355 | 356 | close(requests) 357 | 358 | // Give time for async to shutdown. 359 | time.Sleep(time.Millisecond) 360 | 361 | // updates should have a queued value. 362 | if len(updates) != 1 { 363 | t.Fatalf("updates channel: expected 1 queued value, got %d", len(updates)) 364 | } 365 | 366 | // We should recieve update elements 1, 2, 3, 4. 367 | total := 0 368 | var want []Element 369 | for pending := range updates { 370 | t.Log(pending) 371 | for ii := range pending { 372 | su := pending[ii] 373 | total++ 374 | if su.Type != push { 375 | t.Errorf("expected push update, got %v", su.Type) 376 | } 377 | got := su.Synthesis.Source 378 | want = append(want, testElement{ 379 | serial: strconv.Itoa(total), 380 | synthCount: 0, 381 | }) 382 | if !reflect.DeepEqual(got, want) { 383 | t.Errorf("state update: want %+v, got %+v", want, got) 384 | } 385 | } 386 | } 387 | if total != 4 { 388 | t.Fatalf("expected 4 pending updates, got %d", total) 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /list/compact.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import "sort" 4 | 5 | // Compact is a list of sorted Elements with a specific maximum size. 6 | // It supports insertion, updating elements in place, and removing 7 | // elements. 8 | type Compact struct { 9 | elements []Element 10 | Size int 11 | Comparator 12 | } 13 | 14 | // NewCompact returns a Compact with the given maximum size and 15 | // using the given Comparator to sort its contents. 16 | func NewCompact(size int, comp Comparator) *Compact { 17 | return &Compact{ 18 | Size: size, 19 | Comparator: comp, 20 | } 21 | } 22 | 23 | func (c *Compact) mapping() map[Serial]int { 24 | serialToRaw := make(map[Serial]int) 25 | for i, elem := range c.elements { 26 | serialToRaw[elem.Serial()] = i 27 | } 28 | return serialToRaw 29 | } 30 | 31 | // Apply inserts, updates, and removes elements from within the contents 32 | // of the compact. 33 | func (c *Compact) Apply(insertOrUpdate []Element, updateOnly []Element, remove []Serial) { 34 | serialToRaw := c.mapping() 35 | // Search newElems for elements that already exist within the Raw slice. 36 | SliceFilter(&insertOrUpdate, func(elem Element) bool { 37 | rawIndex, exists := serialToRaw[elem.Serial()] 38 | if exists { 39 | // Update the stored existing element. 40 | c.elements[rawIndex] = elem 41 | return false 42 | } 43 | return true 44 | }) 45 | 46 | // Update elements if and only if they are present. 47 | for _, elem := range updateOnly { 48 | index, isPresent := serialToRaw[elem.Serial()] 49 | if !isPresent { 50 | continue 51 | } 52 | c.elements[index] = elem 53 | } 54 | 55 | // Find the index of each element needing removal. 56 | var targetIndicies []int 57 | for _, serial := range remove { 58 | idx, ok := serialToRaw[serial] 59 | if !ok { 60 | continue 61 | } 62 | targetIndicies = append(targetIndicies, idx) 63 | } 64 | // Remove them by swapping and re-slicing, starting from the highest 65 | // index to ensure that we do not move a removed element into the 66 | // middle of the list as part of the swap. 67 | sort.Sort(sort.Reverse(sort.IntSlice(targetIndicies))) 68 | for _, target := range targetIndicies { 69 | SliceRemove(&c.elements, target) 70 | } 71 | 72 | c.elements = append(c.elements, insertOrUpdate...) 73 | // Re-sort elements. 74 | sort.SliceStable(c.elements, func(i, j int) bool { 75 | return c.Comparator(c.elements[i], c.elements[j]) 76 | }) 77 | } 78 | 79 | // Compact returns a compacted slice of the elements managed by the Compact. 80 | // The resulting elements are garanteed to be sorted using the 81 | // Compact's Comparator and there will usually be no more than c.Size elements. 82 | // The exception is when c.Size is smaller than 3 times the distance between 83 | // keepStart and keepEnd. In that case, Compact will attempt to return a slice 84 | // containing the region described by [keepStart,keepEnd] with the same number 85 | // of elements on either side. 86 | func (c *Compact) Compact(keepStart, keepEnd Serial) (contents []Element, compacted []Serial) { 87 | if len(c.elements) < 1 { 88 | return nil, nil 89 | } 90 | serialToRaw := c.mapping() 91 | keepStartIdx, ok := serialToRaw[keepStart] 92 | if !ok || keepStart == NoSerial { 93 | keepStartIdx = 0 94 | } 95 | keepEndIdx, ok := serialToRaw[keepEnd] 96 | if !ok || keepEnd == NoSerial { 97 | keepEndIdx = len(c.elements) - 1 98 | } 99 | visible := (1 + keepEndIdx - keepStartIdx) 100 | size := max(c.Size, 3*visible) 101 | additional := size - visible 102 | if additional > 0 { 103 | // cut the additional size in half, ensuring that no element is 104 | // lost to integer truncation. 105 | half := additional / 2 106 | secondHalf := additional - half 107 | if keepStartIdx < half { 108 | // Donate any unused quota at the beginning of the list to 109 | // the end. 110 | secondHalf += (half - keepStartIdx) 111 | } 112 | if newEnd := keepEndIdx + secondHalf; newEnd >= len(c.elements) { 113 | // Donate any unused quota at the end of the list to 114 | // the beginning. 115 | half += newEnd - (len(c.elements) - 1) 116 | } 117 | keepStartIdx = max(keepStartIdx-half, 0) 118 | keepEndIdx = min(keepEndIdx+secondHalf, len(c.elements)-1) 119 | } 120 | 121 | // Collect the serials of elements that are being deallocated by compaction. 122 | for i := 0; i < keepStartIdx; i++ { 123 | compacted = append(compacted, c.elements[i].Serial()) 124 | } 125 | for i := keepEndIdx + 1; i < len(c.elements); i++ { 126 | compacted = append(compacted, c.elements[i].Serial()) 127 | } 128 | 129 | // Allocate a new Raw slice to house the data, allowing the older, 130 | // longer slice to be garbage collected. 131 | newLength := keepEndIdx - keepStartIdx + 1 132 | newRaw := make([]Element, newLength) 133 | copy(newRaw, c.elements[keepStartIdx:keepEndIdx+1]) 134 | c.elements = newRaw 135 | 136 | return c.elements, compacted 137 | } 138 | -------------------------------------------------------------------------------- /list/compact_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import "testing" 4 | 5 | func TestCompactInsert(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /list/element.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gioui.org/app" 7 | "gioui.org/layout" 8 | "gioui.org/widget/material" 9 | ) 10 | 11 | // Serial uniquely identifies a list element. 12 | type Serial string 13 | 14 | // NoSerial is a special serial that can be used by Elements that do not require 15 | // a unique identifier. Only stateless elements may go without a unique 16 | // identifier. 17 | const NoSerial = Serial("") 18 | 19 | // Element is a type that can be presented by a Manager. 20 | type Element interface { 21 | // Serial returns a unique identifier for the Element, if it has one. 22 | // In order for an Element to be stateful, it _must_ return a unique 23 | // Serial. Elements that are not stateful may return the special Serial 24 | // NoSerial to indicate that they do not need any state allocated 25 | // for them. 26 | Serial() Serial 27 | } 28 | 29 | // Start is a psuedo Element that indicates the beginning of the list view, 30 | // that is, the beginning of the elements currently loaded in memory. 31 | // Type assert inside Synthesizer to check for list boundary. 32 | type Start struct{} 33 | 34 | func (Start) Serial() Serial { 35 | return Serial("START") 36 | } 37 | 38 | // End is a psuedo Element that indicates the end of the list view, that is, 39 | // the end of the elements currently loaded in memory. 40 | // Type assert inside Synthesizer to check for list boundary. 41 | type End struct{} 42 | 43 | func (End) Serial() Serial { 44 | return Serial("END") 45 | } 46 | 47 | // Synthesizer is a function that can insert synthetic elements into 48 | // a list of elements. The most common use case for this is to insert 49 | // separators between elements indicating the passage of time or 50 | // some other logical transition between them. previous may be nil 51 | // if the Synthesizer is invoked at the beginning of the list. This 52 | // function may choose to return nil to prevent current from 53 | // being shown. 54 | type Synthesizer func(previous, current, next Element) []Element 55 | 56 | // Comparator returns whether element a sorts before element b in the 57 | // list. 58 | type Comparator func(a, b Element) bool 59 | 60 | // Loader is a function that can fulfill load requests. If it returns 61 | // a response with no elements in a given direction or false as its 62 | // second return value, the manager will not 63 | // invoke the loader in that direction again until the manager loads 64 | // data from the other end of the list or another manger state update 65 | // occurs. 66 | // 67 | // Loader implements pull modifications. When the manager wants more data it 68 | // will invoke the Loader hook to get more. 69 | type Loader func(direction Direction, relativeTo Serial) (elems []Element, more bool) 70 | 71 | // Presenter is a function that can transform the data for an Element 72 | // into a widget to be laid out in the user interface. It must not return 73 | // nil. The state parameter may be nil if the Element either has no 74 | // Serial or if the Allocator function returned nil for the element. 75 | type Presenter func(current Element, state interface{}) layout.Widget 76 | 77 | // Allocator is a function that can allocate the appropriate state 78 | // type for a given Element. It will only be invoked for Elements that 79 | // return a serial from their Serial() method. It may return nil, 80 | // indicating that the element in question does not need any persistent 81 | // state. 82 | type Allocator func(current Element) (state interface{}) 83 | 84 | // Hooks provides the lifecycle hooks necessary for a Manager 85 | // to orchestrate the state of all its managed elements. See the documentation 86 | // of each function type for details. 87 | type Hooks struct { 88 | Synthesizer 89 | Comparator 90 | Loader 91 | Presenter 92 | Allocator 93 | // Invalidator triggers a new frame in the window displaying the managed 94 | // list. 95 | Invalidator func() 96 | } 97 | 98 | type defaultElement struct { 99 | serial Serial 100 | } 101 | 102 | func (d defaultElement) Serial() Serial { 103 | return d.serial 104 | } 105 | 106 | func newDefaultElements() (out []Element) { 107 | for i := 0; i < 100; i++ { 108 | out = append(out, defaultElement{ 109 | serial: Serial(fmt.Sprintf("%05d", i)), 110 | }) 111 | } 112 | return out 113 | } 114 | 115 | // DefaultHooks returns a Hooks instance with most fields defined as no-ops. 116 | // It does populate the Invalidator field with w.Invalidate. 117 | func DefaultHooks(w *app.Window, th *material.Theme) Hooks { 118 | return Hooks{ 119 | Synthesizer: func(prev, curr, next Element) []Element { 120 | return []Element{curr} 121 | }, 122 | Comparator: func(a, b Element) bool { 123 | return string(a.Serial()) < string(b.Serial()) 124 | }, 125 | Loader: func(dir Direction, relativeTo Serial) ([]Element, bool) { 126 | if relativeTo == NoSerial { 127 | return newDefaultElements(), false 128 | } 129 | return nil, false 130 | }, 131 | Presenter: func(elem Element, state interface{}) layout.Widget { 132 | return material.H4(th, "Implement list.Hooks to change me.").Layout 133 | }, 134 | Allocator: func(elem Element) interface{} { 135 | return nil 136 | }, 137 | Invalidator: w.Invalidate, 138 | } 139 | } 140 | 141 | func min(ints ...int) int { 142 | lowest := ints[0] 143 | for i := 1; i < len(ints); i++ { 144 | if ints[i] < lowest { 145 | lowest = ints[i] 146 | } 147 | } 148 | return lowest 149 | } 150 | 151 | func max(a, b int) int { 152 | if a > b { 153 | return a 154 | } 155 | return b 156 | } 157 | 158 | // Direction indicates a direction relative to the viewport of a list. 159 | type Direction uint8 160 | 161 | // Add combines the receiving direction with the parameter. 162 | func (d *Direction) Add(other Direction) { 163 | switch *d { 164 | case NoDirection: 165 | *d = other 166 | case After: 167 | if other == Before { 168 | *d = Both 169 | } 170 | case Before: 171 | if other == After { 172 | *d = Both 173 | } 174 | } 175 | } 176 | 177 | // Contains returns whether the receiver direction logically includes the 178 | // provided direction. 179 | func (d Direction) Contains(other Direction) bool { 180 | switch d { 181 | case NoDirection: 182 | return false 183 | case Both: 184 | return true 185 | case After: 186 | return other == After 187 | case Before: 188 | return other == Before 189 | default: 190 | return false 191 | } 192 | } 193 | 194 | const ( 195 | // NoDirection refers to no specific direction. 196 | NoDirection Direction = iota 197 | // Before refers to serial values earlier than a reference value 198 | // (usually the beginning of the viewport). 199 | Before 200 | // After refers to serial values after a reference value 201 | // (usually the end of the viewport). 202 | After 203 | // Both indicates the Before and After Directions simultaneously. 204 | Both 205 | ) 206 | 207 | // String converts a direction into a printable representation. 208 | func (d Direction) String() string { 209 | switch d { 210 | case NoDirection: 211 | return "NoDirection" 212 | case Before: 213 | return "Before" 214 | case After: 215 | return "After" 216 | case Both: 217 | return "Both" 218 | default: 219 | return "unknown direction" 220 | } 221 | } 222 | 223 | // loadRequest represents a request to load more elements on one end of the list. 224 | type loadRequest struct { 225 | Direction Direction 226 | viewport 227 | } 228 | 229 | // modificationRequest represents a request to insert or update some elements 230 | // within the managed list. 231 | type modificationRequest struct { 232 | NewOrUpdate []Element 233 | UpdateOnly []Element 234 | Remove []Serial 235 | } 236 | 237 | // SliceRemove takes the given index of a slice and swaps it with the final 238 | // index in the slice, then shortens the slice by one element. This hides 239 | // the element at index from the slice, though it does not erase its data. 240 | func SliceRemove(s *[]Element, index int) { 241 | if s == nil || len(*s) < 1 || index >= len(*s) { 242 | return 243 | } 244 | lastIndex := len(*s) - 1 245 | (*s)[index], (*s)[lastIndex] = (*s)[lastIndex], (*s)[index] 246 | *s = (*s)[:lastIndex] 247 | } 248 | 249 | // SliceFilter removes elements for which the predicate returns false 250 | // from the slice. 251 | func SliceFilter(s *[]Element, predicate func(elem Element) bool) { 252 | if predicate == nil { 253 | return 254 | } 255 | // Avoids using a range loop because we modify the slice as we iterate. 256 | for i := 0; i < len(*s); i++ { 257 | elem := (*s)[i] 258 | if predicate(elem) { 259 | continue 260 | } 261 | // Remove this element from the new slice. 262 | SliceRemove(s, i) 263 | // Check the element at this index again next iteration. 264 | i-- 265 | } 266 | } 267 | 268 | // MakeIndexValid forces the given index to be in bounds for given slice. 269 | func MakeIndexValid(slice []Element, index int) int { 270 | if index > len(slice) { 271 | index = len(slice) - 1 272 | } else if index < 0 { 273 | index = 0 274 | } 275 | return index 276 | } 277 | 278 | // SerialAtOrBefore returns the serial of the element at the given index 279 | // if it is not NoSerial. If it is NoSerial, this method iterates backwards 280 | // towards the beginning of the list, searching for the nearest element with 281 | // a serial. If no serial is found before the beginning of the list, NoSerial 282 | // is returned. 283 | func SerialAtOrBefore(list []Element, index int) Serial { 284 | for i := MakeIndexValid(list, index); i >= 0; i-- { 285 | if s := list[index].Serial(); s != NoSerial { 286 | return s 287 | } 288 | } 289 | return NoSerial 290 | } 291 | 292 | // SerialAtOrAfter returns the serial of the element at the given index 293 | // if it is not NoSerial. If it is NoSerial, this method iterates forwards 294 | // towards the end of the list, searching for the nearest element with 295 | // a serial. If no serial is found before the end of the list, NoSerial 296 | // is returned. 297 | func SerialAtOrAfter(list []Element, index int) Serial { 298 | for i := MakeIndexValid(list, index); i < len(list); i++ { 299 | if s := list[index].Serial(); s != NoSerial { 300 | return s 301 | } 302 | } 303 | return NoSerial 304 | } 305 | -------------------------------------------------------------------------------- /list/element_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | ) 7 | 8 | type testElement struct { 9 | serial string 10 | synthCount int 11 | } 12 | 13 | func (t testElement) Serial() Serial { 14 | return Serial(t.serial) 15 | } 16 | 17 | func testSynthesizer(previous, current, next Element) []Element { 18 | out := []Element{} 19 | for i := 0; i < current.(testElement).synthCount; i++ { 20 | out = append(out, current) 21 | } 22 | return out 23 | } 24 | 25 | func testComparator(a, b Element) bool { 26 | return strings.Compare(string(a.Serial()), string(b.Serial())) < 0 27 | } 28 | 29 | func elementsEqual(a, b []Element) bool { 30 | if len(a) != len(b) { 31 | return false 32 | } 33 | for i := range a { 34 | if !reflect.DeepEqual(a[i], b[i]) { 35 | return false 36 | } 37 | } 38 | return true 39 | } 40 | 41 | func serialsEqual(a, b []Serial) bool { 42 | if len(a) != len(b) { 43 | return false 44 | } 45 | for i := range a { 46 | if !reflect.DeepEqual(a[i], b[i]) { 47 | return false 48 | } 49 | } 50 | return true 51 | } 52 | 53 | var compactionList = []Element{ 54 | testElement{ 55 | serial: "a", 56 | synthCount: 1, 57 | }, 58 | testElement{ 59 | serial: "b", 60 | synthCount: 1, 61 | }, 62 | testElement{ 63 | serial: "c", 64 | synthCount: 1, 65 | }, 66 | testElement{ 67 | serial: "d", 68 | synthCount: 1, 69 | }, 70 | testElement{ 71 | serial: "e", 72 | synthCount: 1, 73 | }, 74 | testElement{ 75 | serial: "f", 76 | synthCount: 1, 77 | }, 78 | testElement{ 79 | serial: "g", 80 | synthCount: 1, 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /list/list.test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioverse/chat/0e60eda7d46019fa9e8f3724121f9684017722c2/list/list.test -------------------------------------------------------------------------------- /list/slice_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import "testing" 4 | 5 | func TestSliceRemove(t *testing.T) { 6 | type testcase struct { 7 | name string 8 | data []Element 9 | index int 10 | result []Element 11 | } 12 | for _, tc := range []testcase{ 13 | { 14 | name: "empty slice", 15 | data: []Element{}, 16 | index: 0, 17 | result: []Element{}, 18 | }, 19 | { 20 | name: "nil slice", 21 | data: nil, 22 | index: 0, 23 | result: nil, 24 | }, 25 | { 26 | name: "index out of bounds", 27 | data: []Element{ 28 | testElement{}, 29 | }, 30 | index: 5, 31 | result: []Element{ 32 | testElement{}, 33 | }, 34 | }, 35 | { 36 | name: "single element slice", 37 | data: []Element{ 38 | testElement{}, 39 | }, 40 | index: 0, 41 | result: []Element{}, 42 | }, 43 | { 44 | name: "two element slice (remove first)", 45 | data: []Element{ 46 | testElement{serial: "a"}, 47 | testElement{serial: "b"}, 48 | }, 49 | index: 0, 50 | result: []Element{ 51 | testElement{serial: "b"}, 52 | }, 53 | }, 54 | { 55 | name: "two element slice (remove last)", 56 | data: []Element{ 57 | testElement{serial: "a"}, 58 | testElement{serial: "b"}, 59 | }, 60 | index: 1, 61 | result: []Element{ 62 | testElement{serial: "a"}, 63 | }, 64 | }, 65 | { 66 | name: "three element slice (remove first)", 67 | data: []Element{ 68 | testElement{serial: "a"}, 69 | testElement{serial: "b"}, 70 | testElement{serial: "c"}, 71 | }, 72 | index: 0, 73 | result: []Element{ 74 | testElement{serial: "c"}, 75 | testElement{serial: "b"}, 76 | }, 77 | }, 78 | { 79 | name: "three element slice (remove middle)", 80 | data: []Element{ 81 | testElement{serial: "a"}, 82 | testElement{serial: "b"}, 83 | testElement{serial: "c"}, 84 | }, 85 | index: 1, 86 | result: []Element{ 87 | testElement{serial: "a"}, 88 | testElement{serial: "c"}, 89 | }, 90 | }, 91 | { 92 | name: "three element slice (remove last)", 93 | data: []Element{ 94 | testElement{serial: "a"}, 95 | testElement{serial: "b"}, 96 | testElement{serial: "c"}, 97 | }, 98 | index: 2, 99 | result: []Element{ 100 | testElement{serial: "a"}, 101 | testElement{serial: "b"}, 102 | }, 103 | }, 104 | } { 105 | t.Run(tc.name, func(t *testing.T) { 106 | SliceRemove(&tc.data, tc.index) 107 | if !elementsEqual(tc.data, tc.result) { 108 | t.Errorf("expected %v, got %v", tc.result, tc.data) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | func TestSliceFilter(t *testing.T) { 115 | type testcase struct { 116 | name string 117 | data []Element 118 | predicate func(Element) bool 119 | result []Element 120 | } 121 | for _, tc := range []testcase{ 122 | { 123 | name: "empty slice", 124 | data: []Element{}, 125 | predicate: func(_ Element) bool { return true }, 126 | result: []Element{}, 127 | }, 128 | { 129 | name: "nil slice", 130 | data: nil, 131 | predicate: func(_ Element) bool { return true }, 132 | result: nil, 133 | }, 134 | { 135 | name: "nil predicate", 136 | data: []Element{ 137 | testElement{}, 138 | }, 139 | predicate: nil, 140 | result: []Element{ 141 | testElement{}, 142 | }, 143 | }, 144 | { 145 | name: "single element slice remove none", 146 | data: []Element{ 147 | testElement{}, 148 | }, 149 | predicate: func(_ Element) bool { return true }, 150 | result: []Element{ 151 | testElement{}, 152 | }, 153 | }, 154 | { 155 | name: "single element slice remove all", 156 | data: []Element{ 157 | testElement{}, 158 | }, 159 | predicate: func(_ Element) bool { return false }, 160 | result: []Element{}, 161 | }, 162 | { 163 | name: "two element slice (remove first)", 164 | data: []Element{ 165 | testElement{serial: "a"}, 166 | testElement{serial: "b"}, 167 | }, 168 | predicate: func(e Element) bool { return e.Serial() != "a" }, 169 | result: []Element{ 170 | testElement{serial: "b"}, 171 | }, 172 | }, 173 | { 174 | name: "two element slice (remove last)", 175 | data: []Element{ 176 | testElement{serial: "a"}, 177 | testElement{serial: "b"}, 178 | }, 179 | predicate: func(e Element) bool { return e.Serial() != "b" }, 180 | result: []Element{ 181 | testElement{serial: "a"}, 182 | }, 183 | }, 184 | { 185 | name: "three element slice (remove first)", 186 | data: []Element{ 187 | testElement{serial: "a"}, 188 | testElement{serial: "b"}, 189 | testElement{serial: "c"}, 190 | }, 191 | predicate: func(e Element) bool { return e.Serial() != "a" }, 192 | result: []Element{ 193 | testElement{serial: "c"}, 194 | testElement{serial: "b"}, 195 | }, 196 | }, 197 | { 198 | name: "three element slice (remove middle)", 199 | data: []Element{ 200 | testElement{serial: "a"}, 201 | testElement{serial: "b"}, 202 | testElement{serial: "c"}, 203 | }, 204 | predicate: func(e Element) bool { return e.Serial() != "b" }, 205 | result: []Element{ 206 | testElement{serial: "a"}, 207 | testElement{serial: "c"}, 208 | }, 209 | }, 210 | { 211 | name: "three element slice (remove last)", 212 | data: []Element{ 213 | testElement{serial: "a"}, 214 | testElement{serial: "b"}, 215 | testElement{serial: "c"}, 216 | }, 217 | predicate: func(e Element) bool { return e.Serial() != "c" }, 218 | result: []Element{ 219 | testElement{serial: "a"}, 220 | testElement{serial: "b"}, 221 | }, 222 | }, 223 | { 224 | name: "three element slice (remove first two)", 225 | data: []Element{ 226 | testElement{serial: "a"}, 227 | testElement{serial: "b"}, 228 | testElement{serial: "c"}, 229 | }, 230 | predicate: func(e Element) bool { return e.Serial() != "a" && e.Serial() != "b" }, 231 | result: []Element{ 232 | testElement{serial: "c"}, 233 | }, 234 | }, 235 | { 236 | name: "three element slice (remove last two)", 237 | data: []Element{ 238 | testElement{serial: "a"}, 239 | testElement{serial: "b"}, 240 | testElement{serial: "c"}, 241 | }, 242 | predicate: func(e Element) bool { return e.Serial() != "b" && e.Serial() != "c" }, 243 | result: []Element{ 244 | testElement{serial: "a"}, 245 | }, 246 | }, 247 | } { 248 | t.Run(tc.name, func(t *testing.T) { 249 | SliceFilter(&tc.data, tc.predicate) 250 | if !elementsEqual(tc.data, tc.result) { 251 | t.Errorf("expected %v, got %v", tc.result, tc.data) 252 | } 253 | }) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /list/synthesizer.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gioui.org/layout" 7 | ) 8 | 9 | // Synthesis holds the results of transforming a slice of Elements 10 | // with a Synthesizer hook. 11 | type Synthesis struct { 12 | // Elements holds the resulting elements. 13 | Elements []Element 14 | // SerialToIndex maps the serial of an element to the index it 15 | // occupies within the Elements slice. 16 | SerialToIndex map[Serial]int 17 | // ToSourceIndicies maps each index in Elements to the index of 18 | // the element that generated it when given to the Synthesizer. 19 | // It is always true that Elements[i] was synthesized from 20 | // Source[ToSourceIndicies[i]]. 21 | ToSourceIndicies []int 22 | // The source elements. 23 | Source []Element 24 | } 25 | 26 | func (s Synthesis) String() string { 27 | return fmt.Sprintf("{Elements: %v}", s.Elements) 28 | } 29 | 30 | // SerialAt returns the serial at the given index within the Source slice, 31 | // if there is one. 32 | func (s Synthesis) SerialAt(index int) Serial { 33 | if index < 0 || index >= len(s.Source) { 34 | return NoSerial 35 | } 36 | return s.Source[index].Serial() 37 | } 38 | 39 | // ViewportToSerials converts the First and Count fields of the provided 40 | // viewport into a pair of serials representing the range of elements 41 | // visible within that viewport. 42 | func (s Synthesis) ViewportToSerials(viewport layout.Position) (Serial, Serial) { 43 | if len(s.ToSourceIndicies) < 1 { 44 | return NoSerial, NoSerial 45 | } 46 | if viewport.First >= len(s.ToSourceIndicies) { 47 | viewport.First = len(s.ToSourceIndicies) - 1 48 | } else if viewport.First < 0 { 49 | viewport.First = 0 50 | } 51 | startSrcIdx := s.ToSourceIndicies[viewport.First] 52 | startSerial := SerialAtOrBefore(s.Source, startSrcIdx) 53 | lastIndex := len(s.ToSourceIndicies) - 1 54 | vpLastIndex := max(0, viewport.First+viewport.Count-1) 55 | endSrcIdx := s.ToSourceIndicies[min(vpLastIndex, lastIndex)] 56 | endSerial := SerialAtOrAfter(s.Source, endSrcIdx) 57 | return startSerial, endSerial 58 | } 59 | 60 | // Synthesize applies a Synthesizer to a slice of elements, returning 61 | // the resulting slice of elements as well as a mapping from the index 62 | // of each resulting element to the input element that generated it. 63 | func Synthesize(elements []Element, synth Synthesizer) Synthesis { 64 | var s Synthesis 65 | s.Source = elements 66 | for i, elem := range elements { 67 | var ( 68 | previous Element 69 | next Element 70 | ) 71 | if i > 0 { 72 | previous = elements[i-1] 73 | } else { 74 | previous = Start{} 75 | } 76 | if i < len(elements)-1 { 77 | next = elements[i+1] 78 | } else { 79 | next = End{} 80 | } 81 | synthesized := synth(previous, elem, next) 82 | // Mark that each of these synthesized elements came from the 83 | // raw element at index i. 84 | for range synthesized { 85 | s.ToSourceIndicies = append(s.ToSourceIndicies, i) 86 | } 87 | s.Elements = append(s.Elements, synthesized...) 88 | } 89 | s.SerialToIndex = make(map[Serial]int) 90 | for i, e := range s.Elements { 91 | if e.Serial() != NoSerial { 92 | s.SerialToIndex[e.Serial()] = i 93 | } 94 | } 95 | return s 96 | } 97 | -------------------------------------------------------------------------------- /ninepatch/decode.go: -------------------------------------------------------------------------------- 1 | package ninepatch 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "gioui.org/layout" 8 | ) 9 | 10 | // DecodeNinePatch from source image. 11 | // 12 | // Note: Any colored pixel around the border will be considered a 9-Patch marker. 13 | func DecodeNinePatch(src image.Image) NinePatch { 14 | var ( 15 | b = src.Bounds() 16 | inset = PxInset{} 17 | x1, x2 = 0, 0 18 | y1, y2 = 0, 0 19 | ) 20 | right := walk(src, b.Max.X-1, layout.Vertical) 21 | if right.IsValid() { 22 | inset.Top = right.Start 23 | inset.Bottom = b.Max.Y - right.End 24 | } 25 | bottom := walk(src, b.Max.Y-1, layout.Horizontal) 26 | if bottom.IsValid() { 27 | inset.Left = bottom.Start 28 | inset.Right = b.Max.X - bottom.End 29 | } 30 | top := walk(src, 0, layout.Vertical) 31 | if top.IsValid() { 32 | y1, y2 = top.Start, b.Max.Y-top.End 33 | } 34 | left := walk(src, 0, layout.Horizontal) 35 | if left.IsValid() { 36 | x1, x2 = left.Start, b.Max.X-left.End 37 | } 38 | return NinePatch{ 39 | Image: eraseBorder(src), 40 | Content: inset, 41 | Grid: Grid{ 42 | Size: image.Point{ 43 | X: b.Dx(), 44 | Y: b.Dy(), 45 | }, 46 | X1: x1, X2: x2, 47 | Y1: y1, Y2: y2, 48 | }, 49 | } 50 | } 51 | 52 | // eraseBorder clears the 1px border around the image containing the 9-Patch 53 | // region specifiers (1px black lines). 54 | // 55 | // TODO(jfm) [performance]: type switch src to see if we can mutate it directly 56 | // and avoid copying it. 57 | // 58 | // The goal of loading a 9-Patch image is, at least ostensibly, to use the 59 | // NinePatch type. It would be unexpected to then want that data for something 60 | // else, post NinePatch allocation. 61 | // 62 | // However, if that were the case then mutating the src may be a bad idea. 63 | // 64 | // TODO(jfm) [performance]: current implemenation leaves 1px border of 65 | // transparent pixels, which consumes memory for no gain. 66 | func eraseBorder(src image.Image) *image.NRGBA { 67 | var ( 68 | b = src.Bounds() 69 | out = image.NewNRGBA(b) 70 | ) 71 | // Copy image data. 72 | for xx := b.Min.X; xx < b.Max.X; xx++ { 73 | for yy := b.Min.Y; yy < b.Max.Y; yy++ { 74 | out.Set(xx, yy, src.At(xx, yy)) 75 | } 76 | } 77 | // Clear out the borders which contain 1px 9-Patch stretch region 78 | // identifiers. 79 | for xx := b.Min.X; xx < b.Max.X; xx++ { 80 | out.Set(xx, b.Min.Y, color.NRGBA{}) 81 | out.Set(xx, b.Max.Y-1, color.NRGBA{}) 82 | } 83 | for yy := b.Min.Y; yy < b.Max.Y; yy++ { 84 | out.Set(b.Min.X, yy, color.NRGBA{}) 85 | out.Set(b.Max.X-1, yy, color.NRGBA{}) 86 | } 87 | return out 88 | } 89 | 90 | // line encodes a one-dimensional line. 91 | type line struct { 92 | Start, End int 93 | } 94 | 95 | func (l line) IsValid() bool { 96 | return l.Start > -1 && l.End > -1 97 | } 98 | 99 | // walk pixels in the source image, along the specified main axis, and offset 100 | // along the cross axis, returning a line that describes the length of any 101 | // squence of colored pixels. 102 | // 103 | // NOTE(jfm): in time we may want tighter control over what is considered 104 | // "colored". For now, any color that is not zero will suffice. 105 | func walk(src image.Image, offset int, axis layout.Axis) line { 106 | var ( 107 | end = axis.Convert(src.Bounds().Max).X 108 | line = line{Start: -1, End: -1} 109 | ) 110 | for ii := 0; ii < end; ii++ { 111 | pt := axis.Convert(image.Point{X: ii, Y: offset}) 112 | r, g, b, a := src.At(pt.X, pt.Y).RGBA() 113 | var ( 114 | colorIsSet = r > 0 || g > 0 || b > 0 || a > 0 115 | startIsSet = line.Start > -1 116 | endIsSet = line.End > -1 117 | ) 118 | if colorIsSet && !startIsSet { 119 | line.Start = ii 120 | } 121 | if !colorIsSet && startIsSet { 122 | line.End = ii 123 | } 124 | if startIsSet && endIsSet { 125 | break 126 | } 127 | } 128 | return line 129 | } 130 | -------------------------------------------------------------------------------- /ninepatch/grid.go: -------------------------------------------------------------------------------- 1 | package ninepatch 2 | 3 | import "image" 4 | 5 | // Grid describes the stretchable regions of a 9-Patch as 3x3 grid divided 6 | // by 4 lines. 7 | type Grid struct { 8 | // Size specifies the total dimensions including static and stretch regions. 9 | Size image.Point 10 | // X1 is the distance in pixels before the stretchable region along the X axis. 11 | // X2 is the distance in pixels after the stretchable region along the X axis. 12 | X1, X2 int 13 | // Y1 is the distance in pixels before the stretchable region along the Y axis. 14 | // Y2 is the distance in pixels after the stretchable region along the Y axis. 15 | Y1, Y2 int 16 | } 17 | 18 | // Static returns the statically known dimensions (the corners). 19 | func (g Grid) Static() image.Point { 20 | return image.Point{ 21 | X: g.X1 + g.X2, 22 | Y: g.Y1 + g.Y2, 23 | } 24 | } 25 | 26 | // Stretch returns the stretch dimensions (the space between the corners). 27 | func (g Grid) Stretch() image.Point { 28 | stretch := g.Size.Sub(g.Static()) 29 | if stretch.X < 0 { 30 | stretch.X = 0 31 | } 32 | if stretch.Y < 0 { 33 | stretch.Y = 0 34 | } 35 | return stretch 36 | } 37 | -------------------------------------------------------------------------------- /ninepatch/ninepatch.go: -------------------------------------------------------------------------------- 1 | // Package ninepatch implements 9-Patch image rendering in Gio. 2 | // https://developer.android.com/guide/topics/graphics/drawables#nine-patch 3 | package ninepatch 4 | 5 | import ( 6 | "image" 7 | "math" 8 | "sync" 9 | 10 | "gioui.org/f32" 11 | "gioui.org/layout" 12 | "gioui.org/op" 13 | "gioui.org/op/clip" 14 | "gioui.org/op/paint" 15 | "gioui.org/unit" 16 | ) 17 | 18 | type ( 19 | C = layout.Context 20 | D = layout.Dimensions 21 | ) 22 | 23 | // NinePatch can lay out a 9-Patch image as the background for another widget. 24 | // 25 | // Note: create a new instance per 9-Patch image. Changing the image.Image 26 | // after the first layout will have no effect because the paint.ImageOp is 27 | // cached. 28 | type NinePatch struct { 29 | // Image is the backing image of the 9-Patch. 30 | image.Image 31 | // Grid describes the stretchable regions of the 9-Patch. 32 | Grid Grid 33 | // Inset describes content insets defined by the black lines on the bottom 34 | // and right of the 9-Patch image. 35 | Content PxInset 36 | // Cache the image. 37 | cache paint.ImageOp 38 | once sync.Once 39 | } 40 | 41 | // PxInset describes an inset in pixels. 42 | type PxInset struct { 43 | Top, Bottom, Left, Right int 44 | } 45 | 46 | func (p PxInset) ToDp(m unit.Metric) layout.Inset { 47 | return layout.Inset{ 48 | Top: unit.Dp(float32(p.Top) * m.PxPerDp), 49 | Bottom: unit.Dp(float32(p.Bottom) * m.PxPerDp), 50 | Left: unit.Dp(float32(p.Left) * m.PxPerDp), 51 | Right: unit.Dp(float32(p.Right) * m.PxPerDp), 52 | } 53 | } 54 | 55 | // Patch describes the position and size of single patch in a 9-Patch image. 56 | type Patch struct { 57 | Offset image.Point 58 | Size image.Point 59 | } 60 | 61 | // Region describes how to lay out a particular patch of a 9-Patch image. 62 | type Region struct { 63 | // Source is the patch relative to the source image. 64 | Source Patch 65 | // Stretched is the patch relative to the layout. 66 | Stretched Patch 67 | } 68 | 69 | // Layout the patch of the provided ImageOp described by the Region, scaling 70 | // as needed. 71 | func (r Region) Layout(gtx C, src paint.ImageOp) D { 72 | // Set the paint material to our source texture. 73 | src.Add(gtx.Ops) 74 | 75 | // If we need to scale the source image to cover the content area, do so: 76 | if r.Stretched.Size != r.Source.Size { 77 | defer op.Affine(f32.Affine2D{}.Scale(layout.FPt(r.Stretched.Offset), f32.Point{ 78 | X: float32(r.Stretched.Size.X) / float32(r.Source.Size.X), 79 | Y: float32(r.Stretched.Size.Y) / float32(r.Source.Size.Y), 80 | })).Push(gtx.Ops).Pop() 81 | } 82 | 83 | // Shift layout to the origin of the region that we are covering, but compensate 84 | // for the fact that we're going to be reaching to an arbitrary point in the 85 | // source image. This logic aligns the origin of the important region of the 86 | // source image with the origin of the region that we're laying out. 87 | defer op.Offset(r.Stretched.Offset.Sub(r.Source.Offset)).Push(gtx.Ops).Pop() 88 | 89 | // Clip the scaled image to the bounds of the area we need to cover. 90 | defer clip.Rect(image.Rectangle{ 91 | Min: r.Source.Offset, 92 | Max: r.Source.Size.Add(r.Source.Offset), 93 | }).Push(gtx.Ops).Pop() 94 | 95 | // Paint the scaled, clipped image. 96 | paint.PaintOp{}.Add(gtx.Ops) 97 | 98 | return D{Size: r.Stretched.Size} 99 | } 100 | 101 | // DefaultScale is a standard 72 DPI. 102 | // Inverse of `widget.Image`, shrink as the screen becomes _less_ dense. 103 | const DefaultScale = 1 / float32(160.0/72.0) 104 | 105 | // Layout the provided widget with the NinePatch as a background. 106 | func (n NinePatch) Layout(gtx C, w layout.Widget) D { 107 | n.once.Do(func() { 108 | n.cache = paint.NewImageOp(n.Image) 109 | }) 110 | 111 | // TODO(jfm) [performance]: cache scaled grid instead of recomputing every 112 | // frame. 113 | 114 | // TODO(jfm): publicize scale factor in a way that is obvious to use and 115 | // tested. 116 | 117 | scale := DefaultScale 118 | 119 | // Handle screen density. 120 | scale *= gtx.Metric.PxPerDp 121 | 122 | var ( 123 | src = n.Grid 124 | str = Grid{ 125 | X1: int(math.Round(float64(src.X1) * float64(scale))), 126 | X2: int(math.Round(float64(src.X2) * float64(scale))), 127 | Y1: int(math.Round(float64(src.Y1) * float64(scale))), 128 | Y2: int(math.Round(float64(src.Y2) * float64(scale))), 129 | } 130 | inset = layout.Inset{ 131 | Left: unit.Dp(float32(n.Content.Left) * scale), 132 | Right: unit.Dp(float32(n.Content.Right) * scale), 133 | Top: unit.Dp(float32(n.Content.Top) * scale), 134 | Bottom: unit.Dp(float32(n.Content.Bottom) * scale), 135 | } 136 | ) 137 | 138 | // Layout content in macro to compute it's dimensions. 139 | // These dimensions are needed to figure out how much stretch is needed. 140 | macro := op.Record(gtx.Ops) 141 | dims := inset.Layout(gtx, w) 142 | call := macro.Stop() 143 | 144 | str.Size = dims.Size 145 | 146 | // Handle tiny content: at least stretch by the amount that original does. 147 | if str.Stretch().Y <= src.Stretch().Y { 148 | dims.Size.Y = dims.Size.Y - str.Stretch().Y + src.Stretch().Y 149 | str.Size.Y = str.Size.Y - str.Stretch().Y + src.Stretch().Y 150 | } 151 | if str.Stretch().X <= src.Stretch().X { 152 | dims.Size.X = dims.Size.X - str.Stretch().X + src.Stretch().X 153 | str.Size.X = str.Size.X - str.Stretch().X + src.Stretch().X 154 | } 155 | 156 | // Layout each of the 9 patches. 157 | 158 | // upper left 159 | Region{ 160 | Source: Patch{ 161 | Size: image.Point{ 162 | X: src.X1, 163 | Y: src.Y1, 164 | }, 165 | }, 166 | Stretched: Patch{ 167 | Size: image.Point{ 168 | X: str.X1, 169 | Y: str.Y1, 170 | }, 171 | }, 172 | }.Layout(gtx, n.cache) 173 | 174 | // upper middle 175 | Region{ 176 | Source: Patch{ 177 | Size: image.Point{ 178 | X: src.Stretch().X, 179 | Y: src.Y1, 180 | }, 181 | Offset: image.Point{ 182 | X: src.X1, 183 | }, 184 | }, 185 | Stretched: Patch{ 186 | Size: image.Point{ 187 | X: str.Stretch().X, 188 | Y: str.Y1, 189 | }, 190 | Offset: image.Point{ 191 | X: str.X1, 192 | }, 193 | }, 194 | }.Layout(gtx, n.cache) 195 | 196 | // upper right 197 | Region{ 198 | Source: Patch{ 199 | Size: image.Point{ 200 | X: src.X2, 201 | Y: src.Y1, 202 | }, 203 | Offset: image.Point{ 204 | X: src.X1 + src.Stretch().X, 205 | }, 206 | }, 207 | Stretched: Patch{ 208 | Size: image.Point{ 209 | X: str.X2, 210 | Y: str.Y1, 211 | }, 212 | Offset: image.Point{ 213 | X: str.X1 + str.Stretch().X, 214 | }, 215 | }, 216 | }.Layout(gtx, n.cache) 217 | 218 | // middle left 219 | Region{ 220 | Source: Patch{ 221 | Size: image.Point{ 222 | X: src.X1, 223 | Y: src.Stretch().Y, 224 | }, 225 | Offset: image.Point{ 226 | Y: src.Y1, 227 | }, 228 | }, 229 | Stretched: Patch{ 230 | Size: image.Point{ 231 | X: str.X1, 232 | Y: str.Stretch().Y, 233 | }, 234 | Offset: image.Point{ 235 | Y: str.Y1, 236 | }, 237 | }, 238 | }.Layout(gtx, n.cache) 239 | 240 | // middle middle 241 | Region{ 242 | Source: Patch{ 243 | Size: image.Point{ 244 | X: src.Stretch().X, 245 | Y: src.Stretch().Y, 246 | }, 247 | Offset: image.Point{ 248 | X: src.X1, 249 | Y: src.Y1, 250 | }, 251 | }, 252 | Stretched: Patch{ 253 | Size: image.Point{ 254 | X: str.Stretch().X, 255 | Y: str.Stretch().Y, 256 | }, 257 | Offset: image.Point{ 258 | X: str.X1, 259 | Y: str.Y1, 260 | }, 261 | }, 262 | }.Layout(gtx, n.cache) 263 | 264 | // middle right 265 | Region{ 266 | Source: Patch{ 267 | Size: image.Point{ 268 | X: src.X2, 269 | Y: src.Stretch().Y, 270 | }, 271 | Offset: image.Point{ 272 | X: src.X1 + src.Stretch().X, 273 | Y: src.Y1, 274 | }, 275 | }, 276 | Stretched: Patch{ 277 | Size: image.Point{ 278 | X: str.X2, 279 | Y: str.Stretch().Y, 280 | }, 281 | Offset: image.Point{ 282 | X: str.X1 + str.Stretch().X, 283 | Y: str.Y1, 284 | }, 285 | }, 286 | }.Layout(gtx, n.cache) 287 | 288 | // lower left 289 | Region{ 290 | Source: Patch{ 291 | Size: image.Point{ 292 | X: src.X1, 293 | Y: src.Y2, 294 | }, 295 | Offset: image.Point{ 296 | Y: src.Y1 + src.Stretch().Y, 297 | }, 298 | }, 299 | Stretched: Patch{ 300 | Size: image.Point{ 301 | X: str.X1, 302 | Y: str.Y2, 303 | }, 304 | Offset: image.Point{ 305 | Y: str.Y1 + str.Stretch().Y, 306 | }, 307 | }, 308 | }.Layout(gtx, n.cache) 309 | 310 | // lower middle 311 | Region{ 312 | Source: Patch{ 313 | Size: image.Point{ 314 | X: src.Stretch().X, 315 | Y: src.Y2, 316 | }, 317 | Offset: image.Point{ 318 | X: src.X1, 319 | Y: src.Y1 + src.Stretch().Y, 320 | }, 321 | }, 322 | Stretched: Patch{ 323 | Size: image.Point{ 324 | X: str.Stretch().X, 325 | Y: str.Y2, 326 | }, 327 | Offset: image.Point{ 328 | X: str.X1, 329 | Y: str.Y1 + str.Stretch().Y, 330 | }, 331 | }, 332 | }.Layout(gtx, n.cache) 333 | 334 | // lower right 335 | Region{ 336 | Source: Patch{ 337 | Size: image.Point{ 338 | X: src.X2, 339 | Y: src.Y2, 340 | }, 341 | Offset: image.Point{ 342 | Y: src.Y1 + src.Stretch().Y, 343 | X: src.X1 + src.Stretch().X, 344 | }, 345 | }, 346 | Stretched: Patch{ 347 | Size: image.Point{ 348 | X: str.X2, 349 | Y: str.Y2, 350 | }, 351 | Offset: image.Point{ 352 | Y: str.Y1 + str.Stretch().Y, 353 | X: str.X1 + str.Stretch().X, 354 | }, 355 | }, 356 | }.Layout(gtx, n.cache) 357 | 358 | call.Add(gtx.Ops) 359 | 360 | return dims 361 | } 362 | -------------------------------------------------------------------------------- /ninepatch/ninepatch_test.go: -------------------------------------------------------------------------------- 1 | package ninepatch 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "image/png" 8 | "testing" 9 | 10 | "gioui.org/layout" 11 | "gioui.org/unit" 12 | "git.sr.ht/~gioverse/chat/res" 13 | ) 14 | 15 | var ( 16 | platocookie = open("9-Patch/iap_platocookie_asset_2.png") 17 | hotdog = open("9-Patch/iap_hotdog_asset.png") 18 | ) 19 | 20 | // TestDecodeNinePatch tests that 9-Patch data is successfully read from a 21 | // source image. 22 | func TestDecodeNinePatch(t *testing.T) { 23 | for _, tt := range []struct { 24 | Label string 25 | Src image.Image 26 | NP NP 27 | }{ 28 | { 29 | Label: "empty image", 30 | Src: NewImg(image.Pt(0, 0)), 31 | NP: NP{}, 32 | }, 33 | { 34 | // An image with no stretch markers will be considered "completely 35 | // static", and therefore will not resize in any way. 36 | // 37 | // An image with no content inset will have no padding around 38 | // content. 39 | // 40 | // Both are still "valid" 9-Patch images, however unusable. 41 | Label: "image with no border", 42 | Src: NewImg(image.Pt(100, 100)), 43 | NP: NP{Grid: Grid{Size: image.Point{X: 100, Y: 100}}}, 44 | }, 45 | { 46 | Label: "image with no content inset", 47 | Src: NewImg(image.Pt(100, 100)).TopBorder(25, 50).LeftBorder(25, 50), 48 | NP: NP{ 49 | Grid: Grid{ 50 | Size: image.Point{X: 100, Y: 100}, 51 | X1: 25, X2: 25, 52 | Y1: 25, Y2: 25, 53 | }, 54 | }, 55 | }, 56 | { 57 | Label: "image with no stretch regions", 58 | Src: NewImg(image.Pt(100, 100)).BottomBorder(25, 50).RightBorder(25, 50), 59 | NP: NP{ 60 | Content: layout.Inset{ 61 | Top: unit.Dp(25), 62 | Right: unit.Dp(25), 63 | Bottom: unit.Dp(25), 64 | Left: unit.Dp(25), 65 | }, 66 | Grid: Grid{Size: image.Point{X: 100, Y: 100}}, 67 | }, 68 | }, 69 | { 70 | Label: "image with content inset and stretch regions", 71 | Src: NewImg(image.Pt(100, 100)). 72 | TopBorder(25, 50). 73 | LeftBorder(25, 50). 74 | BottomBorder(25, 50). 75 | RightBorder(25, 50), 76 | NP: NP{ 77 | Content: layout.Inset{ 78 | Top: unit.Dp(25), 79 | Right: unit.Dp(25), 80 | Bottom: unit.Dp(25), 81 | Left: unit.Dp(25), 82 | }, 83 | Grid: Grid{ 84 | Size: image.Point{X: 100, Y: 100}, 85 | X1: 25, X2: 25, 86 | Y1: 25, Y2: 25, 87 | }, 88 | }, 89 | }, 90 | { 91 | Label: "platocookie", 92 | Src: platocookie, 93 | NP: NP{ 94 | Content: layout.Inset{ 95 | Top: unit.Dp(31), 96 | Right: unit.Dp(70), 97 | Bottom: unit.Dp(27), 98 | Left: unit.Dp(70), 99 | }, 100 | Grid: Grid{ 101 | Size: image.Point{ 102 | X: platocookie.Bounds().Dx(), 103 | Y: platocookie.Bounds().Dy(), 104 | }, 105 | X1: 86, X2: 61, 106 | Y1: 55, Y2: 47, 107 | }, 108 | }, 109 | }, 110 | { 111 | Label: "hotdog", 112 | Src: hotdog, 113 | NP: NP{ 114 | Content: layout.Inset{ 115 | Top: unit.Dp(31), 116 | Right: unit.Dp(70), 117 | Bottom: unit.Dp(27), 118 | Left: unit.Dp(70), 119 | }, 120 | Grid: Grid{ 121 | Size: image.Point{ 122 | X: hotdog.Bounds().Dx(), 123 | Y: hotdog.Bounds().Dy(), 124 | }, 125 | X1: 86, X2: 61, 126 | Y1: 55, Y2: 47, 127 | }, 128 | }, 129 | }, 130 | } { 131 | t.Run(tt.Label, func(t *testing.T) { 132 | np := DecodeNinePatch(tt.Src) 133 | got := NP{ 134 | Content: np.Content.ToDp(unit.Metric{ 135 | PxPerDp: 1, 136 | PxPerSp: 1, 137 | }), 138 | Grid: np.Grid, 139 | } 140 | want := tt.NP 141 | if got != want { 142 | t.Fatalf("\n got:{%v} \nwant:{%v}\n", got, want) 143 | } 144 | }) 145 | } 146 | } 147 | 148 | // NP wraps the layout data for a NinePatch for convenient equality testing. 149 | type NP struct { 150 | Content layout.Inset 151 | Grid 152 | } 153 | 154 | func (np NP) String() string { 155 | return fmt.Sprintf( 156 | "Content: %+v, Stretch: {X1:%dpx, X2:%dpx, Y1:%dpx, Y2:%dpx}", 157 | np.Content, np.X1, np.X2, np.Y1, np.Y2) 158 | } 159 | 160 | // Img wraps an image.NRGBA with mutators for creating mock 9-Patch images. 161 | type Img struct { 162 | *image.NRGBA 163 | } 164 | 165 | // NewImg allocates an Img for the given size. 166 | func NewImg(sz image.Point) *Img { 167 | return &Img{ 168 | NRGBA: image.NewNRGBA(image.Rectangle{Max: sz}), 169 | } 170 | } 171 | 172 | // LeftBorder renders a line along the first column of pixels. 173 | func (img *Img) LeftBorder(start, size int) *Img { 174 | for ii := start; ii < start+size-1; ii++ { 175 | img.Set(img.Bounds().Min.X, ii, color.NRGBA{A: 255}) 176 | } 177 | return img 178 | } 179 | 180 | // RightBorder renders a line along the last column of pixels. 181 | func (img *Img) RightBorder(start, size int) *Img { 182 | for ii := start; ii < start+size-1; ii++ { 183 | img.Set(img.Bounds().Max.X-1, ii, color.NRGBA{A: 255}) 184 | } 185 | return img 186 | } 187 | 188 | // TopBorder renders a line along the first row of pixels. 189 | func (img *Img) TopBorder(start, size int) *Img { 190 | for ii := start; ii < start+size-1; ii++ { 191 | img.Set(ii, img.Bounds().Min.Y, color.NRGBA{A: 255}) 192 | } 193 | return img 194 | } 195 | 196 | // BottomBorder renders a line along the last row of pixels. 197 | func (img *Img) BottomBorder(start, size int) *Img { 198 | for ii := start; ii < start+size-1; ii++ { 199 | img.Set(ii, img.Bounds().Max.Y-1, color.NRGBA{A: 255}) 200 | } 201 | return img 202 | } 203 | 204 | // open and decode a png from resources. Panic on failure. 205 | func open(path string) image.Image { 206 | imgf, err := res.Resources.Open(path) 207 | if err != nil { 208 | panic(fmt.Errorf("opening 9-Patch image: %v", err)) 209 | } 210 | defer imgf.Close() 211 | img, err := png.Decode(imgf) 212 | if err != nil { 213 | panic(fmt.Errorf("decoding png: %v", err)) 214 | } 215 | return img 216 | } 217 | -------------------------------------------------------------------------------- /profile/profile.go: -------------------------------------------------------------------------------- 1 | // Package profile unifies the profiling api between Gio profiler and pkg/profile. 2 | package profile 3 | 4 | import ( 5 | "log" 6 | 7 | "gioui.org/layout" 8 | "gioui.org/x/profiling" 9 | "github.com/pkg/profile" 10 | ) 11 | 12 | // Profiler unifies the profiling api between Gio profiler and pkg/profile. 13 | type Profiler struct { 14 | Type Opt 15 | Starter func(p *profile.Profile) 16 | Stopper func() 17 | Recorder func(gtx layout.Context) 18 | } 19 | 20 | // Start profiling. 21 | func (pfn *Profiler) Start() { 22 | if pfn.Starter != nil && pfn.Type != Gio { 23 | pfn.Stopper = profile.Start(pfn.Starter).Stop 24 | } else if pfn.Type == Gio { 25 | pfn.Starter(nil) 26 | } 27 | } 28 | 29 | // Stop profiling. 30 | func (pfn *Profiler) Stop() { 31 | if pfn.Stopper != nil { 32 | pfn.Stopper() 33 | } 34 | } 35 | 36 | // Record GUI stats per frame. 37 | func (pfn Profiler) Record(gtx layout.Context) { 38 | if pfn.Recorder != nil { 39 | pfn.Recorder(gtx) 40 | } 41 | } 42 | 43 | // Opt specifies the various profiling options. 44 | type Opt string 45 | 46 | const ( 47 | None Opt = "none" 48 | CPU Opt = "cpu" 49 | Memory Opt = "mem" 50 | Block Opt = "block" 51 | Goroutine Opt = "goroutine" 52 | Mutex Opt = "mutex" 53 | Trace Opt = "trace" 54 | Gio Opt = "gio" 55 | ) 56 | 57 | // NewProfiler creates a profiler based on the selected option. 58 | func (p Opt) NewProfiler() Profiler { 59 | switch p { 60 | case "", None: 61 | return Profiler{Type: p} 62 | case CPU: 63 | return Profiler{Type: p, Starter: profile.CPUProfile} 64 | case Memory: 65 | return Profiler{Type: p, Starter: profile.MemProfile} 66 | case Block: 67 | return Profiler{Type: p, Starter: profile.BlockProfile} 68 | case Goroutine: 69 | return Profiler{Type: p, Starter: profile.GoroutineProfile} 70 | case Mutex: 71 | return Profiler{Type: p, Starter: profile.MutexProfile} 72 | case Trace: 73 | return Profiler{Type: p, Starter: profile.TraceProfile} 74 | case Gio: 75 | var ( 76 | recorder *profiling.CSVTimingRecorder 77 | err error 78 | ) 79 | return Profiler{ 80 | Type: p, 81 | Starter: func(*profile.Profile) { 82 | recorder, err = profiling.NewRecorder(nil) 83 | if err != nil { 84 | log.Printf("starting profiler: %v", err) 85 | } 86 | }, 87 | Stopper: func() { 88 | if recorder == nil { 89 | return 90 | } 91 | if err := recorder.Stop(); err != nil { 92 | log.Printf("stopping profiler: %v", err) 93 | } 94 | }, 95 | Recorder: func(gtx layout.Context) { 96 | if recorder == nil { 97 | return 98 | } 99 | recorder.Profile(gtx) 100 | }, 101 | } 102 | } 103 | return Profiler{} 104 | } 105 | -------------------------------------------------------------------------------- /res/9-Patch/iap_hotdog_asset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioverse/chat/0e60eda7d46019fa9e8f3724121f9684017722c2/res/9-Patch/iap_hotdog_asset.png -------------------------------------------------------------------------------- /res/9-Patch/iap_platocookie_asset_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gioverse/chat/0e60eda7d46019fa9e8f3724121f9684017722c2/res/9-Patch/iap_platocookie_asset_2.png -------------------------------------------------------------------------------- /res/res.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import "embed" 4 | 5 | //go:embed 9-Patch 6 | var Resources embed.FS 7 | -------------------------------------------------------------------------------- /row-manager.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import "gioui.org/layout" 4 | 5 | // RowID uniquely identifies a row of content. 6 | type RowID string 7 | 8 | // NoID is a special ID that can be used by Rows that do not require 9 | // a unique identifier. Only stateless rows may go without a unique 10 | // identifier. 11 | const NoID = RowID("") 12 | 13 | // Row is a type that can be presented by a RowManager. 14 | type Row interface { 15 | // ID returns a unique identifier for the Row, if it has one. 16 | // In order for a Row to be stateful, it _must_ return a unique 17 | // ID. Rows that are not stateful may return the special ID 18 | // NoID to indicate that they do not need any state allocated 19 | // for them. 20 | ID() RowID 21 | } 22 | 23 | // Presenter is a function that can transform the data for a Row 24 | // into a widget to be laid out in the user interface. 25 | type Presenter func(current Row, state interface{}) layout.Widget 26 | 27 | // Allocator is a function that can allocate the appropriate state 28 | // type for a given Row. 29 | type Allocator func(current Row) (state interface{}) 30 | 31 | // RowManager presents heterogenous Row data. Each row could represent 32 | // any element of an interface that occupies a horizontal slice of 33 | // screen real-estate. 34 | type RowManager struct { 35 | // Rows is the list of data to present. 36 | Rows []Row 37 | // presenter is a function that can transform a single Row into 38 | // a presentable widget. 39 | presenter Presenter 40 | // allocator is a function that can instantiate the state for a particular 41 | // Row. 42 | allocator Allocator 43 | // rowState is a map storing the state for the Rows managed 44 | // by the manager. 45 | rowState map[RowID]interface{} 46 | } 47 | 48 | // NewManager constructs a manager with the given allocator and presenter. 49 | func NewManager(allocator Allocator, presenter Presenter) *RowManager { 50 | return &RowManager{ 51 | presenter: presenter, 52 | allocator: allocator, 53 | rowState: make(map[RowID]interface{}), 54 | } 55 | } 56 | 57 | // Layout the Row at position index within the manager's Row list. 58 | func (m *RowManager) Layout(gtx layout.Context, index int) layout.Dimensions { 59 | data := m.Rows[index] 60 | id := data.ID() 61 | state, ok := m.rowState[id] 62 | if !ok && id != NoID { 63 | state = m.allocator(data) 64 | m.rowState[id] = state 65 | } 66 | widget := m.presenter(data, state) 67 | return widget(gtx) 68 | } 69 | 70 | // Len returns the number of rows managed by this manager. 71 | func (m *RowManager) Len() int { 72 | return len(m.Rows) 73 | } 74 | -------------------------------------------------------------------------------- /widget/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package widget provides stateful widget types for building chat interfaces. 3 | */ 4 | package widget 5 | -------------------------------------------------------------------------------- /widget/image.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/op/paint" 7 | ) 8 | 9 | // CachedImage is a cacheable image operation. 10 | type CachedImage struct { 11 | op paint.ImageOp 12 | ch bool 13 | } 14 | 15 | // Reload tells the CachedImage to repopulate the cache. 16 | func (img *CachedImage) Reload() { 17 | img.ch = true 18 | } 19 | 20 | // Cache the image if it is not already. 21 | // First call will compute the image operation, subsequent calls will noop. 22 | // When reloaded, cache will re-populated on next invocation. 23 | func (img *CachedImage) Cache(src image.Image) *CachedImage { 24 | if img == nil || src == nil { 25 | return img 26 | } 27 | if img.op == (paint.ImageOp{}) || img.changed() { 28 | img.op = paint.NewImageOp(src) 29 | } 30 | return img 31 | } 32 | 33 | // Op returns the concrete image operation. 34 | func (img CachedImage) Op() paint.ImageOp { 35 | return paint.ImageOp(img.op) 36 | } 37 | 38 | // changed reports whether the underlying image has changed and therefore 39 | // should be cached again. 40 | func (img *CachedImage) changed() bool { 41 | defer func() { img.ch = false }() 42 | return img.ch 43 | } 44 | -------------------------------------------------------------------------------- /widget/material/bubble.go: -------------------------------------------------------------------------------- 1 | package material 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "gioui.org/layout" 8 | "gioui.org/op/clip" 9 | "gioui.org/op/paint" 10 | "gioui.org/unit" 11 | "gioui.org/widget/material" 12 | ) 13 | 14 | // BubbleStyle defines a colored surface with (optionally) rounded corners. 15 | type BubbleStyle struct { 16 | // The radius of the corners of the surface. 17 | // Non-rounded rectangles can just provide a zero. 18 | CornerRadius unit.Dp 19 | Color color.NRGBA 20 | } 21 | 22 | // Bubble creates a Bubble style for the provided theme with the theme 23 | // background color and rounded corners. 24 | func Bubble(th *material.Theme) BubbleStyle { 25 | return BubbleStyle{ 26 | CornerRadius: unit.Dp(12), 27 | Color: th.Bg, 28 | } 29 | } 30 | 31 | // Layout renders the BubbleStyle, beneath the provided widget. 32 | func (c BubbleStyle) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions { 33 | return layout.Stack{}.Layout(gtx, 34 | layout.Expanded(func(gtx layout.Context) layout.Dimensions { 35 | surface := clip.UniformRRect(image.Rectangle{ 36 | Max: gtx.Constraints.Min, 37 | }, gtx.Dp(c.CornerRadius)) 38 | paint.FillShape(gtx.Ops, c.Color, surface.Op(gtx.Ops)) 39 | return layout.Dimensions{Size: gtx.Constraints.Min} 40 | }), 41 | layout.Stacked(w), 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /widget/material/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package material provides material design building blocks for chat interfaces. 3 | */ 4 | package material 5 | -------------------------------------------------------------------------------- /widget/material/image.go: -------------------------------------------------------------------------------- 1 | package material 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/op/clip" 9 | "gioui.org/op/paint" 10 | "gioui.org/unit" 11 | "gioui.org/widget" 12 | ) 13 | 14 | // Image lays out an image with optionally rounded corners. 15 | type Image struct { 16 | widget.Image 17 | widget.Clickable 18 | // Radii specifies the amount of rounding. 19 | Radii unit.Dp 20 | // Width and Height specify respective dimensions. 21 | // If left empty, dimensions will be unconstrained. 22 | Width, Height unit.Dp 23 | } 24 | 25 | // Layout the image. 26 | func (img Image) Layout(gtx layout.Context) layout.Dimensions { 27 | if img.Width > 0 { 28 | gtx.Constraints.Max.X = gtx.Constraints.Constrain(image.Pt(gtx.Dp(img.Width), 0)).X 29 | } 30 | if img.Height > 0 { 31 | gtx.Constraints.Max.Y = gtx.Constraints.Constrain(image.Pt(0, gtx.Dp(img.Height))).Y 32 | } 33 | if img.Image.Src == (paint.ImageOp{}) { 34 | return D{Size: gtx.Constraints.Max} 35 | } 36 | macro := op.Record(gtx.Ops) 37 | dims := img.Image.Layout(gtx) 38 | call := macro.Stop() 39 | r := gtx.Dp(img.Radii) 40 | defer clip.RRect{ 41 | Rect: image.Rectangle{Max: dims.Size}, 42 | NE: r, NW: r, SE: r, SW: r, 43 | }.Push(gtx.Ops).Pop() 44 | call.Add(gtx.Ops) 45 | return dims 46 | } 47 | -------------------------------------------------------------------------------- /widget/material/message.go: -------------------------------------------------------------------------------- 1 | package material 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "gioui.org/layout" 8 | "gioui.org/op/paint" 9 | "gioui.org/unit" 10 | "gioui.org/widget" 11 | "gioui.org/widget/material" 12 | "gioui.org/x/richtext" 13 | chatlayout "git.sr.ht/~gioverse/chat/layout" 14 | "git.sr.ht/~gioverse/chat/ninepatch" 15 | chatwidget "git.sr.ht/~gioverse/chat/widget" 16 | "golang.org/x/exp/shiny/materialdesign/icons" 17 | ) 18 | 19 | // Note: the values choosen are a best-guess heuristic, open to change. 20 | var ( 21 | DefaultMaxImageHeight = unit.Dp(400) 22 | DefaultMaxMessageWidth = unit.Dp(600) 23 | DefaultAvatarSize = unit.Dp(24) 24 | DefaultDangerColor = color.NRGBA{R: 200, A: 255} 25 | ) 26 | 27 | // ErrorIcon is the material design outlined error indicator. 28 | var ErrorIcon *widget.Icon = func() *widget.Icon { 29 | icon, _ := widget.NewIcon(icons.AlertErrorOutline) 30 | return icon 31 | }() 32 | 33 | // FailedToSend is the message that is displayed to the user when there was a 34 | // problem sending a chat message. 35 | const FailedToSend = "Sending failed" 36 | 37 | type ( 38 | C = layout.Context 39 | D = layout.Dimensions 40 | ) 41 | 42 | // UserInfoStyle defines the presentation of information about a user. 43 | // It can present the user's name and avatar with a space between them. 44 | type UserInfoStyle struct { 45 | // Username configures the presentation of the user name text. 46 | Username material.LabelStyle 47 | // Avatar defines the image shown as the user's avatar. 48 | Avatar Image 49 | // Spacer is inserted between the username and avatar fields. 50 | layout.Spacer 51 | // Local controls the Left-to-Right ordering of layout. If false, 52 | // the Left-to-Right order will be: 53 | // - Avatar 54 | // - Spacer 55 | // - Username 56 | // If true, the order is reversed. 57 | Local bool 58 | } 59 | 60 | // UserInfo constructs a UserInfoStyle with sensible defaults. 61 | func UserInfo(th *material.Theme, interact *chatwidget.UserInfo, username string, avatar image.Image) UserInfoStyle { 62 | interact.Avatar.Cache(avatar) 63 | return UserInfoStyle{ 64 | Username: material.Body1(th, username), 65 | Avatar: Image{ 66 | Image: widget.Image{ 67 | Src: interact.Avatar.Op(), 68 | Fit: widget.Cover, 69 | Position: layout.Center, 70 | }, 71 | Radii: unit.Dp(8), 72 | Width: DefaultAvatarSize, 73 | Height: DefaultAvatarSize, 74 | }, 75 | Spacer: layout.Spacer{Width: unit.Dp(8)}, 76 | } 77 | } 78 | 79 | // Layout the user information. 80 | func (ui UserInfoStyle) Layout(gtx C) D { 81 | return layout.Flex{ 82 | Axis: layout.Horizontal, 83 | Alignment: layout.Middle, 84 | }.Layout(gtx, 85 | chatlayout.Reverse(ui.Local, 86 | layout.Rigid(ui.Avatar.Layout), 87 | layout.Rigid(ui.Spacer.Layout), 88 | layout.Rigid(ui.Username.Layout), 89 | )..., 90 | ) 91 | } 92 | 93 | // MessageStyle configures the presentation of a chat message. 94 | type MessageStyle struct { 95 | // Interaction holds the stateful parts of this message. 96 | Interaction *chatwidget.Message 97 | // MaxMessageWidth constrains the display width of the message's background. 98 | MaxMessageWidth unit.Dp 99 | // MaxImageHeight constrains the maximum height of an image message. The image 100 | // will be scaled to fit within this height. 101 | MaxImageHeight unit.Dp 102 | // ContentPadding separates the Content field from the edges of the background. 103 | ContentPadding layout.Inset 104 | // BubbleStyle configures a chat bubble beneath the message. If NinePatch is 105 | // non-nil, this field is ignored. 106 | BubbleStyle 107 | // Ninepatch provides a ninepatch stretchable image background. Only used if 108 | // non-nil. 109 | *ninepatch.NinePatch 110 | // Content is the actual styled text of the message. 111 | Content richtext.TextStyle 112 | // Image is the optional image content of the message. 113 | Image 114 | } 115 | 116 | // Message constructs a MessageStyle with sensible defaults. 117 | func Message(th *material.Theme, interact *chatwidget.Message, content string, img image.Image) MessageStyle { 118 | interact.Image.Cache(img) 119 | l := material.Body1(th, "") 120 | return MessageStyle{ 121 | BubbleStyle: Bubble(th), 122 | Content: richtext.Text(&interact.InteractiveText, th.Shaper, richtext.SpanStyle{ 123 | Font: l.Font, 124 | Size: l.TextSize, 125 | Color: th.Fg, 126 | Content: content, 127 | }), 128 | ContentPadding: layout.UniformInset(unit.Dp(8)), 129 | Image: Image{ 130 | Width: unit.Dp(400), 131 | Height: unit.Dp(400), 132 | Image: widget.Image{ 133 | Src: interact.Image.Op(), 134 | Fit: widget.Cover, 135 | Position: layout.Center, 136 | }, 137 | Radii: unit.Dp(8), 138 | }, 139 | MaxMessageWidth: DefaultMaxMessageWidth, 140 | MaxImageHeight: DefaultMaxImageHeight, 141 | Interaction: interact, 142 | } 143 | } 144 | 145 | // WithNinePatch sets the message surface to a ninepatch image. 146 | func (c MessageStyle) WithNinePatch(th *material.Theme, np ninepatch.NinePatch) MessageStyle { 147 | c.NinePatch = &np 148 | var ( 149 | b = np.Image.Bounds() 150 | ) 151 | // TODO(jfm): refine into more robust solution for picking the text color, 152 | // as needed. 153 | // 154 | // Currently, we pick the middle pixel and use a heuristic formula to get 155 | // relative luminance. 156 | // 157 | // Only considers color.NRGBA colors. 158 | if cl, ok := np.Image.At(b.Dx()/2, b.Dy()/2).(color.NRGBA); ok { 159 | if Luminance(cl) < 0.5 { 160 | for i := range c.Content.Styles { 161 | c.Content.Styles[i].Color = th.Bg 162 | } 163 | } 164 | } 165 | return c 166 | } 167 | 168 | // WithBubbleColor sets the message bubble color and selects a contrasted text color. 169 | func (c MessageStyle) WithBubbleColor(th *material.Theme, col color.NRGBA, luminance float64) MessageStyle { 170 | c.BubbleStyle.Color = col 171 | if luminance < .5 { 172 | for i := range c.Content.Styles { 173 | c.Content.Styles[i].Color = th.Bg 174 | } 175 | } 176 | return c 177 | } 178 | 179 | // Layout the message atop its background. 180 | func (m MessageStyle) Layout(gtx C) D { 181 | gtx.Constraints.Max.X = int(float32(gtx.Constraints.Max.X) * 0.8) 182 | max := gtx.Dp(m.MaxMessageWidth) 183 | if gtx.Constraints.Max.X > max { 184 | gtx.Constraints.Max.X = max 185 | } 186 | if m.Image.Src == (paint.ImageOp{}) { 187 | surface := m.BubbleStyle.Layout 188 | if m.NinePatch != nil { 189 | surface = m.NinePatch.Layout 190 | } 191 | return surface(gtx, func(gtx C) D { 192 | return m.ContentPadding.Layout(gtx, func(gtx C) D { 193 | return m.Content.Layout(gtx) 194 | }) 195 | }) 196 | } 197 | return material.Clickable(gtx, &m.Interaction.Clickable, func(gtx C) D { 198 | gtx.Constraints.Max.Y = gtx.Dp(m.MaxImageHeight) 199 | return m.Image.Layout(gtx) 200 | }) 201 | } 202 | 203 | // Luminance computes the relative brightness of a color, normalized between 204 | // [0,1]. Ignores alpha. 205 | func Luminance(c color.NRGBA) float64 { 206 | return (float64(float64(0.299)*float64(c.R) + float64(0.587)*float64(c.G) + float64(0.114)*float64(c.B))) / 255 207 | } 208 | -------------------------------------------------------------------------------- /widget/material/row.go: -------------------------------------------------------------------------------- 1 | package material 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "time" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/unit" 10 | "gioui.org/widget" 11 | "gioui.org/widget/material" 12 | "gioui.org/x/component" 13 | chatlayout "git.sr.ht/~gioverse/chat/layout" 14 | chatwidget "git.sr.ht/~gioverse/chat/widget" 15 | ) 16 | 17 | // RowStyle configures the presentation of a chat message within 18 | // a vertical list of chat messages. 19 | type RowStyle struct { 20 | chatlayout.Row 21 | // Local indicates that the message was sent by the local user, 22 | // and should be right-aligned. 23 | Local bool 24 | // Time is the timestamp associated with the message. 25 | Time material.LabelStyle 26 | // StatusIcon is an optional icon that will be displayed to the right of 27 | // the message instead of its timestamp. 28 | StatusIcon *widget.Icon 29 | // StatusIconColor is the color of the status icon, if any is set. 30 | StatusIconColor color.NRGBA 31 | // IconSize defines the size of the StatusIcon (if it is set). 32 | IconSize unit.Dp 33 | // StatusMessage defines a warning message to be displayed beneath the 34 | // chat message. 35 | StatusMessage material.LabelStyle 36 | // UserInfoStyle configures how the sender's information is displayed. 37 | UserInfoStyle 38 | // MessageStyle configures how the text and its background are presented. 39 | MessageStyle 40 | // Interaction holds the interactive state of this message. 41 | Interaction *chatwidget.Row 42 | // Menu configures the right-click context menu for this message. 43 | Menu component.MenuStyle 44 | } 45 | 46 | // RowConfig describes the aspects of a chat message relevant for 47 | // displaying it within a widget. 48 | type RowConfig struct { 49 | Sender string 50 | Avatar image.Image 51 | Content string 52 | SentAt time.Time 53 | Image image.Image 54 | Local bool 55 | Status string 56 | } 57 | 58 | // NewRow creates a style type that can lay out the data for a message. 59 | func NewRow(th *material.Theme, interact *chatwidget.Row, menu *component.MenuState, msg RowConfig) RowStyle { 60 | if interact == nil { 61 | interact = &chatwidget.Row{} 62 | } 63 | if menu == nil { 64 | menu = &component.MenuState{} 65 | } 66 | ms := RowStyle{ 67 | Row: chatlayout.Row{ 68 | Margin: chatlayout.VerticalMargin(), 69 | InternalMargin: chatlayout.VerticalMargin(), 70 | Gutter: chatlayout.Gutter(), 71 | Direction: layout.W, 72 | }, 73 | Time: material.Body2(th, msg.SentAt.Local().Format("15:04")), 74 | Local: msg.Local, 75 | IconSize: unit.Dp(32), 76 | UserInfoStyle: UserInfo(th, &interact.UserInfo, msg.Sender, msg.Avatar), 77 | Interaction: interact, 78 | Menu: component.Menu(th, menu), 79 | MessageStyle: Message(th, &interact.Message, msg.Content, msg.Image), 80 | } 81 | ms.UserInfoStyle.Local = msg.Local 82 | if msg.Local { 83 | ms.Row.Direction = layout.E 84 | } 85 | if msg.Status != "" { 86 | ms.StatusMessage = material.Body2(th, msg.Status) 87 | ms.StatusMessage.Color = DefaultDangerColor 88 | ms.StatusIcon = ErrorIcon 89 | ms.StatusIconColor = DefaultDangerColor 90 | } 91 | return ms 92 | } 93 | 94 | // Layout the message. 95 | func (c RowStyle) Layout(gtx C) D { 96 | return c.Row.Layout(gtx, 97 | chatlayout.ContentRow(c.UserInfoStyle.Layout), 98 | chatlayout.FullRow(nil, c.layoutBubble, c.layoutTimeOrIcon), 99 | chatlayout.UnifiedRow(c.layoutStatusMessage), 100 | ) 101 | } 102 | 103 | // layoutBubble lays out the chat bubble. 104 | func (c RowStyle) layoutBubble(gtx C) D { 105 | return layout.Stack{}.Layout(gtx, 106 | layout.Stacked(func(gtx C) D { 107 | return c.MessageStyle.Layout(gtx) 108 | }), 109 | layout.Expanded(func(gtx C) D { 110 | return c.Interaction.ContextArea.Layout(gtx, func(gtx C) D { 111 | gtx.Constraints.Min = image.Point{} 112 | return c.Menu.Layout(gtx) 113 | }) 114 | }), 115 | ) 116 | } 117 | 118 | // layoutTimeOrIcon lays out a status icon if one is set, and 119 | // otherwise lays out the time the messages was sent. 120 | func (c RowStyle) layoutTimeOrIcon(gtx C) D { 121 | return layout.Center.Layout(gtx, func(gtx C) D { 122 | if c.StatusIcon == nil { 123 | return c.Time.Layout(gtx) 124 | } 125 | sideLength := gtx.Dp(c.IconSize) 126 | gtx.Constraints.Max.X = sideLength 127 | gtx.Constraints.Max.Y = sideLength 128 | gtx.Constraints.Min = gtx.Constraints.Constrain(gtx.Constraints.Min) 129 | return c.StatusIcon.Layout(gtx, c.StatusIconColor) 130 | }) 131 | } 132 | 133 | // layoutStatusMessage lays out status message text, if any. 134 | func (c RowStyle) layoutStatusMessage(gtx C) D { 135 | if c.StatusMessage.Text == "" { 136 | return D{} 137 | } 138 | return layout.E.Layout(gtx, c.StatusMessage.Layout) 139 | } 140 | -------------------------------------------------------------------------------- /widget/material/separator.go: -------------------------------------------------------------------------------- 1 | package material 2 | 3 | import ( 4 | "image" 5 | "time" 6 | 7 | "gioui.org/layout" 8 | "gioui.org/op/clip" 9 | "gioui.org/op/paint" 10 | "gioui.org/unit" 11 | "gioui.org/widget/material" 12 | ) 13 | 14 | // SeparatorStyle configures the presentation of the unread indicator. 15 | type SeparatorStyle struct { 16 | Message material.LabelStyle 17 | TextMargin layout.Inset 18 | LineMargin layout.Inset 19 | LineWidth unit.Dp 20 | } 21 | 22 | // UnreadSeparator fills in a SeparatorStyle with sensible defaults. 23 | func UnreadSeparator(th *material.Theme) SeparatorStyle { 24 | us := SeparatorStyle{ 25 | Message: material.Body1(th, "New Messages"), 26 | TextMargin: layout.UniformInset(unit.Dp(8)), 27 | LineMargin: layout.UniformInset(unit.Dp(8)), 28 | LineWidth: unit.Dp(2), 29 | } 30 | us.Message.Color = th.ContrastBg 31 | return us 32 | } 33 | 34 | // DateSeparator makes a SeparatorStyle with indicating the transition to 35 | // the date provided in the time.Time. 36 | func DateSeparator(th *material.Theme, date time.Time) SeparatorStyle { 37 | return SeparatorStyle{ 38 | Message: material.Body1(th, date.Format("Mon Jan 2, 2006")), 39 | TextMargin: layout.UniformInset(unit.Dp(8)), 40 | LineMargin: layout.UniformInset(unit.Dp(8)), 41 | LineWidth: unit.Dp(2), 42 | } 43 | } 44 | 45 | // Layout the Separator. 46 | func (u SeparatorStyle) Layout(gtx layout.Context) layout.Dimensions { 47 | layoutLine := func(gtx layout.Context) layout.Dimensions { 48 | return u.LineMargin.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 49 | size := image.Point{ 50 | X: gtx.Constraints.Max.X, 51 | Y: gtx.Dp(u.LineWidth), 52 | } 53 | paint.FillShape(gtx.Ops, u.Message.Color, clip.Rect(image.Rectangle{Max: size}).Op()) 54 | return layout.Dimensions{Size: size} 55 | }) 56 | } 57 | return layout.Flex{ 58 | Alignment: layout.Middle, 59 | }.Layout(gtx, 60 | layout.Flexed(.5, layoutLine), 61 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 62 | return u.TextMargin.Layout(gtx, u.Message.Layout) 63 | }), 64 | layout.Flexed(.5, layoutLine), 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /widget/message.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "gioui.org/widget" 5 | "gioui.org/x/richtext" 6 | ) 7 | 8 | // Message holds the state necessary to facilitate user 9 | // interactions with messages across frames. 10 | type Message struct { 11 | richtext.InteractiveText 12 | // Clickable tracks clicks on the message image. 13 | widget.Clickable 14 | // Image contains the cached image op for the message. 15 | Image CachedImage 16 | } 17 | -------------------------------------------------------------------------------- /widget/plato/message.go: -------------------------------------------------------------------------------- 1 | package plato 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "time" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | "gioui.org/unit" 11 | "gioui.org/widget" 12 | "gioui.org/widget/material" 13 | "gioui.org/x/component" 14 | "gioui.org/x/richtext" 15 | "git.sr.ht/~gioverse/chat/ninepatch" 16 | chatwidget "git.sr.ht/~gioverse/chat/widget" 17 | chatmaterial "git.sr.ht/~gioverse/chat/widget/material" 18 | ) 19 | 20 | // MessageStyle configures the presentation of a chat message. 21 | type MessageStyle struct { 22 | // Interaction holds the stateful parts of this message. 23 | Interaction *chatwidget.Message 24 | // MaxMessageWidth constrains the display width of the message's background. 25 | MaxMessageWidth unit.Dp 26 | // MinMessageWidth constrains the display width of the message's background. 27 | MinMessageWidth unit.Dp 28 | // MaxImageHeight constrains the maximum height of an image message. The image 29 | // will be scaled to fit within this height. 30 | MaxImageHeight unit.Dp 31 | // ContentPadding separates the Content field from the edges of the background. 32 | // If using a NinePatch background, this field will be ignored in favor of the 33 | // content padding encoded within the ninepatch image. 34 | ContentPadding layout.Inset 35 | // BubbleStyle configures a chat bubble beneath the message. If NinePatch is 36 | // non-nil, this field is ignored. 37 | chatmaterial.BubbleStyle 38 | // Ninepatch provides a ninepatch stretchable image background. Only used if 39 | // non-nil. 40 | *ninepatch.NinePatch 41 | // Content is the actual styled text of the message. 42 | Content richtext.TextStyle 43 | // Seen if this message has been seen, show a read receipt. 44 | Seen bool 45 | // Time is the timestamp associated with the message. 46 | Time material.LabelStyle 47 | // Receipt lays out the read receipt. 48 | Receipt *widget.Icon 49 | // Clickable indicates whether the message content should be able to receive 50 | // click events. 51 | Clickable bool 52 | // Compact mode avoids laying out timestamp and read-receipt. 53 | Compact bool 54 | // TickIconColor is the color of the "read and received" checkmark icon if it 55 | // is displayed. 56 | TickIconColor color.NRGBA 57 | } 58 | 59 | // MessageConfig describes aspects of a chat message. 60 | type MessageConfig struct { 61 | // Content specifies the raw textual content of the message. 62 | Content string 63 | // Seen indicates whether this message has been "seen" by other users. 64 | Seen bool 65 | // Time indicates when this message was sent. 66 | Time time.Time 67 | // Color of the message bubble. 68 | // Defaults to LocalMessageColor. 69 | Color color.NRGBA 70 | // Compact mode avoids laying out timestamp and read-receipt. 71 | Compact bool 72 | } 73 | 74 | // Message constructs a MessageStyle with sensible defaults. 75 | func Message(th *material.Theme, interact *chatwidget.Message, msg MessageConfig) MessageStyle { 76 | l := material.Body1(th, "") 77 | return MessageStyle{ 78 | TickIconColor: color.NRGBA{G: 200, B: 50, A: 255}, 79 | BubbleStyle: func() chatmaterial.BubbleStyle { 80 | b := chatmaterial.Bubble(th) 81 | if msg.Color == (color.NRGBA{}) { 82 | msg.Color = LocalMessageColor 83 | } 84 | b.Color = msg.Color 85 | return b 86 | }(), 87 | Content: richtext.Text(&interact.InteractiveText, th.Shaper, richtext.SpanStyle{ 88 | Font: l.Font, 89 | Size: l.TextSize, 90 | Color: th.Fg, 91 | Content: msg.Content, 92 | }), 93 | ContentPadding: layout.UniformInset(unit.Dp(8)), 94 | MaxMessageWidth: DefaultMaxMessageWidth, 95 | MinMessageWidth: DefaultMinMessageWidth, 96 | MaxImageHeight: DefaultMaxImageHeight, 97 | Interaction: interact, 98 | Time: func() material.LabelStyle { 99 | l := material.Label(th, unit.Sp(11), msg.Time.Local().Format("3:04 PM")) 100 | l.Color = component.WithAlpha(l.Color, 200) 101 | return l 102 | }(), 103 | Receipt: TickIcon, 104 | Compact: msg.Compact, 105 | } 106 | } 107 | 108 | // WithNinePatch sets the message surface to a ninepatch image. 109 | func (c MessageStyle) WithNinePatch(th *material.Theme, np ninepatch.NinePatch) MessageStyle { 110 | c.NinePatch = &np 111 | c.ContentPadding = layout.Inset{} 112 | return c 113 | } 114 | 115 | // WithBubbleColor sets the message bubble color and selects a contrasted text color. 116 | func (c MessageStyle) WithBubbleColor(th *material.Theme, col color.NRGBA, luminance float64) MessageStyle { 117 | c.BubbleStyle.Color = col 118 | if luminance < .5 { 119 | for i := range c.Content.Styles { 120 | c.Content.Styles[i].Color = th.Bg 121 | } 122 | } 123 | return c 124 | } 125 | 126 | func (c *MessageStyle) TextColor(cl color.NRGBA) { 127 | c.Time.Color = cl 128 | for i := range c.Content.Styles { 129 | c.Content.Styles[i].Color = cl 130 | } 131 | } 132 | 133 | // Layout the message atop its background. 134 | func (m MessageStyle) Layout(gtx C) (d D) { 135 | gtx.Constraints.Max.X = int(float32(gtx.Constraints.Max.X) * 0.8) 136 | max := gtx.Dp(m.MaxMessageWidth) 137 | if gtx.Constraints.Max.X > max { 138 | gtx.Constraints.Max.X = max 139 | } 140 | var contentInset layout.Inset = m.ContentPadding 141 | surface := m.BubbleStyle.Layout 142 | if m.NinePatch != nil { 143 | surface = m.NinePatch.Layout 144 | // Override ContentPadding if using a ninepatch background, as it has 145 | // its own internal padding. 146 | contentInset = m.NinePatch.Content.ToDp(gtx.Metric) 147 | } 148 | if m.Compact { 149 | return surface(gtx, func(gtx C) D { 150 | return m.ContentPadding.Layout(gtx, func(gtx C) D { 151 | return m.Content.Layout(gtx) 152 | }) 153 | }) 154 | } 155 | macro := op.Record(gtx.Ops) 156 | dims := contentInset.Layout(gtx, func(gtx C) D { 157 | return m.Content.Layout(gtx) 158 | }) 159 | macro.Stop() 160 | if !m.Clickable { 161 | return D{} 162 | } 163 | return m.Interaction.Clickable.Layout(gtx, func(gtx C) D { 164 | return surface(gtx, func(gtx C) D { 165 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 166 | layout.Rigid(func(gtx C) D { 167 | return layout.Inset{ 168 | Top: m.ContentPadding.Top, 169 | Left: m.ContentPadding.Left, 170 | }.Layout(gtx, m.Content.Layout) 171 | }), 172 | layout.Rigid(func(gtx C) D { 173 | width := gtx.Dp(m.MinMessageWidth) 174 | if dims.Size.X > width { 175 | width = dims.Size.X 176 | } 177 | gtx.Constraints.Max.X = gtx.Constraints.Constrain(image.Pt(width, 0)).X 178 | return layout.Inset{ 179 | Bottom: m.ContentPadding.Right, 180 | Right: m.ContentPadding.Bottom, 181 | }.Layout(gtx, func(gtx C) D { 182 | return layout.Flex{ 183 | Axis: layout.Horizontal, 184 | Alignment: layout.Middle, 185 | }.Layout(gtx, 186 | layout.Flexed(1, func(gtx C) D { 187 | return D{Size: gtx.Constraints.Min} 188 | }), 189 | layout.Rigid(func(gtx C) D { 190 | return m.Time.Layout(gtx) 191 | }), 192 | layout.Rigid(func(gtx C) D { 193 | return m.Receipt.Layout(gtx, m.TickIconColor) 194 | }), 195 | ) 196 | }) 197 | }), 198 | ) 199 | }) 200 | }) 201 | } 202 | -------------------------------------------------------------------------------- /widget/plato/plato.go: -------------------------------------------------------------------------------- 1 | // Package plato implements themed styles for Plato Team Inc. 2 | // https://www.platoapp.com/ 3 | package plato 4 | 5 | import ( 6 | "image/color" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/unit" 10 | "gioui.org/widget" 11 | "gioui.org/widget/material" 12 | chatlayout "git.sr.ht/~gioverse/chat/layout" 13 | "golang.org/x/exp/shiny/materialdesign/icons" 14 | ) 15 | 16 | type ( 17 | C = layout.Context 18 | D = layout.Dimensions 19 | ) 20 | 21 | var ( 22 | DefaultMaxImageHeight = unit.Dp(400) 23 | DefaultMaxMessageWidth = unit.Dp(600) 24 | DefaultMinMessageWidth = unit.Dp(80) 25 | DefaultAvatarSize = unit.Dp(28) 26 | LocalMessageColor = color.NRGBA{R: 63, G: 133, B: 232, A: 255} 27 | NonLocalMessageColor = color.NRGBA{R: 50, G: 50, B: 50, A: 255} 28 | ) 29 | 30 | // TickIcon used for read receipts. 31 | var TickIcon = func() *widget.Icon { 32 | icon, _ := widget.NewIcon(icons.NavigationCheck) 33 | return icon 34 | }() 35 | 36 | // UserInfoStyle defines the presentation of information about a user. 37 | type UserInfoStyle struct { 38 | // Username configures the presentation of the user name text. 39 | Username material.LabelStyle 40 | // Spacer is inserted between the username and avatar fields. 41 | layout.Spacer 42 | // Local controls the Left-to-Right ordering of layout. If false, 43 | // the Left-to-Right order will be: 44 | // - Avatar 45 | // - Spacer 46 | // - Username 47 | // If true, the order is reversed. 48 | Local bool 49 | } 50 | 51 | // UserInfo constructs a UserInfoStyle with sensible defaults. 52 | func UserInfo(th *material.Theme, username string) UserInfoStyle { 53 | return UserInfoStyle{ 54 | Username: material.Body1(th, username), 55 | Spacer: layout.Spacer{Width: unit.Dp(8)}, 56 | } 57 | } 58 | 59 | // Layout the user information. 60 | func (ui UserInfoStyle) Layout(gtx C) D { 61 | return layout.Flex{ 62 | Axis: layout.Horizontal, 63 | Alignment: layout.Middle, 64 | }.Layout(gtx, 65 | chatlayout.Reverse(ui.Local, 66 | layout.Rigid(ui.Spacer.Layout), 67 | layout.Rigid(ui.Username.Layout), 68 | )..., 69 | ) 70 | } 71 | 72 | // Luminance computes the relative brightness of a color, normalized between 73 | // [0,1]. Ignores alpha. 74 | func Luminance(c color.NRGBA) float64 { 75 | return (float64(float64(0.299)*float64(c.R) + float64(0.587)*float64(c.G) + float64(0.114)*float64(c.B))) / 255 76 | } 77 | -------------------------------------------------------------------------------- /widget/plato/row.go: -------------------------------------------------------------------------------- 1 | package plato 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "time" 7 | 8 | "gioui.org/layout" 9 | "gioui.org/unit" 10 | "gioui.org/widget" 11 | "gioui.org/widget/material" 12 | "gioui.org/x/component" 13 | chatlayout "git.sr.ht/~gioverse/chat/layout" 14 | chatwidget "git.sr.ht/~gioverse/chat/widget" 15 | chatmaterial "git.sr.ht/~gioverse/chat/widget/material" 16 | ) 17 | 18 | // RowStyle configures the presentation of a chat message within 19 | // a vertical list of chat messages. 20 | // 21 | // In particular, RowStyle is repsonsible for gutters and anchoring of 22 | // messages. 23 | type RowStyle struct { 24 | chatlayout.Row 25 | // Local indicates that the message was sent by the local user, 26 | // and should be right-aligned. 27 | Local bool 28 | // Time is the timestamp associated with the message. 29 | Time material.LabelStyle 30 | // StatusMessage defines a warning message to be displayed beneath the 31 | // chat message. 32 | StatusMessage material.LabelStyle 33 | // UserInfoStyle configures how the sender's information is displayed. 34 | UserInfoStyle 35 | // Avatar image for the user. 36 | Avatar chatmaterial.Image 37 | // MessageStyle configures how the text and its background are presented. 38 | MessageStyle 39 | // Interaction holds the interactive state of this message. 40 | Interaction *chatwidget.Row 41 | // Menu configures the right-click context menu for this message. 42 | Menu component.MenuStyle 43 | } 44 | 45 | // RowConfig describes the aspects of a chat row relevant for displaying 46 | // it within a widget. 47 | type RowConfig struct { 48 | Sender string 49 | Avatar image.Image 50 | Content string 51 | SentAt time.Time 52 | Image image.Image 53 | Local bool 54 | } 55 | 56 | // NewRow creates a style type that can lay out the data for a message. 57 | func NewRow( 58 | th *material.Theme, 59 | interact *chatwidget.Row, 60 | menu *component.MenuState, 61 | msg RowConfig, 62 | ) RowStyle { 63 | if interact == nil { 64 | interact = &chatwidget.Row{} 65 | } 66 | if menu == nil { 67 | menu = &component.MenuState{} 68 | } 69 | interact.Avatar.Cache(msg.Avatar) 70 | ms := RowStyle{ 71 | Row: chatlayout.Row{ 72 | Margin: chatlayout.VerticalMargin(), 73 | InternalMargin: chatlayout.VerticalMargin(), 74 | Gutter: chatlayout.GutterStyle{ 75 | LeftWidth: unit.Dp(12) + DefaultAvatarSize, 76 | RightWidth: unit.Dp(12) + DefaultAvatarSize, 77 | Alignment: layout.Start, 78 | }, 79 | Direction: layout.W, 80 | }, 81 | Time: material.Body2(th, msg.SentAt.Local().Format("15:04")), 82 | Local: msg.Local, 83 | UserInfoStyle: UserInfo(th, msg.Sender), 84 | Avatar: chatmaterial.Image{ 85 | Image: widget.Image{ 86 | Src: interact.Avatar.Op(), 87 | Fit: widget.Cover, 88 | Position: layout.Center, 89 | }, 90 | // Half size radius makes for a circle. 91 | Radii: unit.Dp(DefaultAvatarSize * 0.5), 92 | Width: DefaultAvatarSize, 93 | Height: DefaultAvatarSize, 94 | }, 95 | Interaction: interact, 96 | Menu: component.Menu(th, menu), 97 | MessageStyle: Message(th, &interact.Message, MessageConfig{ 98 | Content: msg.Content, 99 | Seen: true, 100 | Time: msg.SentAt, 101 | Color: func() color.NRGBA { 102 | if msg.Local { 103 | return LocalMessageColor 104 | } 105 | return NonLocalMessageColor 106 | }(), 107 | Compact: msg.SentAt == (time.Time{}), 108 | }), 109 | } 110 | ms.UserInfoStyle.Local = msg.Local 111 | if msg.Local { 112 | ms.Row.Direction = layout.E 113 | } 114 | return ms 115 | } 116 | 117 | // Layout the message. 118 | func (c RowStyle) Layout(gtx C) D { 119 | return c.Row.Layout(gtx, 120 | chatlayout.ContentRow(c.UserInfoStyle.Layout), 121 | chatlayout.FullRow( 122 | func(gtx C) D { 123 | if c.Local { 124 | return D{} 125 | } 126 | return c.layoutAvatar(gtx) 127 | }, 128 | c.layoutBubble, 129 | func(gtx C) D { 130 | if !c.Local { 131 | return D{} 132 | } 133 | return c.layoutAvatar(gtx) 134 | }, 135 | ), 136 | ) 137 | } 138 | 139 | // layoutBubble lays out the chat bubble. 140 | func (c RowStyle) layoutBubble(gtx C) D { 141 | return layout.Stack{}.Layout(gtx, 142 | layout.Stacked(func(gtx C) D { 143 | return c.MessageStyle.Layout(gtx) 144 | }), 145 | layout.Expanded(func(gtx C) D { 146 | return c.Interaction.ContextArea.Layout(gtx, func(gtx C) D { 147 | gtx.Constraints.Min = image.Point{} 148 | return c.Menu.Layout(gtx) 149 | }) 150 | }), 151 | ) 152 | } 153 | 154 | // layoutAvatar lays out the user avatar image. 155 | func (c RowStyle) layoutAvatar(gtx C) D { 156 | return layout.Inset{ 157 | Top: unit.Dp(8), 158 | Bottom: unit.Dp(6), 159 | Left: unit.Dp(6), 160 | Right: unit.Dp(6), 161 | }.Layout(gtx, func(gtx C) D { 162 | return c.Avatar.Layout(gtx) 163 | }) 164 | } 165 | -------------------------------------------------------------------------------- /widget/row.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import "gioui.org/x/component" 4 | 5 | // Row holds persistent state for a single row of a chat. 6 | type Row struct { 7 | // ContextArea holds the clicks state for the right-click context menu. 8 | component.ContextArea 9 | 10 | Message 11 | UserInfo 12 | } 13 | -------------------------------------------------------------------------------- /widget/userinfo.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | // UserInfo holds persistent state for displaying a user's information. 4 | type UserInfo struct { 5 | Avatar CachedImage 6 | } 7 | --------------------------------------------------------------------------------