// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"fmt"
"image"
"image/color"
"log"
"math"
"runtime"
"strings"
"time"
"unicode"
"golang.org/x/image/draw"
"gioui.org/app"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/profile"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/text/shape"
"gioui.org/unit"
"gioui.org/widget"
"golang.org/x/exp/shiny/iconvg"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goitalic"
"golang.org/x/image/font/gofont/gomono"
"golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/font/sfnt"
"golang.org/x/exp/shiny/materialdesign/icons"
)
type Env struct {
insets layout.Inset
client *Client
redraw func()
}
type App struct {
w *app.Window
env Env
stack pageStack
// Profiling.
profiling bool
profile profile.Event
lastMallocs uint64
}
type Transition struct {
prev, page Page
reverse bool
time time.Time
}
type pageStack struct {
pages []Page
stopChan chan<- struct{}
}
type Page interface {
Start(stop <-chan struct{})
Event(gtx *layout.Context) interface{}
Layout(gtx *layout.Context)
}
type signInPage struct {
env *Env
account *Account
list *layout.List
fields []*formField
submit *Button
}
type Button struct {
Label string
buttonState
}
type Topbar struct {
Back bool
backClick gesture.Click
}
type formField struct {
env *Env
Header string
Hint string
Value *string
edit *text.Editor
}
type threadsPage struct {
env *Env
account *Account
fab *IconButton
updates <-chan struct{}
threadUpdates chan []*Thread
list *layout.List
threads []*Thread
clicks []gesture.Click
}
type threadPage struct {
env *Env
checkmark *icon
thread *Thread
list *layout.List
messages []*Message
result chan []*Message
msgEdit *text.Editor
send *IconButton
invite *Button
accept *Button
topbar *Topbar
updates <-chan struct{}
}
type contactsPage struct {
env *Env
list *layout.List
searchEdit *text.Editor
contacts []*Contact
clicks []gesture.Click
query chan []*Contact
topbar *Topbar
}
type Contact struct {
Address string
}
type icon struct {
src []byte
size unit.Value
// Cached values.
img image.Image
imgSize int
}
type IconButton struct {
Icon *icon
Inset layout.Inset
buttonState
}
type buttonState struct {
click gesture.Click
clicks int
}
type BackEvent struct{}
type SignInEvent struct {
Account *Account
}
type NewThreadEvent struct {
Address string
}
type ShowContactsEvent struct{}
type ShowThreadEvent struct {
Thread string
}
var families struct {
primary *shape.Family
mono *shape.Family
}
var theme struct {
text op.MacroOp
tertText op.MacroOp
brand op.MacroOp
white op.MacroOp
}
func uiMain() {
families.primary = &shape.Family{
Regular: mustLoadFont(goregular.TTF),
Italic: mustLoadFont(goitalic.TTF),
Bold: mustLoadFont(gobold.TTF),
}
families.mono = &shape.Family{
Regular: mustLoadFont(gomono.TTF),
}
var ops op.Ops
theme.text = colorMaterial(&ops, rgb(0x000000))
theme.tertText = colorMaterial(&ops, rgb(0xbbbbbb))
theme.brand = colorMaterial(&ops, rgb(0x3c98c6))
theme.white = colorMaterial(&ops, rgb(0xffffff))
go func() {
w := app.NewWindow(
app.Size(unit.Dp(400), unit.Dp(800)),
app.Title("Scatter"),
)
if err := newApp(w).run(); err != nil {
log.Fatal(err)
}
}()
app.Main()
}
func colorMaterial(ops *op.Ops, color color.RGBA) op.MacroOp {
var mat op.MacroOp
mat.Record(ops)
paint.ColorOp{Color: color}.Add(ops)
mat.Stop()
return mat
}
func (a *App) run() error {
var updates <-chan struct{}
gtx := &layout.Context{
Queue: a.w.Queue(),
}
for {
select {
case <-updates:
if err := a.env.client.Err(); err != nil {
log.Printf("client err: %v", err)
a.stack.Clear(newSignInPage(&a.env))
}
a.w.Invalidate()
case e := <-a.w.Events():
switch e := e.(type) {
case key.Event:
switch e.Name {
case key.NameEscape:
if a.stack.Len() > 1 {
a.stack.Pop()
a.w.Invalidate()
}
case 'P':
if e.Modifiers&key.ModCommand != 0 {
a.profiling = !a.profiling
a.w.Invalidate()
}
}
case app.DestroyEvent:
return e.Err
case app.StageEvent:
if e.Stage >= app.StageRunning {
if a.env.client == nil {
a.env.client = getClient()
updates = a.env.client.register(a)
defer a.env.client.unregister(a)
}
if a.stack.Len() == 0 {
a.stack.Push(newThreadsPage(&a.env))
}
}
case *app.CommandEvent:
switch e.Type {
case app.CommandBack:
if a.stack.Len() > 1 {
a.stack.Pop()
e.Cancel = true
a.w.Invalidate()
}
}
case app.UpdateEvent:
gtx.Reset(&e.Config, e.Size)
a.env.insets = layout.Inset{
Top: e.Insets.Top,
Left: e.Insets.Left,
Right: e.Insets.Right,
Bottom: e.Insets.Bottom,
}
a.Layout(gtx)
if a.profiling {
a.layoutTimings(gtx)
}
a.w.Update(gtx.Ops)
}
}
}
}
func newApp(w *app.Window) *App {
a := &App{
w: w,
}
a.env.redraw = a.w.Invalidate
return a
}
func (t *Transition) Start(stop <-chan struct{}) {
t.page.Start(stop)
}
func (t *Transition) Event(gtx *layout.Context) interface{} {
return t.page.Event(gtx)
}
func (t *Transition) Layout(gtx *layout.Context) {
var stack op.StackOp
stack.Push(gtx.Ops)
prev, page := t.prev, t.page
if prev != nil {
if t.reverse {
prev, page = page, prev
}
now := gtx.Now()
if t.time.IsZero() {
t.time = now
}
prev.Layout(gtx)
cs := gtx.Constraints
size := f32.Point{X: float32(cs.Width.Max), Y: float32(cs.Height.Max)}
max := float32(math.Sqrt(float64(size.X*size.X + size.Y*size.Y)))
progress := float32(now.Sub(t.time).Seconds()) * 3
progress = progress * progress // Accelerate
if progress >= 1 {
// Stop animation when complete.
t.prev = nil
}
if t.reverse {
progress = 1 - progress
}
diameter := progress * max
radius := diameter / 2
op.InvalidateOp{}.Add(gtx.Ops)
center := size.Mul(.5)
clipCenter := f32.Point{X: diameter / 2, Y: diameter / 2}
off := op.TransformOp{}.Offset(center.Sub(clipCenter))
off.Add(gtx.Ops)
rrect(gtx.Ops, diameter, diameter, radius, radius, radius, radius)
off.Invert().Add(gtx.Ops)
fill{theme.white}.Layout(gtx)
}
page.Layout(gtx)
stack.Pop()
}
func (s *pageStack) Len() int {
return len(s.pages)
}
func (s *pageStack) Current() Page {
return s.pages[len(s.pages)-1]
}
func (s *pageStack) Pop() {
s.stop()
i := len(s.pages) - 1
prev := s.pages[i]
s.pages[i] = nil
s.pages = s.pages[:i]
if len(s.pages) > 0 {
s.pages[i-1] = &Transition{
reverse: true,
prev: prev,
page: s.Current(),
}
s.start()
}
}
func (s *pageStack) start() {
stop := make(chan struct{})
s.stopChan = stop
s.Current().Start(stop)
}
func (s *pageStack) Swap(p Page) {
prev := s.pages[len(s.pages)-1]
s.pages[len(s.pages)-1] = &Transition{
prev: prev,
page: p,
}
s.start()
}
func (s *pageStack) Push(p Page) {
if s.stopChan != nil {
s.stop()
}
if len(s.pages) > 0 {
p = &Transition{
prev: s.Current(),
page: p,
}
}
s.pages = append(s.pages, p)
s.start()
}
func (s *pageStack) stop() {
close(s.stopChan)
s.stopChan = nil
}
func (s *pageStack) Clear(p Page) {
for len(s.pages) > 0 {
s.Pop()
}
s.Push(p)
}
func mustLoadFont(fontData []byte) *sfnt.Font {
fnt, err := sfnt.Parse(fontData)
if err != nil {
panic("failed to load font")
}
return fnt
}
func rgb(c uint32) color.RGBA {
return argb((0xff << 24) | c)
}
func argb(c uint32) color.RGBA {
return color.RGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}
func (a *App) Layout(gtx *layout.Context) {
a.update(gtx)
a.stack.Current().Layout(gtx)
}
func (a *App) layoutTimings(gtx *layout.Context) {
for _, e := range gtx.Events(a) {
if e, ok := e.(profile.Event); ok {
a.profile = e
}
}
profile.Op{Key: a}.Add(gtx.Ops)
var mstats runtime.MemStats
runtime.ReadMemStats(&mstats)
mallocs := mstats.Mallocs - a.lastMallocs
a.lastMallocs = mstats.Mallocs
layout.Align(layout.NE).Layout(gtx, func() {
in := a.env.insets
in.Top = unit.Max(gtx, unit.Dp(16), in.Top)
in.Layout(gtx, func() {
txt := fmt.Sprintf("m: %d %s", mallocs, a.profile.Timings)
text.Label{Material: theme.text, Size: unit.Sp(10), Text: txt}.Layout(gtx, families.mono)
})
})
}
func newContactsPage(env *Env) *contactsPage {
p := &contactsPage{
env: env,
list: &layout.List{
Axis: layout.Vertical,
},
searchEdit: &text.Editor{
Family: families.primary,
Size: unit.Sp(20),
SingleLine: true,
Submit: true,
Hint: "Email address",
Material: theme.white,
HintMaterial: theme.tertText,
},
topbar: &Topbar{
Back: true,
},
}
p.searchEdit.Focus()
return p
}
func (p *contactsPage) Start(stop <-chan struct{}) {}
func (p *contactsPage) Event(gtx *layout.Context) interface{} {
for e, ok := p.searchEdit.Event(gtx); ok; e, ok = p.searchEdit.Event(gtx) {
if !ok {
break
}
switch e.(type) {
case text.ChangeEvent:
p.queryContacts(p.searchEdit.Text())
case text.SubmitEvent:
if t := p.searchEdit.Text(); isEmailAddress(t) {
return NewThreadEvent{Address: t}
}
}
}
select {
case p.contacts = <-p.query:
p.clicks = make([]gesture.Click, len(p.contacts))
default:
}
for i := range p.clicks {
for _, e := range p.clicks[i].Events(gtx) {
if e.Type == gesture.TypeClick {
return NewThreadEvent{p.contacts[i].Address}
}
}
}
return p.topbar.Event(gtx)
}
func isEmailAddress(e string) bool {
idx := strings.Index(e, "@")
return idx > 0 && idx < len(e)-1
}
func (p *contactsPage) queryContacts(q string) {
p.query = make(chan []*Contact, 1)
go func() {
var contacts []*Contact
if isEmailAddress(q) {
contacts = append(contacts, &Contact{Address: q})
}
threads, err := p.env.client.QueryThreads(q)
if err == nil {
for _, t := range threads {
contacts = append(contacts, &Contact{Address: t.ID})
}
} else {
log.Printf("queryContacts: failed to query threads: %v", err)
}
p.query <- contacts
p.env.redraw()
}()
}
func (p *contactsPage) Layout(gtx *layout.Context) {
for e := p.Event(gtx); e != nil; e = p.Event(gtx) {
}
l := p.list
if l.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
f := layout.Flex{Axis: layout.Vertical}
c1 := f.Rigid(gtx, func() {
p.topbar.Layout(gtx, p.env.insets, func() {
p.searchEdit.Layout(gtx)
})
})
c2 := f.Flex(gtx, 1, func() {
gtx.Constraints.Height.Min = gtx.Constraints.Height.Max
l.Layout(gtx, len(p.contacts), func(i int) {
p.contact(gtx, i)
})
})
f.Layout(gtx, c1, c2)
}
func (p *contactsPage) contact(gtx *layout.Context, index int) {
in := layout.Inset{
Top: unit.Dp(16),
Bottom: unit.Dp(16),
Left: unit.Max(gtx, unit.Dp(16), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
}
contact := p.contacts[index]
click := &p.clicks[index]
in.Layout(gtx, func() {
f := layout.Flex{Alignment: layout.Middle}
c1 := f.Rigid(gtx, func() {
in := layout.Inset{Right: unit.Dp(8)}
in.Layout(gtx, func() {
cc := clipCircle{}
cc.Layout(gtx, func() {
sz := image.Point{X: gtx.Px(unit.Dp(48)), Y: gtx.Px(unit.Dp(48))}
gtx.Constraints = layout.RigidConstraints(gtx.Constraints.Constrain(sz))
fill{theme.brand}.Layout(gtx)
})
})
})
c2 := f.Flex(gtx, 1, func() {
text.Label{Material: theme.text, Size: unit.Sp(18), Text: contact.Address}.Layout(gtx, families.primary)
})
f.Layout(gtx, c1, c2)
})
pointer.RectAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops)
click.Add(gtx.Ops)
}
func (t *Topbar) Event(gtx *layout.Context) interface{} {
for _, e := range t.backClick.Events(gtx) {
if e.Type == gesture.TypeClick {
return BackEvent{}
}
}
return nil
}
func (t *Topbar) Layout(gtx *layout.Context, insets layout.Inset, w layout.Widget) {
stack := layout.Stack{Alignment: layout.SW}
insets = layout.Inset{
Top: unit.Add(gtx, insets.Top, unit.Dp(16)),
Bottom: unit.Dp(16),
Left: unit.Max(gtx, insets.Left, unit.Dp(16)),
Right: unit.Max(gtx, insets.Right, unit.Dp(16)),
}
stackContent := stack.Rigid(gtx, func() {
insets.Layout(gtx, func() {
flex := layout.Flex{Alignment: layout.Middle}
backChild := flex.Rigid(gtx, func() {
if t.Back {
ico := (&icon{src: icons.NavigationArrowBack, size: unit.Dp(24)}).image(gtx, rgb(0xffffff))
widget.Image{Src: ico, Rect: ico.Bounds(), Scale: 1}.Layout(gtx)
gtx.Dimensions.Size.X += gtx.Px(unit.Dp(4))
pointer.RectAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops)
t.backClick.Add(gtx.Ops)
}
})
content := flex.Flex(gtx, 1, w)
flex.Layout(gtx, backChild, content)
})
})
bg := stack.Expand(gtx, func() {
fill{theme.brand}.Layout(gtx)
})
stack.Layout(gtx, bg, stackContent)
}
func newSignInPage(env *Env) *signInPage {
acc := env.client.Account()
if acc.SMTPHost == "" && acc.IMAPHost == "" {
acc.SMTPHost = "smtp.gmail.com:587"
acc.IMAPHost = "imap.gmail.com:993"
}
p := &signInPage{
env: env,
account: acc,
list: &layout.List{
Axis: layout.Vertical,
},
fields: []*formField{
&formField{Header: "Email address", Hint: "you@example.org", Value: &acc.User},
&formField{Header: "Password", Hint: "correct horse battery staple", Value: &acc.Password},
&formField{Header: "IMAP host", Hint: "host:port", Value: &acc.IMAPHost},
&formField{Header: "SMTP host", Hint: "host:port", Value: &acc.SMTPHost},
},
submit: &Button{
Label: "Sign in",
},
}
for _, f := range p.fields {
f.env = p.env
f.edit = &text.Editor{
Family: families.primary,
Size: unit.Sp(16),
SingleLine: true,
Hint: f.Hint,
Material: theme.text,
HintMaterial: theme.tertText,
}
f.edit.SetText(*f.Value)
}
return p
}
func (p *signInPage) Start(stop <-chan struct{}) {
}
func (p *signInPage) Event(gtx *layout.Context) interface{} {
if p.submit.Clicked(gtx) {
for _, f := range p.fields {
*f.Value = f.edit.Text()
}
return SignInEvent{p.account}
}
return nil
}
func (p *signInPage) Layout(gtx *layout.Context) {
f := layout.Flex{Axis: layout.Vertical}
c1 := f.Rigid(gtx, func() {
var t Topbar
t.Layout(gtx, p.env.insets, func() {
text.Label{Material: colorMaterial(gtx.Ops, rgb(0xffffff)), Size: unit.Sp(20), Text: "Sign in"}.Layout(gtx, families.primary)
})
})
c2 := f.Flex(gtx, 1, func() {
p.layoutSigninForm(gtx)
})
f.Layout(gtx, c1, c2)
}
func (p *signInPage) layoutSigninForm(gtx *layout.Context) {
l := p.list
inset := layout.Inset{
Left: unit.Max(gtx, unit.Dp(32), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(32), p.env.insets.Right),
}
l.Layout(gtx, len(p.fields)+1, func(i int) {
in := inset
switch {
case i < len(p.fields):
in.Bottom = unit.Dp(12)
if i == 0 {
in.Top = unit.Dp(32)
}
in.Layout(gtx, func() {
p.fields[i].Layout(gtx)
})
default:
in.Bottom = unit.Max(gtx, unit.Dp(32), p.env.insets.Bottom)
layout.Align(layout.E).Layout(gtx, func() {
in.Layout(gtx, func() {
p.submit.Layout(gtx, p.env)
})
})
}
})
}
func (f *formField) Layout(gtx *layout.Context) {
theme.text.Add(gtx.Ops)
fl := layout.Flex{Axis: layout.Vertical}
header := text.Label{Material: theme.text, Text: f.Header, Size: unit.Sp(12)}
header.Face.Weight = text.Bold
c1 := fl.Rigid(gtx, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
header.Layout(gtx, families.primary)
gtx.Dimensions.Size.Y += gtx.Px(unit.Dp(4))
})
c2 := fl.Rigid(gtx, func() {
f.edit.Layout(gtx)
})
fl.Layout(gtx, c1, c2)
}
func (b *buttonState) Clicked(gtx *layout.Context) bool {
for _, e := range b.click.Events(gtx) {
if e.Type == gesture.TypeClick {
b.clicks++
}
}
if b.clicks > 0 {
b.clicks--
return true
}
return false
}
func (b *buttonState) clear() {
b.clicks = 0
}
func (b *Button) Layout(gtx *layout.Context, env *Env) {
b.buttonState.clear()
bg := Background{
Material: theme.brand,
Radius: unit.Dp(4),
Inset: layout.UniformInset(unit.Dp(8)),
}
bg.Layout(gtx, func() {
lbl := text.Label{Material: theme.white, Size: unit.Sp(16), Text: b.Label, Alignment: text.Middle}
lbl.Layout(gtx, families.primary)
})
pointer.RectAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops)
b.click.Add(gtx.Ops)
}
type Background struct {
Material op.MacroOp
Radius unit.Value
Inset layout.Inset
}
func (b *Background) Layout(gtx *layout.Context, w layout.Widget) {
var macro op.MacroOp
macro.Record(gtx.Ops)
b.Inset.Layout(gtx, w)
macro.Stop()
var stack op.StackOp
stack.Push(gtx.Ops)
size := gtx.Dimensions.Size
width, height := float32(size.X), float32(size.Y)
if r := float32(gtx.Px(b.Radius)); r > 0 {
if r > width/2 {
r = width / 2
}
if r > height/2 {
r = height / 2
}
rrect(gtx.Ops, width, height, r, r, r, r)
}
b.Material.Add(gtx.Ops)
paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: width, Y: height}}}.Add(gtx.Ops)
macro.Add(gtx.Ops)
stack.Pop()
}
func newThreadsPage(env *Env) *threadsPage {
return &threadsPage{
env: env,
list: &layout.List{
Axis: layout.Vertical,
},
fab: &IconButton{
Icon: &icon{src: icons.ContentCreate, size: unit.Dp(24)},
Inset: layout.UniformInset(unit.Dp(16)),
},
}
}
func (p *threadsPage) Start(stop <-chan struct{}) {
p.account = p.env.client.Account()
p.fetchThreads()
p.updates = p.env.client.register(p)
go func() {
<-stop
p.env.client.unregister(p)
}()
}
func (p *threadsPage) Event(gtx *layout.Context) interface{} {
select {
case <-p.updates:
p.fetchThreads()
case threads := <-p.threadUpdates:
p.threads = threads
p.clicks = make([]gesture.Click, len(threads))
p.env.redraw()
default:
}
if p.fab.Clicked(gtx) {
return ShowContactsEvent{}
}
for i := range p.clicks {
click := &p.clicks[i]
for _, e := range click.Events(gtx) {
if e.Type == gesture.TypeClick {
t := p.threads[i]
return ShowThreadEvent{Thread: t.ID}
}
}
}
return nil
}
func (p *threadsPage) fetchThreads() {
p.threadUpdates = make(chan []*Thread, 1)
go func() {
threads, err := p.env.client.Threads()
if err != nil {
log.Printf("scatter: failed to load threads: %v", err)
return
}
p.threadUpdates <- threads
p.env.redraw()
}()
}
func (p *threadsPage) Layout(gtx *layout.Context) {
st := layout.Stack{Alignment: layout.Center}
c1 := st.Rigid(gtx, func() {
f := layout.Flex{Axis: layout.Vertical}
c1 := f.Rigid(gtx, func() {
var t Topbar
t.Layout(gtx, p.env.insets, func() {
text.Label{Material: theme.white, Size: unit.Sp(20), Text: p.account.User}.Layout(gtx, families.primary)
})
})
c2 := f.Flex(gtx, 1, func() {
p.layoutThreads(gtx)
})
f.Layout(gtx, c1, c2)
})
c2 := st.Rigid(gtx, func() {
layout.Align(layout.SE).Layout(gtx, func() {
layout.Inset{
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
Bottom: unit.Max(gtx, unit.Dp(16), p.env.insets.Bottom),
}.Layout(gtx, func() {
p.fab.Layout(gtx)
})
})
})
st.Layout(gtx, c1, c2)
}
func (p *threadsPage) layoutThreads(gtx *layout.Context) {
l := p.list
if l.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
l.Layout(gtx, len(p.threads), func(i int) {
in := layout.Inset{}
switch i {
case 0:
in.Top = unit.Dp(4)
case len(p.threads) - 1:
in.Bottom = unit.Max(gtx, unit.Dp(4), p.env.insets.Bottom)
}
in.Layout(gtx, func() {
p.thread(gtx, i)
})
})
}
var contactColors = []color.RGBA{
{A: 0xff, R: 0xef, G: 0x6c, B: 0x00},
{A: 0xff, R: 0x00, G: 0x57, B: 0x9b},
{A: 0xff, R: 0x00, G: 0x97, B: 0xa7},
{A: 0xff, R: 0x00, G: 0x4d, B: 0x40},
{A: 0xff, R: 0x7b, G: 0x1f, B: 0xa2},
{A: 0xff, R: 0x00, G: 0x89, B: 0x7b},
}
func (p *threadsPage) thread(gtx *layout.Context, index int) {
t := p.threads[index]
bgtexmat := theme.tertText
face := text.Face{}
if t.Unread > 0 {
bgtexmat = theme.text
face.Weight = text.Bold
}
click := &p.clicks[index]
in := layout.Inset{
Left: unit.Max(gtx, unit.Dp(16), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
}
in.Layout(gtx, func() {
elem := layout.Flex{Axis: layout.Vertical}
c1 := elem.Rigid(gtx, func() {
in := layout.Inset{Top: unit.Dp(8), Bottom: unit.Dp(8)}
in.Layout(gtx, func() {
f := centerRowOpts()
c1 := f.Rigid(gtx, func() {
in := layout.Inset{Right: unit.Dp(12)}
cc := clipCircle{}
in.Layout(gtx, func() {
cc.Layout(gtx, func() {
st := layout.Stack{Alignment: layout.Center}
// Background color
c1 := st.Rigid(gtx, func() {
sz := image.Point{X: gtx.Px(unit.Dp(48)), Y: gtx.Px(unit.Dp(48))}
gtx.Constraints = layout.RigidConstraints(gtx.Constraints.Constrain(sz))
color := contactColors[index%len(contactColors)]
mat := colorMaterial(gtx.Ops, color)
fill{mat}.Layout(gtx)
})
// Contact initial.
c2 := st.Rigid(gtx, func() {
initial := ""
for _, c := range t.ID {
initial = string(unicode.ToUpper(c))
break
}
text.Label{Material: theme.white, Size: unit.Sp(24), Text: initial}.Layout(gtx, families.primary)
})
st.Layout(gtx, c1, c2)
})
})
})
c2 := f.Rigid(gtx, func() {
f := column()
c1 := f.Rigid(gtx, func() {
f := baseline()
c1 := f.Rigid(gtx, func() {
text.Label{Material: theme.text, Face: face, Size: unit.Sp(18), Text: t.ID}.Layout(gtx, families.primary)
})
c2 := f.Flex(gtx, 1, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
in := layout.Inset{Left: unit.Dp(2)}
in.Layout(gtx, func() {
text.Label{
Alignment: text.End,
Material: bgtexmat,
Face: face,
Size: unit.Sp(12),
Text: formatTime(t.Updated),
}.Layout(gtx, families.primary)
})
})
f.Layout(gtx, c1, c2)
})
c2 := f.Rigid(gtx, func() {
in := layout.Inset{Top: unit.Dp(6)}
in.Layout(gtx, func() {
text.Label{Material: bgtexmat, Face: face, Size: unit.Sp(14), MaxLines: 1, Text: t.Snippet}.Layout(gtx, families.primary)
})
})
f.Layout(gtx, c1, c2)
})
f.Layout(gtx, c1, c2)
})
pointer.RectAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops)
click.Add(gtx.Ops)
})
elem.Layout(gtx, c1)
})
}
func newThreadPage(env *Env, threadID string) *threadPage {
thread := env.client.Thread(threadID)
return &threadPage{
env: env,
thread: thread,
checkmark: &icon{src: icons.ActionDone, size: unit.Dp(12)},
list: &layout.List{
Axis: layout.Vertical,
ScrollToEnd: true,
},
result: make(chan []*Message, 1),
msgEdit: &text.Editor{
Family: families.primary,
Size: unit.Sp(14),
Submit: true,
Hint: "Send a message",
Material: theme.text,
HintMaterial: theme.tertText,
},
send: &IconButton{
Icon: &icon{src: icons.ContentSend, size: unit.Dp(24)},
Inset: layout.UniformInset(unit.Dp(6)),
},
invite: &Button{
Label: "Send invitation",
},
accept: &Button{
Label: "Accept invitation",
},
topbar: &Topbar{
Back: true,
},
}
}
func (p *threadPage) Start(stop <-chan struct{}) {
p.fetchMessages()
p.updates = p.env.client.register(p)
p.env.client.MarkRead(p.thread.ID)
go func() {
<-stop
p.env.client.unregister(p)
}()
}
func (p *threadPage) Event(gtx *layout.Context) interface{} {
select {
case <-p.updates:
p.fetchMessages()
default:
}
for e, ok := p.msgEdit.Event(gtx); ok; e, ok = p.msgEdit.Event(gtx) {
if _, ok := e.(text.SubmitEvent); ok {
p.sendMessage()
}
}
if p.send.Clicked(gtx) {
p.sendMessage()
}
if p.invite.Clicked(gtx) {
if err := p.env.client.Send(p.thread.ID, "Invitation sent"); err != nil {
log.Printf("failed to send invitation: %v", err)
}
}
if p.accept.Clicked(gtx) {
if err := p.env.client.Send(p.thread.ID, "Invitation accepted"); err != nil {
log.Printf("failed to send invitation accept: %v", err)
}
}
return p.topbar.Event(gtx)
}
func (p *threadPage) sendMessage() {
if t := p.msgEdit.Text(); t != "" {
if err := p.env.client.Send(p.thread.ID, t); err != nil {
log.Printf("failed to send message: %v", err)
}
p.msgEdit.SetText("")
}
}
func (p *threadPage) Layout(gtx *layout.Context) {
l := p.list
if l.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
select {
case p.messages = <-p.result:
default:
}
f := layout.Flex{Axis: layout.Vertical}
c1 := f.Rigid(gtx, func() {
p.topbar.Layout(gtx, p.env.insets, func() {
text.Label{Material: theme.white, Size: unit.Sp(20), Text: p.thread.ID}.Layout(gtx, families.primary)
})
})
c3 := f.Rigid(gtx, func() {
in := layout.Inset{
Top: unit.Dp(16),
Left: unit.Max(gtx, unit.Dp(16), p.env.insets.Left),
Right: unit.Max(gtx, unit.Dp(16), p.env.insets.Right),
Bottom: unit.Max(gtx, unit.Dp(16), p.env.insets.Bottom),
}
in.Layout(gtx, func() {
switch {
case p.thread.PendingInvitation:
p.accept.Layout(gtx, p.env)
case p.env.client.ContainsSession(p.thread.ID):
p.layoutMessageBox(gtx)
default:
p.invite.Layout(gtx, p.env)
}
})
})
c2 := f.Flex(gtx, 1, func() {
gtx.Constraints.Height.Min = gtx.Constraints.Height.Max
l.Layout(gtx, len(p.messages), func(i int) {
p.message(gtx, i)
})
})
f.Layout(gtx, c1, c2, c3)
}
func (p *threadPage) layoutMessageBox(gtx *layout.Context) {
if mh := gtx.Px(unit.Dp(100)); gtx.Constraints.Height.Max > mh {
gtx.Constraints.Height.Max = mh
}
f := layout.Flex{Alignment: layout.End}
var sendHeight int
c2 := f.Rigid(gtx, func() {
in := layout.Inset{Left: unit.Dp(8)}
in.Layout(gtx, func() {
p.send.Layout(gtx)
sendHeight = gtx.Dimensions.Size.Y
})
})
c1 := f.Flex(gtx, 1, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
if gtx.Constraints.Height.Min < sendHeight {
gtx.Constraints.Height.Min = sendHeight
}
bg := Background{
Material: colorMaterial(gtx.Ops, rgb(0xeeeeee)),
Inset: layout.UniformInset(unit.Dp(8)),
Radius: unit.Dp(10),
}
bg.Layout(gtx, func() {
layout.Align(layout.W).Layout(gtx, func() {
gtx.Constraints.Width.Min = gtx.Constraints.Width.Max
p.msgEdit.Layout(gtx)
})
})
})
f.Layout(gtx, c1, c2)
}
func (p *threadPage) message(gtx *layout.Context, index int) {
msg := p.messages[index]
in := layout.Inset{Top: unit.Dp(16), Left: unit.Dp(16), Right: unit.Dp(40)}
align := layout.Align(layout.W)
msgMat := colorMaterial(gtx.Ops, rgb(0xffffff))
bgcol := theme.brand
timecol := argb(0xaaaaaaaa)
if msg.Own {
in.Left, in.Right = in.Right, in.Left
align = layout.Align(layout.E)
bgcol = colorMaterial(gtx.Ops, rgb(0xeeeeee))
msgMat = theme.text
timecol = rgb(0x888888)
}
in.Left = unit.Max(gtx, in.Left, p.env.insets.Left)
in.Right = unit.Max(gtx, in.Right, p.env.insets.Right)
in.Layout(gtx, func() {
align.Layout(gtx, func() {
bg := Background{
Material: bgcol,
Inset: layout.Inset{Top: unit.Dp(8), Bottom: unit.Dp(8), Left: unit.Dp(12), Right: unit.Dp(12)},
Radius: unit.Dp(10),
}
bg.Layout(gtx, func() {
f := layout.Flex{Axis: layout.Vertical}
var msgWidth int
c1 := f.Rigid(gtx, func() {
label := text.Label{Material: msgMat, Size: unit.Sp(14), Text: msg.Message}
label.Layout(gtx, families.primary)
gtx.Dimensions.Size.Y += gtx.Px(unit.Dp(4))
msgWidth = gtx.Dimensions.Size.X
})
c2 := f.Rigid(gtx, func() {
gtx.Constraints.Width.Min = msgWidth
f := layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween, Alignment: layout.Middle}
var children []layout.FlexChild
child := f.Rigid(gtx, func() {
time := formatTime(msg.Time)
tlbl := text.Label{Material: colorMaterial(gtx.Ops, timecol), Size: unit.Sp(10), Text: time}
tlbl.Layout(gtx, families.primary)
})
children = append(children, child)
if msg.Own {
child := f.Rigid(gtx, func() {
in := layout.Inset{Left: unit.Dp(12)}
in.Layout(gtx, func() {
checkmark := p.checkmark.image(gtx, timecol)
r := checkmark.Bounds()
if msg.Sent {
paint.ImageOp{Src: checkmark, Rect: r}.Add(gtx.Ops)
paint.PaintOp{Rect: toRectF(r)}.Add(gtx.Ops)
}
gtx.Dimensions = layout.Dimensions{Size: r.Size()}
})
})
children = append(children, child)
}
f.Layout(gtx, children...)
})
f.Layout(gtx, c1, c2)
})
})
})
}
func (p *threadPage) fetchMessages() {
p.thread = p.env.client.Thread(p.thread.ID)
go func() {
messages, err := p.env.client.Messages(p.thread.ID)
if err != nil {
log.Printf("scatter: failed to load messages: %v", err)
return
}
p.result <- messages
p.env.client.MarkRead(p.thread.ID)
p.env.redraw()
}()
}
// formatTime formats a time relative to now. For times within a
// week the date is left out.
func formatTime(t time.Time) string {
y, m, d := t.Date()
tday := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
y, m, d = time.Now().Date()
nday := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
n := int(nday.Sub(tday) / (time.Hour * 24))
format := "Jan _2 15:04"
if n < 7 {
format = "Mon 15:04"
}
return t.Format(format)
}
func (b *IconButton) Layout(gtx *layout.Context) {
b.buttonState.clear()
ico := b.Icon.image(gtx, rgb(0xffffff))
bg := Background{
Material: theme.brand,
Radius: unit.Px(1e6),
Inset: b.Inset,
}
bg.Layout(gtx, func() {
sz := image.Point{X: ico.Bounds().Dx(), Y: ico.Bounds().Dy()}
gtx.Constraints = layout.RigidConstraints(gtx.Constraints.Constrain(sz))
widget.Image{Src: ico, Rect: ico.Bounds(), Scale: 1}.Layout(gtx)
})
pointer.EllipseAreaOp{Rect: image.Rectangle{Max: gtx.Dimensions.Size}}.Add(gtx.Ops)
b.click.Add(gtx.Ops)
}
func (a *App) update(gtx *layout.Context) {
page := a.stack.Current()
if e := page.Event(gtx); e != nil {
switch e := e.(type) {
case BackEvent:
a.stack.Pop()
case SignInEvent:
a.env.client.SetAccount(e.Account)
a.stack.Clear(newThreadsPage(&a.env))
case NewThreadEvent:
a.stack.Swap(newThreadPage(&a.env, e.Address))
case ShowContactsEvent:
a.stack.Push(newContactsPage(&a.env))
case ShowThreadEvent:
a.stack.Push(newThreadPage(&a.env, e.Thread))
}
}
}
type fill struct {
material op.MacroOp
}
func (f fill) Layout(gtx *layout.Context) {
cs := gtx.Constraints
d := image.Point{X: cs.Width.Max, Y: cs.Height.Max}
dr := f32.Rectangle{
Max: f32.Point{X: float32(d.X), Y: float32(d.Y)},
}
f.material.Add(gtx.Ops)
paint.PaintOp{Rect: dr}.Add(gtx.Ops)
gtx.Dimensions = layout.Dimensions{Size: d, Baseline: d.Y}
}
func column() layout.Flex {
return layout.Flex{Axis: layout.Vertical}
}
func centerRowOpts() layout.Flex {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}
}
func baseline() layout.Flex {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Baseline}
}
type clipCircle struct {
}
func (cc *clipCircle) Layout(gtx *layout.Context, w layout.Widget) {
var macro op.MacroOp
macro.Record(gtx.Ops)
w()
dims := gtx.Dimensions
macro.Stop()
max := dims.Size.X
if dy := dims.Size.Y; dy > max {
max = dy
}
szf := float32(max)
rr := szf * .5
var stack op.StackOp
stack.Push(gtx.Ops)
rrect(gtx.Ops, szf, szf, rr, rr, rr, rr)
macro.Add(gtx.Ops)
stack.Pop()
}
func toRectF(r image.Rectangle) f32.Rectangle {
return f32.Rectangle{
Min: f32.Point{X: float32(r.Min.X), Y: float32(r.Min.Y)},
Max: f32.Point{X: float32(r.Max.X), Y: float32(r.Max.Y)},
}
}
func (ic *icon) image(c unit.Converter, col color.RGBA) image.Image {
sz := c.Px(ic.size)
if sz == ic.imgSize {
return ic.img
}
m, _ := iconvg.DecodeMetadata(ic.src)
dx, dy := m.ViewBox.AspectRatio()
img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}})
var ico iconvg.Rasterizer
ico.SetDstImage(img, img.Bounds(), draw.Src)
m.Palette[0] = col
iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{
Palette: &m.Palette,
})
ic.img = img
ic.imgSize = sz
return img
}
// https://pomax.github.io/bezierinfo/#circles_cubic.
func rrect(ops *op.Ops, width, height, se, sw, nw, ne float32) {
w, h := float32(width), float32(height)
const c = 0.55228475 // 4*(sqrt(2)-1)/3
var b paint.Path
b.Begin(ops)
b.Move(f32.Point{X: w, Y: h - se})
b.Cube(f32.Point{X: 0, Y: se * c}, f32.Point{X: -se + se*c, Y: se}, f32.Point{X: -se, Y: se}) // SE
b.Line(f32.Point{X: sw - w + se, Y: 0})
b.Cube(f32.Point{X: -sw * c, Y: 0}, f32.Point{X: -sw, Y: -sw + sw*c}, f32.Point{X: -sw, Y: -sw}) // SW
b.Line(f32.Point{X: 0, Y: nw - h + sw})
b.Cube(f32.Point{X: 0, Y: -nw * c}, f32.Point{X: nw - nw*c, Y: -nw}, f32.Point{X: nw, Y: -nw}) // NW
b.Line(f32.Point{X: w - ne - nw, Y: 0})
b.Cube(f32.Point{X: ne * c, Y: 0}, f32.Point{X: ne, Y: ne - ne*c}, f32.Point{X: ne, Y: ne}) // NE
b.End()
}