~f4814n/frost

ca9b9ee41e22358de94a37a05cf475caa7e48ae1 — Fabian Geiselhart 9 months ago 528722a
split into packages
28 files changed, 1261 insertions(+), 862 deletions(-)

M cmd/frost/events.go
D cmd/frost/log.go
M cmd/frost/main.go
M cmd/frost/merge_view.go
M cmd/frost/platform_linux.go
A component.go
A component/cache/cache.go
A component/cache/events.go
A doc.go
A events.go
M go.mod
M go.sum
A logger.go
A util/icon.go
A util/rgb.go
A util/time.go
A view.go
A view/login/event.go
R cmd/frost/login_view.go => view/login/view.go
R cmd/frost/users_view.go => view/memberlist/memberlist.go
R cmd/frost/room_view.go => view/roomhistory/roomhistory.go
R cmd/frost/list_view.go => view/roomlist/roomlist.go
R cmd/frost/util.go => widget/background.go
A widget/clipcircle.go
A widget/fill.go
R cmd/frost/widgets.go => widget/list.go
A widget/roundimage.go
A widget/util.go
M cmd/frost/events.go => cmd/frost/events.go +0 -29
@@ 1,30 1,1 @@
package main

import (
	"git.sr.ht/~f4814n/matrix"
	"net/url"
)

// Received by: App
// Sent by: Everyting
type CacheFetchEvent struct {
	URI      *url.URL
	File     chan matrix.File
	Error    chan error
	Progress chan ProgressPoint
}

type ProgressPoint struct {
	BytesDownloaded, BytesTotal int64
	Percent                     float32
}

// Received by: App
// Sent by: Everyting
type CacheFetchThumbnailEvent struct {
	URI           *url.URL
	Method        matrix.ThumbnailMethod
	Height, Width int
	Thumbnail     chan matrix.Thumbnail
	Error         chan error
}

D cmd/frost/log.go => cmd/frost/log.go +0 -14
@@ 1,14 0,0 @@
package main

import (
	"git.sr.ht/~f4814n/matrix"
	"github.com/sirupsen/logrus"
)

type MatrixLogger struct {
	*logrus.Entry
}

func (m MatrixLogger) WithField(key string, value interface{}) matrix.Logger {
	return MatrixLogger{m.Entry.WithField(key, value)}
}

M cmd/frost/main.go => cmd/frost/main.go +180 -160
@@ 2,7 2,6 @@ package main

import (
	"context"
	"errors"
	"net/http"

	"gioui.org/app"


@@ 12,22 11,22 @@ import (
	"gioui.org/op"
	"gioui.org/unit"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/component/cache"
	"git.sr.ht/~f4814n/frost/view/login"
	"git.sr.ht/~f4814n/frost/view/memberlist"
	"git.sr.ht/~f4814n/frost/view/roomhistory"
	"git.sr.ht/~f4814n/frost/view/roomlist"
	"git.sr.ht/~f4814n/matrix"
	"git.sr.ht/~f4814n/matrix/util/cache"
	"github.com/sirupsen/logrus"
	"go.uber.org/zap"
)

var theme *material.Theme

type Gtx = layout.Context
type Dims = layout.Dimensions
type Event interface{}

type View interface {
	Start(rx chan Event, tx chan Event)
	Layout(Gtx) Dims
	Stop()
}
type (
	Gtx  = layout.Context
	Dims = layout.Dimensions
)

type AppConfig struct {
	Session *SessionConfig `json:"session"`


@@ 41,36 40,52 @@ type SessionConfig struct {
type App struct {
	window   *app.Window
	cli      *matrix.Client
	rx, tx   chan Event
	view     View
	rx, tx   chan frost.Event
	config   AppConfig
	platform Platform
	log      *logrus.Entry
	logger   *zap.Logger

	mux *frost.Mux

	full, left, middle, right         frost.View
	fullID, leftID, middleID, rightID uint
}

func newApp() *App {
	platform := InitPlatform()
	zap, _ := zap.NewDevelopment()
	return &App{
		rx: make(chan Event, 10),
		tx: make(chan Event, 10),
		mux: frost.NewMux(),
		cli: matrix.NewClient(matrix.ClientOpts{
			HTTPClient: http.DefaultClient,
			Backend:    platform.NewBackend(),
			Logger:     MatrixLogger{logrus.WithField("component", "matrix")},
		}),
		window: app.NewWindow(
			app.Size(unit.Dp(400), unit.Dp(800)),
			app.Title("Frost"),
		),
		platform: platform,
		log:      platform.Logger.WithField("component", "app"),
		logger:   zap.Named("frost"),
	}
}

func (a *App) run() error {
	a.view = NewMergeView(a.cli)
	theme = material.NewTheme(gofont.Collection())

	go a.mux.Run()

	_, rx, tx := a.mux.Register()
	a.rx = rx
	a.tx = tx

	// Cache
	cache := cache.New(a.cli, a.platform.Cache, a.logger.Named("cache"))
	_, rx, tx = a.mux.Register()
	go cache.Run(rx, tx)

	matrixEvents := make(chan matrix.Event, 100)
	a.cli.Notify(matrixEvents)

	for {
		select {
		case e := <-a.window.Events():


@@ 79,10 94,9 @@ func (a *App) run() error {
				var err error
				a.config, err = a.platform.LoadConfig()
				if err != nil {
					a.log.WithError(err).Warn("platform: Failed to load config")
					a.logger.Warn("Failed to load config", zap.Error(err))
				}

				a.view.Start(a.tx, a.rx)
				a.initialView()
			case system.FrameEvent:
				var ops op.Ops


@@ 92,44 106,140 @@ func (a *App) run() error {
					Bottom: e.Insets.Bottom,
					Left:   e.Insets.Left,
					Right:  e.Insets.Right,
				}.Layout(gtx, a.view.Layout)
				}.Layout(gtx, a.Layout)
				e.Frame(gtx.Ops)
			case *system.CommandEvent:
				switch e.Type {
				case system.CommandBack:
					e.Cancel = true
					a.tx <- BackEvent{}
					a.rx <- InvalidationEvent{}
					a.tx <- frost.BackEvent{}
					a.rx <- frost.InvalidationEvent{}
				}
			case system.DestroyEvent:
				return e.Err
			}

		case e := <-a.rx:
			logEvent(a.log, e)

			switch e := e.(type) {
			case LoginStartEvent:
			case login.StartEvent:
				go a.login(e)
			case InvalidationEvent:
			case frost.InvalidationEvent:
				a.window.Invalidate()
			case CacheFetchEvent:
				go a.cacheFetchEvent(e)
			case CacheFetchThumbnailEvent:
				go a.cacheFetchThumbnailEvent(e)
			case frost.ShowRoomEvent:
				if a.middle != nil {
					a.middle.Stop()
					a.mux.Unregister(a.middleID)
				}

				self, _ := a.cli.User()
				a.middle = roomhistory.New(self, e.Room, theme, a.logger.Named("roomhistory"))

				id, rx, tx := a.mux.Register()
				a.middleID = id
				go a.middle.Run(rx, tx)
			case frost.ShowMemberlistEvent:
				if a.right != nil {
					a.right.Stop()
					a.mux.Unregister(a.rightID)
					a.right = nil
				}

				a.right = memberlist.New(e.Room, theme, a.logger.Named("memberlist"))
				id, rx, tx := a.mux.Register()
				a.rightID = id
				go a.right.Run(rx, tx)
			case frost.CloseRoomhistoryEvent:
				if a.right != nil {
					a.right.Stop()
					a.mux.Unregister(a.rightID)
					a.right = nil
				}
				a.middle.Stop()
				a.mux.Unregister(a.middleID)
				a.middle = nil
			case frost.CloseMemberlistEvent:
				a.right.Stop()
				a.mux.Unregister(a.rightID)
				a.right = nil
			case frost.BackEvent:
				if a.right != nil {
					a.right.Stop()
					a.mux.Unregister(a.rightID)
					a.right = nil
				} else if a.middle != nil {
					a.middle.Stop()
					a.mux.Unregister(a.middleID)
					a.middle = nil
				}
			}
		case <-matrixEvents:
			a.window.Invalidate()
		}
	}
}

func (a *App) Layout(gtx Gtx) Dims {
	leftsize := gtx.Px(unit.Dp(400))
	rightsize := gtx.Px(unit.Dp(400))

	if a.full != nil {
		return a.full.Layout(gtx)
	}

	if a.left == nil && a.middle == nil && a.right == nil {
		return NoView{}.Layout(gtx)
	}

	if gtx.Constraints.Max.X < gtx.Px(unit.Dp(720)) {
		if view := a.focused(); view != nil {
			return view.Layout(gtx)
		}
		return NoView{}.Layout(gtx)
	} else if gtx.Constraints.Max.X < gtx.Px(unit.Dp(1100)) {
		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
			layout.Rigid(func(gtx Gtx) Dims {
				gtx.Constraints.Max.X = leftsize
				return a.left.Layout(gtx)
			}),
			layout.Flexed(1, func(gtx Gtx) Dims {
				if view := a.focused(); view != nil && view != a.left {
					return view.Layout(gtx)
				} else {
					return NoView{}.Layout(gtx)
				}
			}),
		)
	} else {
		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
			layout.Rigid(func(gtx Gtx) Dims {
				gtx.Constraints.Max.X = leftsize
				return a.left.Layout(gtx)
			}),
			layout.Flexed(1, func(gtx Gtx) Dims {
				if a.middle != nil {
					return a.middle.Layout(gtx)
				}
				return NoView{}.Layout(gtx)
			}),
			layout.Rigid(func(gtx Gtx) Dims {
				if a.right != nil {
					gtx.Constraints.Max.X = rightsize
					return a.right.Layout(gtx)
				}
				return Dims{}
			}),
		)
	}
}

func (a *App) login(event LoginStartEvent) {
func (a *App) login(event login.StartEvent) {
	err := a.cli.Login(event.Username, event.Password)
	if err != nil {
		a.log.WithError(err).Debug("Failed to log in")
		a.tx <- LoginErrorEvent{Err: err}
		a.logger.Debug("Failed to log in", zap.Error(err))
		a.tx <- login.ErrorEvent{Err: err}
		return
	}
	a.log.Debug("Successfully authenticated")
	a.logger.Debug("Successfully authenticated")

	a.config.Session = &SessionConfig{
		MxID:     a.cli.MxID(),


@@ 137,14 247,16 @@ func (a *App) login(event LoginStartEvent) {
	}
	err = a.platform.FlushConfig(a.config)
	if err != nil {
		a.log.WithError(err).Warn("platform: Failed to flush config")
		a.logger.Debug("Failed to flush config", zap.Error(err))
	}

	a.tx <- UnloadViewEvent{Overlay}
	a.tx <- LoadViewEvent{
		Position: Left,
		View:     NewListView(a.cli),
	}
	a.full.Stop()
	a.mux.Unregister(a.fullID)

	a.left = roomlist.New(a.cli, theme, a.logger.Named("roomlist"))
	id, rx, tx := a.mux.Register()
	a.leftID = id
	go a.left.Run(rx, tx)

	matrixEvents := make(chan matrix.Event, 1000)
	a.cli.Notify(matrixEvents)


@@ 152,36 264,45 @@ func (a *App) login(event LoginStartEvent) {
	a.sync()
}

func (a *App) focused() frost.View {
	if a.right != nil {
		return a.right
	} else if a.middle != nil {
		return a.middle
	} else if a.left != nil {
		return a.left
	}

	return nil
}

func (a *App) initialView() {
	if a.config.Session != nil {
		err := a.cli.LoadToken(a.config.Session.MxID, a.config.Session.DeviceID)
		if err != nil {
			a.log.WithField("error", err).Error("Failed to reuse session")
			a.logger.Error("Failed to reuse session", zap.Error(err))
		}
		a.sync()

		a.tx <- LoadViewEvent{
			Position: Left,
			View:     NewListView(a.cli),
		}
		a.left = roomlist.New(a.cli, theme, a.logger.Named("roomlist"))
		id, rx, tx := a.mux.Register()
		a.leftID = id
		go a.left.Run(rx, tx)
	} else {
		a.tx <- LoadViewEvent{
			Position: Overlay,
			View:     NewLoginView(),
		}
		a.full = login.New(theme, a.logger.Named("login"))
		id, rx, tx := a.mux.Register()
		a.fullID = id
		go a.full.Run(rx, tx)
	}

	a.rx <- InvalidationEvent{}
	a.rx <- frost.InvalidationEvent{}
}

func (a *App) sync() {
	// TODO Gracefully stop this goroutine
	go a.cli.Sync(context.TODO(), &matrix.SyncOpts{
		OnError: func(err error) error {
			logrus.WithFields(logrus.Fields{
				"component": "matrix",
				"error":     err,
			}).Warn("Sync error")
			a.logger.Warn("Sync error", zap.Error(err))
			return nil
		},
		Timeout: 10000,


@@ 192,117 313,16 @@ func (a *App) sync() {
		c := make(chan matrix.Event, 100)
		a.cli.Notify(c)
		for range c {
			a.rx <- InvalidationEvent{}
			a.rx <- frost.InvalidationEvent{}
		}
	}()
}

func (a *App) cacheFetchEvent(e CacheFetchEvent) {
	log := a.log.WithFields(logrus.Fields{
		"component": "cache",
		"uri":       e.URI.String(),
	})
	log.Debug("Requested file file")

	file, err := a.cli.DownloadFile(e.URI, "")
	if err != nil {
		log.WithField("error", err).Warn("Failed to get file information")
		return
	}

	progChan, errChan := a.platform.Cache.Put(e.URI, file)

	for {
		select {
		case p := <-progChan:
			progress := ProgressPoint{
				BytesDownloaded: p,
				BytesTotal:      file.Size,
				Percent:         float32(p/file.Size) * 100,
			}
			e.Progress <- progress
		case err := <-errChan:
			if err != nil {
				log.WithField("error", err).Warn("Failed to download file")
				e.Error <- err
				return
			}

			file, err := a.platform.Cache.Get(e.URI)
			if err != nil {
				log.WithField("error", err).Warn("Failed to load file from cache")
				e.Error <- err
				return
			}

			e.File <- file
			e.Error <- nil
			e.Progress <- ProgressPoint{
				BytesDownloaded: file.Size,
				BytesTotal:      file.Size,
				Percent:         100,
			}

			log.Debug("Download complete")
			return
		}
	}
}

func (a *App) cacheFetchThumbnailEvent(e CacheFetchThumbnailEvent) {
	log := a.log.WithFields(logrus.Fields{
		"component": "cache",
		"uri":       e.URI.String(),
		"method":    e.Method,
		"height":    e.Height,
		"width":     e.Width,
	})
	log.Debug("Requested thumbnail")

	thumb, err := a.platform.Cache.GetThumbnail(e.URI, e.Method, e.Width, e.Height)

	if errors.Is(err, cache.ErrNotComplete) {
		panic("concurrent cache access")
	} else if errors.Is(err, cache.ErrCacheMiss) {
		thumb, err := a.cli.DownloadThumbnail(e.URI, e.Width, e.Height, e.Method)
		if err != nil {
			log.WithField("error", err).Warn("Failed to get image information")
			e.Error <- cache.DownloadError{Err: err}
			return
		}

		_, errChan := a.platform.Cache.PutThumbnail(e.URI, e.Method, e.Width, e.Height, thumb)
		err = <-errChan
		if err != nil {
			log.WithField("error", err).Warn("Failed to download thumbnail content")
			e.Error <- err
			return
		}

		thumb, err = a.platform.Cache.GetThumbnail(e.URI, e.Method, e.Width, e.Height)
		if err != nil {
			log.WithField("error", err).Error("Failed to load thumbnail just downloaded")
			e.Error <- err
			return
		}

		e.Thumbnail <- thumb
		e.Error <- nil
	} else if err != nil {
		log.WithField("error", err).Error("Unexpected error while loading thumbnail from cache")
		e.Error <- err
		return
	} else {
		e.Thumbnail <- thumb
		e.Error <- nil
	}
}

func main() {
	go func() {
		a := newApp()
		if err := a.run(); err != nil {
			logrus.Fatal(err)
			a.logger.Fatal("Error in main loop", zap.Error(err))
		}
	}()
	app.Main()

M cmd/frost/merge_view.go => cmd/frost/merge_view.go +158 -214
@@ 1,230 1,174 @@
package main

//
import (
	"image"

	"gioui.org/layout"
	"gioui.org/unit"
	"git.sr.ht/~f4814n/matrix"
	"github.com/sirupsen/logrus"
)

type InvalidationEvent struct{}

type BackEvent struct{}

type Position int

const (
	Left Position = iota
	Middle
	Right
	Overlay
	fwidget "git.sr.ht/~f4814n/frost/widget"
)

type LoadViewEvent struct {
	Position Position
	View     View
}

type UnloadViewEvent struct {
	Position Position
}

// MergeView implements a responsive three grid view.
type MergeView struct {
	rx, tx             chan Event
	rxL, rxM, rxR, rxO chan Event
	txL, txM, txR, txO chan Event
	allRX, allTX       chan Event

	stop chan struct{}

	log *logrus.Entry

	cli *matrix.Client

	left, right, middle, overlay View
}

func NewMergeView(cli *matrix.Client) *MergeView {
	return &MergeView{
		cli: cli,

		log: logrus.WithField("component", "merge_view"),

		stop: make(chan struct{}),

		txL: make(chan Event, 100),
		txM: make(chan Event, 100),
		txR: make(chan Event, 100),
		txO: make(chan Event, 100),

		rxL: make(chan Event, 100),
		rxM: make(chan Event, 100),
		rxR: make(chan Event, 100),
		rxO: make(chan Event, 100),

		allRX: make(chan Event, 100),
		allTX: make(chan Event, 100),
	}
}

func (o *MergeView) Start(rx, tx chan Event) {
	logStart(o.log)

	o.rx, o.tx = rx, tx

	go func() {
		for {
			select {
			case <-o.stop:
				return
			case e := <-o.rxL:
				o.receiveEvent(e)
			case e := <-o.rxM:
				o.receiveEvent(e)
			case e := <-o.rxR:
				o.receiveEvent(e)
			case e := <-o.rxO:
				o.receiveEvent(e)
			case e := <-o.rx:
				o.receiveEvent(e)
			}
		}
	}()
}

// focused returns the rightmost View
func (o *MergeView) focused() View {
	if o.right != nil {
		return o.right
	} else if o.middle != nil {
		return o.middle
	} else if o.left != nil {
		return o.left
	}

	return nil
}

func (o *MergeView) Layout(gtx Gtx) Dims {
	leftsize := gtx.Px(unit.Dp(400))
	rightsize := gtx.Px(unit.Dp(400))

	if o.overlay != nil {
		return o.overlay.Layout(gtx)
	}

	if o.left == nil && o.middle == nil && o.right == nil {
		return NoView{}.Layout(gtx)
	}

	if gtx.Constraints.Max.X < gtx.Px(unit.Dp(720)) {
		if view := o.focused(); view != nil {
			return view.Layout(gtx)
		}
		return NoView{}.Layout(gtx)
	} else if gtx.Constraints.Max.X < gtx.Px(unit.Dp(1100)) {
		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
			layout.Rigid(func(gtx Gtx) Dims {
				gtx.Constraints.Max.X = leftsize
				return o.left.Layout(gtx)
			}),
			layout.Flexed(1, func(gtx Gtx) Dims {
				if view := o.focused(); view != nil && view != o.left {
					return view.Layout(gtx)
				} else {
					return NoView{}.Layout(gtx)
				}
			}),
		)
	} else {
		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
			layout.Rigid(func(gtx Gtx) Dims {
				gtx.Constraints.Max.X = leftsize
				return o.left.Layout(gtx)
			}),
			layout.Flexed(1, func(gtx Gtx) Dims {
				if o.middle != nil {
					return o.middle.Layout(gtx)
				}
				return NoView{}.Layout(gtx)
			}),
			layout.Rigid(func(gtx Gtx) Dims {
				if o.right != nil {
					gtx.Constraints.Max.X = rightsize
					return o.right.Layout(gtx)
				}
				return Dims{}
			}),
		)
	}
}

func (o *MergeView) receiveEvent(event interface{}) {
	logEvent(o.log, event)

	switch event := event.(type) {
	case LoadViewEvent:
		view := event.View

		switch event.Position {
		case Left:
			view.Start(o.txL, o.rxL)
			o.left = view
		case Right:
			view.Start(o.txR, o.rxR)
			o.right = view
		case Middle:
			view.Start(o.txM, o.rxM)
			o.middle = view
		case Overlay:
			view.Start(o.txO, o.rxO)
			o.overlay = view
		}
	case UnloadViewEvent:
		switch event.Position {
		case Left:
			o.left.Stop()
			o.left = nil
		case Right:
			o.right.Stop()
			o.right = nil
		case Middle:
			o.middle.Stop()
			o.middle = nil
		case Overlay:
			o.overlay.Stop()
			o.overlay = nil
		}
	case BackEvent:
		o.focused().Stop()
		if o.focused() == o.right {
			o.right = nil
		} else {
			o.middle = nil
		}
	default:
		o.txL <- event
		o.txM <- event
		o.txR <- event
		o.txO <- event
		o.tx <- event
	}
}

func (o *MergeView) Stop() {
	close(o.stop)
}

//
// type InvalidationEvent struct{}
//
// type BackEvent struct{}
//
// type Position int
//
// const (
// 	Left Position = iota
// 	Middle
// 	Right
// 	Overlay
// )
//
// type LoadViewEvent struct {
// 	Position Position
// 	View     View
// }
//
// type UnloadViewEvent struct {
// 	Position Position
// }
//
// // MergeView implements a responsive three grid view.
// type MergeView struct {
// 	rx, tx chan Event
// 	stop chan struct{}
//
//
// 	cli *matrix.Client
//
// 	left, right, middle, overlay View
// }
//
// func NewMergeView(cli *matrix.Client) *MergeView {
// 	return &MergeView{
// 		cli: cli,
//
// 		stop: make(chan struct{}),
// 	}
// }
//
// func (o *MergeView) Start(rx, tx chan Event) {
// 	o.rx, o.tx = rx, tx
// }
//
// // focused returns the rightmost View
// func (o *MergeView) Layout(gtx Gtx) Dims {
// 	leftsize := gtx.Px(unit.Dp(400))
// 	rightsize := gtx.Px(unit.Dp(400))
//
// 	if o.overlay != nil {
// 		return o.overlay.Layout(gtx)
// 	}
//
// 	if o.left == nil && o.middle == nil && o.right == nil {
// 		return NoView{}.Layout(gtx)
// 	}
//
// 	if gtx.Constraints.Max.X < gtx.Px(unit.Dp(720)) {
// 		if view := o.focused(); view != nil {
// 			return view.Layout(gtx)
// 		}
// 		return NoView{}.Layout(gtx)
// 	} else if gtx.Constraints.Max.X < gtx.Px(unit.Dp(1100)) {
// 		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// 			layout.Rigid(func(gtx Gtx) Dims {
// 				gtx.Constraints.Max.X = leftsize
// 				return o.left.Layout(gtx)
// 			}),
// 			layout.Flexed(1, func(gtx Gtx) Dims {
// 				if view := o.focused(); view != nil && view != o.left {
// 					return view.Layout(gtx)
// 				} else {
// 					return NoView{}.Layout(gtx)
// 				}
// 			}),
// 		)
// 	} else {
// 		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
// 			layout.Rigid(func(gtx Gtx) Dims {
// 				gtx.Constraints.Max.X = leftsize
// 				return o.left.Layout(gtx)
// 			}),
// 			layout.Flexed(1, func(gtx Gtx) Dims {
// 				if o.middle != nil {
// 					return o.middle.Layout(gtx)
// 				}
// 				return NoView{}.Layout(gtx)
// 			}),
// 			layout.Rigid(func(gtx Gtx) Dims {
// 				if o.right != nil {
// 					gtx.Constraints.Max.X = rightsize
// 					return o.right.Layout(gtx)
// 				}
// 				return Dims{}
// 			}),
// 		)
// 	}
// }
//
// // func (o *MergeView) receiveEvent(event interface{}) {
// // 	switch event := event.(type) {
// // 	case LoadViewEvent:
// // 		view := event.View
// //
// // 		switch event.Position {
// // 		case Left:
// // 			view.Start(o.txL, o.rxL)
// // 			o.left = view
// // 		case Right:
// // 			view.Start(o.txR, o.rxR)
// // 			o.right = view
// // 		case Middle:
// // 			view.Start(o.txM, o.rxM)
// // 			o.middle = view
// // 		case Overlay:
// // 			view.Start(o.txO, o.rxO)
// // 			o.overlay = view
// // 		}
// // 	case UnloadViewEvent:
// // 		switch event.Position {
// // 		case Left:
// // 			o.left.Stop()
// // 			o.left = nil
// // 		case Right:
// // 			o.right.Stop()
// // 			o.right = nil
// // 		case Middle:
// // 			o.middle.Stop()
// // 			o.middle = nil
// // 		case Overlay:
// // 			o.overlay.Stop()
// // 			o.overlay = nil
// // 		}
// // 	case BackEvent:
// // 		o.focused().Stop()
// // 		if o.focused() == o.right {
// // 			o.right = nil
// // 		} else {
// // 			o.middle = nil
// // 		}
// // 	default:
// // 		o.txL <- event
// // 		o.txM <- event
// // 		o.txR <- event
// // 		o.txO <- event
// // 		o.tx <- event
// // 	}
// // }
// //
// // func (o *MergeView) Stop() {
// // 	close(o.stop)
// // }
//
type NoView struct{}

func (v NoView) Layout(gtx Gtx) Dims {
	return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
		layout.Rigid(func(gtx Gtx) Dims {
			return layout.Stack{Alignment: layout.SW}.Layout(gtx,
				layout.Expanded(fill{theme.Color.Primary}.Layout),
				layout.Expanded(fwidget.Fill{theme.Color.Primary}.Layout),
				layout.Stacked(func(gtx Gtx) Dims {
					return Dims{Size: image.Point{X: gtx.Constraints.Max.X, Y: gtx.Px(unit.Dp(48))}}
				}),

M cmd/frost/platform_linux.go => cmd/frost/platform_linux.go +2 -0
@@ 36,6 36,8 @@ func InitPlatform() Platform {
		panic(err.Error())
	}

	logrus.SetLevel(logrus.DebugLevel)

	return Platform{Cache: cache, Logger: logrus.New()}
}


A component.go => component.go +72 -0
@@ 0,0 1,72 @@
package frost

import "sync"

type Event interface {
	FrostEvent()
}

type Component interface {
	Run(rx chan Event, tx chan Event)
	Stop()
}

type Mux struct {
	sync.RWMutex

	currentID uint

	rx map[uint]chan Event
	tx map[uint]chan Event
}

func NewMux() *Mux {
	return &Mux{
		currentID: 0,
		rx:        make(map[uint]chan Event),
		tx:        make(map[uint]chan Event),
	}
}

func (m *Mux) Register() (id uint, rx, tx chan Event) {
	m.Lock()
	defer m.Unlock()

	m.currentID += 1

	rx = make(chan Event, 100)
	tx = make(chan Event, 100)

	m.rx[m.currentID] = tx
	m.tx[m.currentID] = rx

	return m.currentID, rx, tx
}

func (m *Mux) Unregister(id uint) {
	m.Lock()
	defer m.Unlock()

	delete(m.rx, id)
	delete(m.tx, id)
}

func (m *Mux) Run() {
	for {
		m.RLock()
		for _, ch := range m.rx {
			select {
			case event := <-ch:
				for _, ch := range m.tx {
					select {
					case ch <- event:
					default:
						panic("Mux channel full")
					}
				}
			default:
			}
		}
		m.RUnlock()
	}
}

A component/cache/cache.go => component/cache/cache.go +166 -0
@@ 0,0 1,166 @@
package cache

import (
	"errors"

	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/matrix"
	"git.sr.ht/~f4814n/matrix/util/cache"
	"go.uber.org/zap"
)

type component struct {
	stop   chan struct{}
	cli    *matrix.Client
	cache  cache.Cache
	logger *zap.SugaredLogger
}

func New(cli *matrix.Client, cache cache.Cache, logger *zap.Logger) frost.Component {
	sugar := logger.Sugar()
	return component{
		stop:   make(chan struct{}),
		cli:    cli,
		cache:  cache,
		logger: sugar,
	}
}

func (c component) Run(rx, tx chan frost.Event) {
loop:
	for {
		select {
		case event := <-rx:
			switch event := event.(type) {
			case FetchEvent:
				c.facheFetchEvent(event)
			case FetchThumbnailEvent:
				c.fetchThumbnailEvent(event)
			}
		case <-c.stop:
			break loop
		}
	}

	c.logger.Sync()
}

func (c component) facheFetchEvent(e FetchEvent) {
	log := c.logger.With(
		"component", "cache",
		"uri", e.URI.String(),
	)
	log.Debug("Requested file file")

	file, err := c.cli.DownloadFile(e.URI, "")
	if err != nil {
		log.Warnw("Failed to get file information",
			"error", err,
		)
		e.Error <- err
		return
	}

	progChan, errChan := c.cache.Put(e.URI, file)

	for {
		select {
		case p := <-progChan:
			progress := ProgressPoint{
				BytesDownloaded: p,
				BytesTotal:      file.Size,
				Percent:         float32(p/file.Size) * 100,
			}
			e.Progress <- progress
		case err := <-errChan:
			if err != nil {
				log.Warnw("Failed to download file",
					"error", err,
				)
				e.Error <- err
				return
			}

			file, err := c.cache.Get(e.URI)
			if err != nil {
				log.Warnw("Failed to load file from cache",
					"error", err,
				)
				e.Error <- err
				return
			}

			e.File <- file
			e.Error <- nil
			e.Progress <- ProgressPoint{
				BytesDownloaded: file.Size,
				BytesTotal:      file.Size,
				Percent:         100,
			}

			log.Debug("Download complete")
			return
		}
	}
}

func (c component) fetchThumbnailEvent(e FetchThumbnailEvent) {
	log := c.logger.With(
		"component", "cache",
		"uri", e.URI.String(),
		"method", e.Method,
		"height", e.Height,
		"width", e.Width,
	)
	log.Debug("Requested thumbnail")

	thumb, err := c.cache.GetThumbnail(e.URI, e.Method, e.Width, e.Height)

	if errors.Is(err, cache.ErrNotComplete) {
		panic("concurrent cache access")
	} else if errors.Is(err, cache.ErrCacheMiss) {
		thumb, err := c.cli.DownloadThumbnail(e.URI, e.Width, e.Height, e.Method)
		if err != nil {
			log.Warnw("Failed to get image information",
				"error", err,
			)
			e.Error <- cache.DownloadError{Err: err}
			return
		}

		_, errChan := c.cache.PutThumbnail(e.URI, e.Method, e.Width, e.Height, thumb)
		err = <-errChan
		if err != nil {
			log.Warnw("Failed to download thumbnail content",
				"error", err,
			)
			e.Error <- err
			return
		}

		thumb, err = c.cache.GetThumbnail(e.URI, e.Method, e.Width, e.Height)
		if err != nil {
			log.Warnw("Failed to load thumbnail just downloaded",
				"error", err,
			)
			e.Error <- err
			return
		}

		e.Thumbnail <- thumb
		e.Error <- nil
	} else if err != nil {
		log.Errorw("Unexpected error while loading thumbnail from cache",
			"error", err,
		)
		e.Error <- err
		return
	} else {
		e.Thumbnail <- thumb
		e.Error <- nil
	}
}

func (c component) Stop() {
	close(c.stop)
}

A component/cache/events.go => component/cache/events.go +30 -0
@@ 0,0 1,30 @@
package cache

import (
	"net/url"
	"git.sr.ht/~f4814n/matrix"
)

type FetchEvent struct {
	URI      *url.URL
	File     chan matrix.File
	Error    chan error
	Progress chan ProgressPoint
}

func (c FetchEvent) FrostEvent() { }

type ProgressPoint struct {
	BytesDownloaded, BytesTotal int64
	Percent                     float32
}

type FetchThumbnailEvent struct {
	URI           *url.URL
	Method        matrix.ThumbnailMethod
	Height, Width int
	Thumbnail     chan matrix.Thumbnail
	Error         chan error
}

func (c FetchThumbnailEvent) FrostEvent() { }

A doc.go => doc.go +3 -0
@@ 0,0 1,3 @@
// Frost is a matrix client built using gio. It consists of Views and Components
// interacting with another. 
package frost

A events.go => events.go +31 -0
@@ 0,0 1,31 @@
package frost

import "git.sr.ht/~f4814n/matrix"

type ShowRoomEvent struct {
	Room matrix.Room
}

func (s ShowRoomEvent) FrostEvent() {}

type CloseMemberlistEvent struct{}

func (s CloseMemberlistEvent) FrostEvent() {}

type ShowMemberlistEvent struct {
	Room matrix.Room
}

func (s ShowMemberlistEvent) FrostEvent() {}

type CloseRoomhistoryEvent struct{}

func (s CloseRoomhistoryEvent) FrostEvent() {}

type InvalidationEvent struct{}

func (s InvalidationEvent) FrostEvent() {}

type BackEvent struct{}

func (s BackEvent) FrostEvent() {}

M go.mod => go.mod +10 -3
@@ 5,11 5,18 @@ go 1.14
require (
	gioui.org v0.0.0-20201018162216-7a4b48f67b54
	gioui.org/cmd v0.0.0-20201020094634-d5bdf0756a5a
	git.sr.ht/~f4814n/matrix v0.0.0-20201119201227-5306ad790987
	github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636 // indirect
	github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041 // indirect
	git.sr.ht/~f4814n/matrix v0.0.0-20201215165513-ab5355096a5a
	github.com/sirupsen/logrus v1.7.0
	go.uber.org/zap v1.16.0
	golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7
	golang.org/x/image v0.0.0-20200618115811-c13761719519
	golang.org/x/mod v0.4.0 // indirect
	golang.org/x/sync v0.0.0-20201207232520-09787c993a3a // indirect
	golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 // indirect
	golang.org/x/tools v0.0.0-20201215192005-fa10ef0b8743 // indirect
	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
	gopkg.in/yaml.v2 v2.2.4 // indirect
	honnef.co/go/tools v0.1.0 // indirect
)

replace git.sr.ht/~f4814n/matrix => ../matrix

M go.sum => go.sum +72 -10
@@ 4,18 4,15 @@ gioui.org v0.0.0-20201018162216-7a4b48f67b54 h1:mM+tSH6C2GBkaErKviXhGL22qiGNgUMu
gioui.org v0.0.0-20201018162216-7a4b48f67b54/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw=
gioui.org/cmd v0.0.0-20201020094634-d5bdf0756a5a h1:BPgJqeQSxuX6CzFQ2EGDj4Kr2ZzwiqwdLyAYLXgxR+k=
gioui.org/cmd v0.0.0-20201020094634-d5bdf0756a5a/go.mod h1:dlmJnCEkOpRaChYxRmJZ5S4jk6y7DCfWnec39xGbUYk=
git.sr.ht/~f4814n/matrix v0.0.0-20201117163840-4881dc01ab55 h1:eU6c32iLiDeJjmSNdLgbUGkv+Knvx4jxHcbgYCSmyAg=
git.sr.ht/~f4814n/matrix v0.0.0-20201117163840-4881dc01ab55/go.mod h1:LinuYhN8Rh6XQ9Cy+XM1hTTZT6zDs6ONBTwXAoWCjI4=
git.sr.ht/~f4814n/matrix v0.0.0-20201119195633-ca71339e7b7e h1:IDXjMbqs+q39NpdO9VR+Eg8X1DRWVyw8FRwhuX7163M=
git.sr.ht/~f4814n/matrix v0.0.0-20201119195633-ca71339e7b7e/go.mod h1:LinuYhN8Rh6XQ9Cy+XM1hTTZT6zDs6ONBTwXAoWCjI4=
git.sr.ht/~f4814n/matrix v0.0.0-20201119201227-5306ad790987 h1:M8S5YSPa3hfl18ab/OhKPK/Af2StnfTR01ywLBVwSwQ=
git.sr.ht/~f4814n/matrix v0.0.0-20201119201227-5306ad790987/go.mod h1:LinuYhN8Rh6XQ9Cy+XM1hTTZT6zDs6ONBTwXAoWCjI4=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=


@@ 29,31 26,57 @@ github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.7.0 h1:h93mCPfUSkaul3Ka/VG8uZdmW1uMHDGxzu0NWHuJmHY=
github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI=
github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636 h1:aSISeOcal5irEhJd1M+IrApc0PdcN7e7Aj4yuEnOrfQ=
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041 h1:llrF3Fs4018ePo4+G/HV/uQUqEI1HMDjCeOf2V6puPc=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tidwall/gjson v1.6.3/go.mod h1:BaHyNc5bjzYkPqgLq7mdVzeiRtULKULXLgZFKsxEHI0=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7 h1:2/QncOxxpPAdiH+E00abYw/SaQG353gltz79Nl1zrYE=


@@ 62,16 85,31 @@ golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMx
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449 h1:xUIPaMhvROX9dhPvRCenIJtU78+lbEenGbgqB5hfHCQ=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0 h1:8pl+sMODzuvGJkmj2W4kZihvVb5mKm8pB/X44PIQHv8=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=


@@ 80,19 118,43 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa h1:5E4dL8+NgFOgjwbTKz+OOEGGhP+ectTmF842l6KjupQ=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200609164405-eb789aa7ce50/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201215192005-fa10ef0b8743 h1:SLHKXsC4wI4NdEGVGe/yxcTBkF/mPUS7agW3Qt5smVg=
golang.org/x/tools v0.0.0-20201215192005-fa10ef0b8743/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.1.0 h1:AWNL1W1i7f0wNZ8VwOKNJ0sliKvOF/adn0EHenfUh+c=
honnef.co/go/tools v0.1.0/go.mod h1:XtegFAyX/PfluP4921rXU5IkjkqBCDnUq4W8VCIoKvM=

A logger.go => logger.go +4 -0
@@ 0,0 1,4 @@
package frost

type Logger interface {
}

A util/icon.go => util/icon.go +12 -0
@@ 0,0 1,12 @@
package util

import "gioui.org/widget"

func MustIcon(data []byte) *widget.Icon {
	ico, err := widget.NewIcon(data)
	if err != nil {
		panic(err)
	}
	return ico
}


A util/rgb.go => util/rgb.go +12 -0
@@ 0,0 1,12 @@
package util

import "image/color"

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)}
}


A util/time.go => util/time.go +29 -0
@@ 0,0 1,29 @@
package util

import (
	"time"
	"git.sr.ht/~f4814n/matrix"
)

func EventTime(event matrix.Event) time.Time {
	switch event := event.(type) {
	case matrix.RoomEvent:
		return event.OriginServerTS
	case matrix.StateEvent:
		return event.OriginServerTS
	}
	return time.Time{}
}

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)
}

A view.go => view.go +9 -0
@@ 0,0 1,9 @@
package frost

import "gioui.org/layout"

type View interface {
	Component

	Layout(layout.Context) layout.Dimensions
}

A view/login/event.go => view/login/event.go +13 -0
@@ 0,0 1,13 @@
package login

type StartEvent struct {
	Username, Password string
}

func (e StartEvent) FrostEvent() { }

type ErrorEvent struct {
	Err error
}

func (e ErrorEvent) FrostEvent() { }

R cmd/frost/login_view.go => view/login/view.go +33 -33
@@ 1,17 1,28 @@
package main
package login

import (
	"image"

	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/util"

	"go.uber.org/zap"

	"gioui.org/layout"
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"github.com/sirupsen/logrus"
)

type LoginView struct {
	rx, tx chan Event
type (
	g = layout.Context
	d = layout.Dimensions
)

type view struct {
	rx, tx chan frost.Event

	theme *material.Theme

	usernameEditor *widget.Editor
	passwordEditor *widget.Editor


@@ 21,50 32,39 @@ type LoginView struct {

	loginInProcess bool

	log *logrus.Entry
	logger *zap.Logger
}

type LoginStartEvent struct {
	Username, Password string
}

type LoginErrorEvent struct {
	Err error
}

func NewLoginView() *LoginView {
	return &LoginView{
func New(theme *material.Theme, logger *zap.Logger) frost.View {
	return &view{
		loginInProcess: false,
		usernameEditor: &widget.Editor{Submit: true, SingleLine: true},
		passwordEditor: &widget.Editor{Submit: true, SingleLine: true, Mask: '•'},
		loginButton:    new(widget.Clickable),
		log:            logrus.WithField("component", "login_view"),
		logger:         logger,
		theme:          theme,
	}
}

func (l *LoginView) Start(rx, tx chan Event) {
	logStart(l.log)

func (l *view) Run(rx, tx chan frost.Event) {
	l.rx, l.tx = rx, tx
}

func (l *LoginView) update() {
func (l *view) update() {
	if l.loginButton.Clicked() {
		l.loginInProcess = true

		username := l.usernameEditor.Text()
		password := l.passwordEditor.Text()

		l.tx <- LoginStartEvent{Username: username, Password: password}
		l.tx <- StartEvent{Username: username, Password: password}
	}

	for {
		select {
		case e := <-l.rx:
			logEvent(l.log, e)

			switch e := e.(type) {
			case LoginErrorEvent:
			case ErrorEvent:
				l.loginInProcess = false
				l.errorMessage = e.Err.Error()
			}


@@ 74,19 74,19 @@ func (l *LoginView) update() {
	}
}

func (l *LoginView) Layout(gtx Gtx) Dims {
func (l *view) Layout(gtx layout.Context) layout.Dimensions {
	l.update()

	if l.loginInProcess {
		gtx = gtx.Disabled()
	}

	return layout.Center.Layout(gtx, func(gtx Gtx) Dims {
	return layout.Center.Layout(gtx, func(gtx g) d {
		gtx.Constraints = layout.Exact(image.Point{X: gtx.Px(unit.Dp(280)), Y: gtx.Px(unit.Dp(480))})
		return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
			layout.Rigid(material.Editor(theme, l.usernameEditor, "Username").Layout),
			layout.Rigid(material.Editor(theme, l.passwordEditor, "Password").Layout),
			layout.Rigid(func(gtx Gtx) Dims {
			layout.Rigid(material.Editor(l.theme, l.usernameEditor, "Username").Layout),
			layout.Rigid(material.Editor(l.theme, l.passwordEditor, "Password").Layout),
			layout.Rigid(func(gtx g) d {
				in := layout.Inset{}

				if l.errorMessage != "" {


@@ 96,14 96,14 @@ func (l *LoginView) Layout(gtx Gtx) Dims {
					}
				}

				lbl := material.Caption(theme, l.errorMessage)
				lbl.Color = rgb(0xff0000)
				lbl := material.Caption(l.theme, l.errorMessage)
				lbl.Color = util.RGB(0xff0000)
				return in.Layout(gtx, lbl.Layout)
			}),
			layout.Rigid(material.Button(theme, l.loginButton, "Login").Layout),
			layout.Rigid(material.Button(l.theme, l.loginButton, "Login").Layout),
		)
	})
}

func (l *LoginView) Stop() {
func (l *view) Stop() {
}

R cmd/frost/users_view.go => view/memberlist/memberlist.go +71 -50
@@ 1,12 1,23 @@
package main
package memberlist

import (
	"image"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"net/url"
	"unicode"

	"gioui.org/layout"
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/component/cache"
	"git.sr.ht/~f4814n/frost/util"
	fwidget "git.sr.ht/~f4814n/frost/widget"
	"git.sr.ht/~f4814n/matrix"
	"github.com/sirupsen/logrus"
	"go.uber.org/zap"
	"golang.org/x/exp/shiny/materialdesign/icons"
	_ "golang.org/x/image/bmp"
	"golang.org/x/image/draw"


@@ 14,21 25,22 @@ import (
	_ "golang.org/x/image/vp8"
	_ "golang.org/x/image/vp8l"
	_ "golang.org/x/image/webp"
	"image"
	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
	"net/url"
	"unicode"
)

type AddAvatarEvent struct {
type (
	g = layout.Context
	d = layout.Dimensions
)

type addAvatarEvent struct {
	Image image.Image
	User  matrix.User
}

type UsersView struct {
	rx, tx chan Event
func (e addAvatarEvent) FrostEvent() {}

type view struct {
	rx, tx chan frost.Event

	matrixEvents chan matrix.Event



@@ 41,22 53,24 @@ type UsersView struct {

	room matrix.Room

	log *logrus.Entry
	theme *material.Theme

	logger *zap.Logger
}

func NewUsersView(room matrix.Room) *UsersView {
	return &UsersView{
func New(room matrix.Room, theme *material.Theme, logger *zap.Logger) frost.View {
	return &view{
		matrixEvents: make(chan matrix.Event, 100),
		avatars:      make(map[string]image.Image),
		backButton:   new(widget.Clickable),
		userList:     &layout.List{Axis: layout.Vertical},
		room:         room,
		log:          logrus.WithField("component", "users_view"),
		logger:       logger,
		theme:        theme,
	}

}

func (v *UsersView) Start(rx, tx chan Event) {
func (v *view) Run(rx, tx chan frost.Event) {
	v.rx, v.tx = rx, tx

	v.room.Notify(v.matrixEvents)


@@ 66,7 80,7 @@ func (v *UsersView) Start(rx, tx chan Event) {
	}
}

func (v *UsersView) addUser(user matrix.Member) {
func (v *view) addUser(user matrix.Member) {
	for _, v := range v.users {
		if v == user {
			return


@@ 74,18 88,26 @@ func (v *UsersView) addUser(user matrix.Member) {
	}

	v.users = append(v.users, user)
	v.log.WithField("user", user.Displayname()).Debug("Adding user")
	v.logger.Debug("Adding user", zap.String("user", user.Displayname()))

	go v.updateAvatar(user)
}

func (v *UsersView) updateAvatar(user matrix.Member) {
	uriString := v.room.GetState("m.room.member", user.ID).Content["avatar_url"]
func (v *view) updateAvatar(user matrix.Member) {
	event, ok := v.room.GetState("m.room.member", user.ID)
	if !ok {
		return
	}

	uriString, ok := event.Content["avatar_url"]
	if !ok {
		return
	}

	log := v.log.WithFields(logrus.Fields{
		"user": user.Displayname(),
		"url":  uriString,
	})
	log := v.logger.With(
		zap.String("user", user.Displayname()),
		zap.Any("url", uriString),
	)

	if _, ok := uriString.(string); !ok {
		log.Debug("avatar_url is not a string. Empty?")


@@ 94,14 116,14 @@ func (v *UsersView) updateAvatar(user matrix.Member) {

	uri, err := url.Parse(uriString.(string))
	if err != nil {
		log.WithField("error", err).Debug("User has invalid avatar_url")
		log.Debug("User has invalid avatar_url", zap.Error(err))
		return
	}

	errChan := make(chan error, 1)
	thumbChan := make(chan matrix.Thumbnail, 1)

	v.tx <- CacheFetchThumbnailEvent{
	v.tx <- cache.FetchThumbnailEvent{
		URI:       uri,
		Method:    matrix.Scale,
		Height:    64,


@@ 112,7 134,7 @@ func (v *UsersView) updateAvatar(user matrix.Member) {

	err = <-errChan
	if err != nil {
		log.WithField("error", err).Debug("Failed to fetch thumbnail")
		log.Debug("Failed to fetch thumbnail", zap.Error(err))
		return
	}



@@ 120,20 142,20 @@ func (v *UsersView) updateAvatar(user matrix.Member) {

	img, _, err := image.Decode(thumb.Content)
	if err != nil {
		log.WithField("error", err).Debug("Failed to decode image")
		log.Debug("Failed to decode image", zap.Error(err))
		return
	}

	dst := image.NewRGBA(image.Rect(0, 0, 46, 46))
	draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)

	v.rx <- AddAvatarEvent{
	v.rx <- addAvatarEvent{
		Image: dst,
		User:  user.User,
	}
}

func (v *UsersView) update(gtx Gtx) {
func (v *view) update(gtx g) {
loop:
	for {
		select {


@@ 147,8 169,8 @@ loop:
			}
		case event := <-v.rx:
			switch event := event.(type) {
			case AddAvatarEvent:
				v.log.WithField("user", event.User.ID).Debug("Adding avatar image")
			case addAvatarEvent:
				v.logger.Debug("Adding avatar image", zap.String("user", event.User.ID))
				v.avatars[event.User.ID] = event.Image
			}
		default:


@@ 157,22 179,22 @@ loop:
	}

	if v.backButton.Clicked() {
		v.tx <- BackEvent{}
		v.tx <- frost.CloseMemberlistEvent{}
	}
}

func (v *UsersView) Layout(gtx Gtx) Dims {
func (v *view) Layout(gtx layout.Context) layout.Dimensions {
	v.update(gtx)

	return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
		layout.Rigid(v.topbar),
		layout.Flexed(1, func(gtx Gtx) Dims {
		layout.Flexed(1, func(gtx g) d {
			return v.userList.Layout(gtx, len(v.users), v.listElem)
		}),
	)
}

func (v *UsersView) listElem(gtx Gtx, index int) Dims {
func (v *view) listElem(gtx g, index int) d {
	user := v.users[index]

	var initial string


@@ 181,40 203,39 @@ func (v *UsersView) listElem(gtx Gtx, index int) Dims {
		break
	}

	icon := InitialSign{Initial: initial}.Layout
	icon := fwidget.InitialSign{Initial: initial, Theme: v.theme}.Layout
	if img, ok := v.avatars[user.ID]; ok {
		icon = func(gtx Gtx) Dims {
		icon = func(gtx g) d {
			gtx.Constraints = layout.Exact(image.Point{
				X: gtx.Px(unit.Dp(46)),
				Y: gtx.Px(unit.Dp(46)),
			})
			return RoundImage{Image: img}.Layout(gtx)
			return fwidget.RoundImage{Image: img}.Layout(gtx)
		}
	}

	elem := IconListElement{
	elem := fwidget.IconListElement{
		Icon:    icon,
		Content: material.Body1(theme, user.Displayname()).Layout,
		Content: material.Body1(v.theme, user.Displayname()).Layout,
	}
	return elem.Layout(gtx)
}

func (v *UsersView) Stop() {
func (v *view) Stop() {
	v.room.Stop(v.matrixEvents)
}

func (v *UsersView) topbar(gtx Gtx) Dims {
func (v *view) topbar(gtx g) d {
	return layout.Stack{Alignment: layout.SW}.Layout(gtx,
		layout.Expanded(func(gtx Gtx) Dims {
		layout.Expanded(func(gtx g) d {
			gtx.Constraints.Min.X = gtx.Constraints.Max.X
			return fill{theme.Color.Primary}.Layout(gtx)
			return fwidget.Fill{Color: v.theme.Color.Primary}.Layout(gtx)
		}),
		layout.Stacked(func(gtx Gtx) Dims {
		layout.Stacked(func(gtx g) d {
			return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
				layout.Rigid(func(gtx Gtx) Dims {
					btn := material.IconButton(theme, v.backButton, mustIcon(icons.NavigationArrowBack))
				layout.Rigid(func(gtx g) d {
					btn := material.IconButton(v.theme, v.backButton, util.MustIcon(icons.NavigationArrowBack))
					return btn.Layout(gtx)

				}),
			)
		}),

R cmd/frost/room_view.go => view/roomhistory/roomhistory.go +147 -127
@@ 1,11 1,15 @@
package main
package roomhistory

import (
	"errors"
	"fmt"
	"time"

	"github.com/sirupsen/logrus"
	fwidget "git.sr.ht/~f4814n/frost/widget"

	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/util"

	"golang.org/x/exp/shiny/materialdesign/icons"

	"gioui.org/layout"


@@ 13,14 17,24 @@ import (
	"gioui.org/widget"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/matrix"
	"go.uber.org/zap"
)

type (
	g = layout.Context
	d = layout.Dimensions
)

type SentRoomEvent struct {
type sentRoomEvent struct {
	Err error
}

type RoomView struct {
	rx, tx chan Event
func (e sentRoomEvent) FrostEvent() {}

type view struct {
	rx, tx chan frost.Event

	theme *material.Theme

	matrixEvents chan matrix.Event
	pastEvents   chan matrix.Event


@@ 36,65 50,63 @@ type RoomView struct {

	messageComposer *messageComposer

	log *logrus.Entry
	logger *zap.Logger
}

func NewRoomView(cli *matrix.Client, room matrix.Room) *RoomView {
	return &RoomView{
		cli:             cli,
func New(self matrix.User, room matrix.Room, theme *material.Theme, logger *zap.Logger) frost.View {
	return &view{
		matrixEvents:    make(chan matrix.Event, 100),
		pastEvents:      make(chan matrix.Event, 100),
		room:            room,
		roomHistory:     newRoomHistory(cli.User().ID),
		roomHistory:     newRoomHistory(self.ID, theme, logger),
		history:         room.History(),
		backButton:      new(widget.Clickable),
		settingsButton:  new(widget.Clickable),
		userListButton:  new(widget.Clickable),
		messageComposer: newMessageComposer(),
		log:             logrus.WithField("component", "room_view"),
		messageComposer: newMessageComposer(theme),
		logger:          logger,
		theme:           theme,
	}
}

func (r *RoomView) Start(rx, tx chan Event) {
func (r *view) Run(rx, tx chan frost.Event) {
	r.logger.Info("Showing room", zap.String("id", r.room.ID))

	r.rx, r.tx = rx, tx

	r.room.Notify(r.matrixEvents)

	// TODO Only load history which is actually needed
	// TODO Stop this goroutine
	go func() {
		for r.history.Next() {
			if r.history.Err != nil {
				r.log.WithField("error", r.history.Err).Warn("Error while traversing history")
				// We probably have insufficient permissions for viewing the history
				// so let's not retry
				if errors.As(r.history.Err, &matrix.LogicError{}) {
					break
				}
				continue
	for r.history.Next() {
		if r.history.Err != nil {
			r.logger.Warn("Error while traversing history", zap.Error(r.history.Err))
			// We probably have insufficient permissions for viewing the history
			// so let's not retry
			if errors.As(r.history.Err, &matrix.LogicError{}) {
				break
			}
			continue
		}

			for _, e := range r.history.Events {
				r.pastEvents <- e
			}
		for _, e := range r.history.Events {
			r.pastEvents <- e
		}
	}()
	}
}

func (r *RoomView) update() {
func (r *view) update() {
	if r.backButton.Clicked() {
		r.tx <- BackEvent{}
		r.tx <- frost.CloseRoomhistoryEvent{}
	}

	if r.settingsButton.Clicked() {
		r.log.Warn("Not implemented")
		r.logger.Warn("Not implemented")
	}

	if r.userListButton.Clicked() {
		r.log.Info("Opening UsersView")
		r.tx <- LoadViewEvent{
			View:     NewUsersView(r.room),
			Position: Right,
		r.logger.Info("Opening UsersView")
		r.tx <- frost.ShowMemberlistEvent{
			Room: r.room,
		}
	}



@@ 106,20 118,16 @@ func (r *RoomView) update() {
				"body":    r.messageComposer.Text(),
				"msgtype": "m.text",
			})
			r.rx <- SentRoomEvent{Err: err}
			r.rx <- sentRoomEvent{Err: err}
		}()
	}

	for {
		select {
		case event := <-r.rx:
			r.log.WithField("event", event).Trace("Received event")

			switch event := event.(type) {
			case SentRoomEvent:
			case sentRoomEvent:
				r.messageComposer.Reenable(event.Err)
			default:
				r.log.WithField("event", event).Warn("Unexpected event")
			}
		case event := <-r.matrixEvents:
			switch event.(type) {


@@ 138,10 146,10 @@ func (r *RoomView) update() {
	}
}

func (r *RoomView) Stop() {
func (r *view) Stop() {
}

func (r *RoomView) Layout(gtx Gtx) Dims {
func (r *view) Layout(gtx g) d {
	r.update()

	return layout.Flex{Alignment: layout.Start, Axis: layout.Vertical}.Layout(gtx,


@@ 151,29 159,32 @@ func (r *RoomView) Layout(gtx Gtx) Dims {
	)
}

func (r *RoomView) topbar(gtx Gtx) Dims {
func (r *view) topbar(gtx g) d {
	return layout.Stack{Alignment: layout.SW}.Layout(gtx,
		layout.Expanded(fill{theme.Color.Primary}.Layout),
		layout.Stacked(func(gtx Gtx) Dims {
		layout.Expanded(fwidget.Fill{Color: r.theme.Color.Primary}.Layout),
		layout.Stacked(func(gtx g) d {
			return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
				layout.Rigid(func(gtx Gtx) Dims {
					btn := material.IconButton(theme, r.backButton, mustIcon(icons.NavigationArrowBack))
				layout.Rigid(func(gtx g) d {
					icon := util.MustIcon(icons.NavigationArrowBack)
					btn := material.IconButton(r.theme, r.backButton, icon)
					return btn.Layout(gtx)
				}),
				layout.Flexed(1, func(gtx Gtx) Dims {
				layout.Flexed(1, func(gtx g) d {
					gtx.Constraints.Min.X = gtx.Constraints.Max.X
					lbl := material.H6(theme, r.room.Displayname())
					lbl.Color = rgb(0xffffff)
					lbl := material.H6(r.theme, r.room.Displayname())
					lbl.Color = util.RGB(0xffffff)
					lbl.MaxLines = 1
					return lbl.Layout(gtx)
					// return Dims{Size: image.Point{X: gtx.Constraints.Max.X}}
					// return d{Size: image.Point{X: gtx.Constraints.Max.X}}
				}),
				layout.Rigid(func(gtx Gtx) Dims {
					btn := material.IconButton(theme, r.userListButton, mustIcon(icons.ActionViewList))
				layout.Rigid(func(gtx g) d {
					icon := util.MustIcon(icons.ActionViewList)
					btn := material.IconButton(r.theme, r.userListButton, icon)
					return btn.Layout(gtx)
				}),
				layout.Rigid(func(gtx Gtx) Dims {
					btn := material.IconButton(theme, r.settingsButton, mustIcon(icons.ActionSettings))
				layout.Rigid(func(gtx g) d {
					icon := util.MustIcon(icons.ActionSettings)
					btn := material.IconButton(r.theme, r.settingsButton, icon)
					return btn.Layout(gtx)
				}),
			)


@@ 187,13 198,17 @@ type roomHistory struct {
	user   string
	list   *layout.List
	events []matrix.Event
	logger *zap.Logger
	theme  *material.Theme
}

func newRoomHistory(user string) *roomHistory {
func newRoomHistory(user string, theme *material.Theme, logger *zap.Logger) *roomHistory {
	return &roomHistory{
		user:   user,
		events: make([]matrix.Event, 0),
		list:   &layout.List{Axis: layout.Vertical, ScrollToEnd: true},
		logger: logger,
		theme:  theme,
	}
}



@@ 206,23 221,24 @@ func (w *roomHistory) AddPastEvent(event matrix.Event) {
}

// Layout implements the layout.Widget interface
func (w *roomHistory) Layout(gtx Gtx) Dims {
func (w *roomHistory) Layout(gtx g) d {
	if w.list == nil {
		return Dims{}
		return d{}
	}
	return w.list.Layout(gtx, len(w.events), w.element)
}

func (w *roomHistory) element(gtx Gtx, index int) Dims {
func (w *roomHistory) element(gtx g, index int) d {
	msg := w.events[index]
	in := layout.Inset{Top: unit.Dp(16), Left: unit.Dp(16), Right: unit.Dp(40)}
	if ownEvent(w.user, msg) {
		in.Left, in.Right = in.Right, in.Left
	}
	return in.Layout(gtx, event{
		Event: msg,
		Own:   ownEvent(w.user, msg),
		log:   logrus.WithField("event", msg),
		Event:  msg,
		Own:    ownEvent(w.user, msg),
		logger: w.logger.Named("event"),
		Theme:  w.theme,
	}.Layout)
}



@@ 230,11 246,12 @@ func (w *roomHistory) element(gtx Gtx, index int) Dims {
type event struct {
	Event matrix.Event
	Own   bool
	Theme *material.Theme

	log *logrus.Entry
	logger *zap.Logger
}

func (e event) Layout(gtx Gtx) Dims {
func (e event) Layout(gtx g) d {
	switch e.Event.(type) {
	case matrix.StateEvent:
		return e.layoutStateEvent(gtx)


@@ 245,12 262,12 @@ func (e event) Layout(gtx Gtx) Dims {
	}
}

func (e event) layoutStateEvent(gtx Gtx) Dims {
func (e event) layoutStateEvent(gtx g) d {
	align := layout.Center
	bgcol := rgb(0xeeeeee)
	bgcol := util.RGB(0xeeeeee)

	return align.Layout(gtx, func(gtx Gtx) Dims {
		bg := Background{
	return align.Layout(gtx, func(gtx g) d {
		bg := fwidget.Background{
			Color: bgcol,
			Inset: layout.Inset{
				Top:    unit.Dp(8),


@@ 265,25 282,25 @@ func (e event) layoutStateEvent(gtx Gtx) Dims {
	})
}

func (e event) layoutStateEventContent(gtx Gtx) Dims {
func (e event) layoutStateEventContent(gtx g) d {
	ev := e.Event.(matrix.StateEvent)
	senderName := ev.Sender.Displayname()

	switch e.Event.Base().Type {
	case "m.room.member":
		lbl := material.Body1(theme, fmt.Sprintf("%s changed their profile", ev.StateKey))
		lbl := material.Body1(e.Theme, fmt.Sprintf("%s changed their profile", ev.StateKey))
		return lbl.Layout(gtx)
	case "m.room.create":
		lbl := material.Body1(theme, fmt.Sprintf("%s created the room", senderName))
		lbl := material.Body1(e.Theme, fmt.Sprintf("%s created the room", senderName))
		return lbl.Layout(gtx)
	case "m.room.power_levels":
		lbl := material.Body1(theme, fmt.Sprintf("Room power levels were changed by %s", senderName))
		lbl := material.Body1(e.Theme, fmt.Sprintf("Room power levels were changed by %s", senderName))
		return lbl.Layout(gtx)
	case "m.room.join_rules":
		lbl := material.Body1(theme, fmt.Sprintf("Join rules were changed by %s", senderName))
		lbl := material.Body1(e.Theme, fmt.Sprintf("Join rules were changed by %s", senderName))
		return lbl.Layout(gtx)
	case "m.room.name":
		lbl := material.Body1(theme, fmt.Sprintf("%s changed the room name to \"%s\"",
		lbl := material.Body1(e.Theme, fmt.Sprintf("%s changed the room name to \"%s\"",
			senderName, ev.Room.Displayname()))
		return lbl.Layout(gtx)
	case "m.room.topic":


@@ 292,9 309,9 @@ func (e event) layoutStateEventContent(gtx Gtx) Dims {
			str = fmt.Sprintf("%s changed the room topic to \"%s\"", senderName, topic)
		} else {
			str = fmt.Sprintf("%s removed the room topic", senderName)
			e.log.Debug("Invalid event")
			e.logger.Debug("Invalid event")
		}
		lbl := material.Body1(theme, str)
		lbl := material.Body1(e.Theme, str)
		return lbl.Layout(gtx)
	case "m.room.guest_access":
		var str string


@@ 305,20 322,20 @@ func (e event) layoutStateEventContent(gtx Gtx) Dims {
				str = fmt.Sprintf("%s disallowed guests to join", senderName)
			}
		} else {
			e.log.Debug("Invalid event")
			e.logger.Debug("Invalid event")
			str = "Invalid"
		}
		return material.Body1(theme, str).Layout(gtx)
		return material.Body1(e.Theme, str).Layout(gtx)

	case "m.room.history_visibility":
		var str string
		if access, ok := ev.Content["history_visibility"]; ok {
			str = fmt.Sprintf("%s set the history visibility to \"%s\"", senderName, access)
		} else {
			e.log.Debug("Invalid event")
			e.logger.Debug("Invalid event")
			str = "Invalid"
		}
		return material.Body1(theme, str).Layout(gtx)
		return material.Body1(e.Theme, str).Layout(gtx)
	case "m.room.canonical_alias":
		var str string
		if alias, ok := ev.Content["canonical_alias"]; ok {


@@ 326,24 343,24 @@ func (e event) layoutStateEventContent(gtx Gtx) Dims {
		} else {
			str = fmt.Sprintf("%s set no or removed the canonical alias", senderName)
		}
		return material.Body1(theme, str).Layout(gtx)
		return material.Body1(e.Theme, str).Layout(gtx)
	default:
		lbl := material.Body1(theme, "[Unformattable Event]")
		lbl.Color = rgb(0xff0000)
		lbl := material.Body1(e.Theme, "[Unformattable Event]")
		lbl.Color = util.RGB(0xff0000)
		return lbl.Layout(gtx)
	}
}

func (e event) layoutRoomEvent(gtx Gtx) Dims {
func (e event) layoutRoomEvent(gtx g) d {
	align := layout.W
	bgcol := theme.Color.Primary
	bgcol := e.Theme.Color.Primary
	if e.Own {
		align = layout.E
		bgcol = rgb(0xeeeeee)
		bgcol = util.RGB(0xeeeeee)
	}

	return align.Layout(gtx, func(gtx Gtx) Dims {
		bg := Background{
	return align.Layout(gtx, func(gtx g) d {
		bg := fwidget.Background{
			Color: bgcol,
			Inset: layout.Inset{
				Top:    unit.Dp(8),


@@ 355,22 372,23 @@ func (e event) layoutRoomEvent(gtx Gtx) Dims {
		}

		var msgWidth int
		timecol := argb(0xaaaaaaaa)
		timecol := util.ARGB(0xaaaaaaaa)

		return bg.Layout(gtx, func(gtx Gtx) Dims {
		return bg.Layout(gtx, func(gtx g) d {
			return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
				layout.Rigid(func(gtx Gtx) Dims {
				layout.Rigid(func(gtx g) d {
					senderName := e.Event.(matrix.RoomEvent).Sender.Displayname()
					lbl := material.Body2(theme, senderName)
					lbl.Color = initialColor(senderName[0])
					lbl := material.Body2(e.Theme, senderName)
					// TODO
					// lbl.Color = initialColor(senderName[0])
					return lbl.Layout(gtx)
				}),

				layout.Rigid(func(gtx Gtx) Dims {
				layout.Rigid(func(gtx g) d {
					return e.layoutRoomEventContent(gtx, &msgWidth)
				}),

				layout.Rigid(func(gtx Gtx) Dims {
				layout.Rigid(func(gtx g) d {
					gtx.Constraints.Min.X = msgWidth
					f := layout.Flex{
						Axis:      layout.Horizontal,


@@ 379,9 397,9 @@ func (e event) layoutRoomEvent(gtx Gtx) Dims {
					}

					var children []layout.FlexChild
					child := layout.Rigid(func(gtx Gtx) Dims {
						time := formatTime(timeEvent(e.Event))
						lbl := material.Caption(theme, time)
					child := layout.Rigid(func(gtx g) d {
						time := util.FormatTime(util.EventTime(e.Event))
						lbl := material.Caption(e.Theme, time)
						lbl.Color = timecol
						return lbl.Layout(gtx)
					})


@@ 394,32 412,31 @@ func (e event) layoutRoomEvent(gtx Gtx) Dims {
	})
}

func (e event) layoutRoomEventContent(gtx Gtx, msgWidth *int) Dims {
func (e event) layoutRoomEventContent(gtx g, msgWidth *int) d {
	var (
		err error
		err     error
		content layout.Widget
	)

	e.Event, err = e.Event.(matrix.RoomEvent).Decrypted()


	if err != nil {
		lbl := material.Body2(theme, err.Error())
		lbl.Color = rgb(0xff0000)
		lbl := material.Body2(e.Theme, err.Error())
		lbl.Color = util.RGB(0xff0000)
		content = lbl.Layout
	} else {
		switch e.Event.Base().Type {
		case "m.room.message":
			lbl := material.Body2(theme, e.Event.(matrix.RoomEvent).Content["body"].(string))
			lbl := material.Body2(e.Theme, e.Event.(matrix.RoomEvent).Content["body"].(string))
			if e.Own {
				lbl.Color = rgb(0x000000)
				lbl.Color = util.RGB(0x000000)
			} else {
				lbl.Color = rgb(0xffffff)
				lbl.Color = util.RGB(0xffffff)
			}
			content = lbl.Layout
		default:
			lbl := material.Body1(theme, "[Unformattable Event]")
			lbl.Color = rgb(0xff0000)
			lbl := material.Body1(e.Theme, "[Unformattable Event]")
			lbl.Color = util.RGB(0xff0000)
			content = lbl.Layout
		}
	}


@@ 433,20 450,22 @@ func (e event) layoutRoomEventContent(gtx Gtx, msgWidth *int) Dims {
type messageComposer struct {
	editor *widget.Editor
	submit *widget.Clickable
	theme  *material.Theme

	disabled  bool
	sendError error
}

func newMessageComposer() *messageComposer {
func newMessageComposer(theme *material.Theme) *messageComposer {
	return &messageComposer{
		editor:   &widget.Editor{Submit: true, SingleLine: false},
		submit:   new(widget.Clickable),
		disabled: false,
		theme:    theme,
	}
}

func (m *messageComposer) Layout(gtx Gtx) Dims {
func (m *messageComposer) Layout(gtx g) d {
	in := layout.Inset{
		Top:    unit.Dp(8),
		Left:   unit.Dp(8),


@@ 463,14 482,15 @@ func (m *messageComposer) Layout(gtx Gtx) Dims {
	}

	var minHeight int
	return in.Layout(gtx, func(gtx Gtx) Dims {
	return in.Layout(gtx, func(gtx g) d {
		return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
			layout.Flexed(1, func(gtx Gtx) Dims {
			layout.Flexed(1, func(gtx g) d {
				return m.layoutEditor(gtx, &minHeight)
			}),
			layout.Rigid(func(gtx Gtx) Dims {
				dims := layout.Inset{Left: unit.Dp(8)}.Layout(gtx, func(gtx Gtx) Dims {
					btn := material.IconButton(theme, m.submit, mustIcon(icons.ContentSend))
			layout.Rigid(func(gtx g) d {
				dims := layout.Inset{Left: unit.Dp(8)}.Layout(gtx, func(gtx g) d {
					icon := util.MustIcon(icons.ContentSend)
					btn := material.IconButton(m.theme, m.submit, icon)
					btn.Size = unit.Dp(24)
					btn.Inset = layout.UniformInset(unit.Dp(6))
					return btn.Layout(gtx)


@@ 482,30 502,30 @@ func (m *messageComposer) Layout(gtx Gtx) Dims {
	})
}

func (m *messageComposer) layoutEditor(gtx Gtx, minHeight *int) Dims {
func (m *messageComposer) layoutEditor(gtx g, minHeight *int) d {
	gtx.Constraints.Min.X = gtx.Constraints.Max.X

	bg := Background{
		Color:  rgb(0xeeeeee),
	bg := fwidget.Background{
		Color:  util.RGB(0xeeeeee),
		Inset:  layout.Inset{Left: unit.Dp(8), Right: unit.Dp(8)},
		Radius: unit.Dp(10),
	}

	return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
		layout.Rigid(func(gtx Gtx) Dims {
		layout.Rigid(func(gtx g) d {
			if m.sendError != nil {
				lbl := material.Body2(theme, m.sendError.Error())
				lbl.Color = rgb(0xff0000)
				lbl := material.Body2(m.theme, m.sendError.Error())
				lbl.Color = util.RGB(0xff0000)
				return lbl.Layout(gtx)
			}
			return Dims{}
			return d{}
		}),
		layout.Rigid(func(gtx Gtx) Dims {
			return bg.Layout(gtx, func(gtx Gtx) Dims {
				return layout.W.Layout(gtx, func(gtx Gtx) Dims {
		layout.Rigid(func(gtx g) d {
			return bg.Layout(gtx, func(gtx g) d {
				return layout.W.Layout(gtx, func(gtx g) d {
					gtx.Constraints.Min.X = gtx.Constraints.Max.X
					gtx.Constraints.Min.Y = *minHeight
					ed := material.Editor(theme, m.editor, "Send a message")
					ed := material.Editor(m.theme, m.editor, "Send a message")
					ed.TextSize = unit.Sp(12)
					return ed.Layout(gtx)
				})

R cmd/frost/list_view.go => view/roomlist/roomlist.go +72 -54
@@ 1,11 1,11 @@
package main
package roomlist

import (
	"fmt"
	"image"
	"unicode"

	"github.com/sirupsen/logrus"
	"go.uber.org/zap"
	"golang.org/x/exp/shiny/materialdesign/icons"

	"gioui.org/gesture"


@@ 14,11 14,19 @@ import (
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/util"
	fwidget "git.sr.ht/~f4814n/frost/widget"
	"git.sr.ht/~f4814n/matrix"
)

type ListView struct {
	rx, tx chan Event
type (
	g = layout.Context
	d = layout.Dimensions
)

type view struct {
	rx, tx chan frost.Event

	matrixEvents chan matrix.Event



@@ 27,28 35,30 @@ type ListView struct {

	menuButton *widget.Clickable

	log *logrus.Entry
	theme *material.Theme

	logger *zap.Logger
}

func NewListView(cli *matrix.Client) *ListView {
	log := logrus.WithField("component", "list_view")
	return &ListView{
func New(cli *matrix.Client, theme *material.Theme, logger *zap.Logger) frost.View {
	return &view{
		cli:          cli,
		matrixEvents: make(chan matrix.Event, 100),
		roomList:     NewRoomList(log),
		roomList:     NewRoomList(logger, theme),
		menuButton:   new(widget.Clickable),
		log:          log,
		theme:        theme,
		logger:       logger,
	}
}

func (l *ListView) Start(rx, tx chan Event) {
	logStart(l.log)
func (l *view) Run(rx, tx chan frost.Event) {
	l.logger.Info("Starting")

	l.rx, l.tx = rx, tx
	l.cli.Notify(l.matrixEvents)
}

func (l *ListView) Layout(gtx Gtx) Dims {
func (l *view) Layout(gtx layout.Context) layout.Dimensions {
	l.update(gtx)

	return layout.Flex{Axis: layout.Vertical}.Layout(gtx,


@@ 57,39 67,41 @@ func (l *ListView) Layout(gtx Gtx) Dims {
	)
}

func (l *ListView) topbar(gtx Gtx) Dims {
func (l *view) topbar(gtx g) d {
	return layout.Stack{Alignment: layout.SW}.Layout(gtx,
		layout.Expanded(fill{theme.Color.Primary}.Layout),
		layout.Stacked(func(gtx Gtx) Dims {
		layout.Expanded(fwidget.Fill{Color: l.theme.Color.Primary}.Layout),
		layout.Stacked(func(gtx g) d {
			return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
				layout.Rigid(func(gtx Gtx) Dims {
					btn := material.IconButton(theme, l.menuButton, mustIcon(icons.NavigationMenu))
				layout.Rigid(func(gtx g) d {
					btn := material.IconButton(l.theme, l.menuButton, util.MustIcon(icons.NavigationMenu))
					return btn.Layout(gtx)
				}),
				layout.Rigid(func(gtx Gtx) Dims {
					return Dims{Size: image.Point{X: gtx.Constraints.Max.X}}
				layout.Rigid(func(gtx g) d {
					return d{Size: image.Point{X: gtx.Constraints.Max.X}}
				}),
			)
		}),
	)
}

func (l *ListView) update(gtx Gtx) {
func (l *view) update(gtx g) {
	for _, room := range l.roomList.rooms {
		for _, e := range room.click.Events(gtx) {
			if e.Type == gesture.TypeClick {
				l.tx <- LoadViewEvent{
					Position: Middle,
					View:     NewRoomView(l.cli, room.room),
				l.tx <- frost.ShowRoomEvent{
					Room: room.room,
				}
				// l.tx <- frost.LoadViewEvent{
				// 	Position: frost.Middle,
				// 	View:     NewRoomView(l.cli, room.room),
				// }
			}
		}
	}

	for {
		select {
		case event := <-l.rx:
			logEvent(l.log, event)
		case <-l.rx:
		case event := <-l.matrixEvents:
			l.roomList.NewEvent(event)
		default:


@@ 98,19 110,21 @@ func (l *ListView) update(gtx Gtx) {
	}
}

func (l *ListView) Stop() {
func (l *view) Stop() {
}

// RoomList is a widget that displays a list of rooms
type RoomList struct {
	list  *layout.List
	rooms []roomListElement
	log   *logrus.Entry
	list   *layout.List
	logger *zap.Logger
	theme  *material.Theme
	rooms  []roomListElement
}

func NewRoomList(log *logrus.Entry) *RoomList {
func NewRoomList(logger *zap.Logger, theme *material.Theme) *RoomList {
	return &RoomList{
		log: log,
		logger: logger,
		theme:  theme,
		list: &layout.List{
			Axis: layout.Vertical,
		},


@@ 132,68 146,72 @@ func (w *RoomList) NewEvent(event matrix.Event) {

	for i, elem := range w.rooms {
		if elem.room == room {
			w.log.WithField("room", room.Displayname()).Debug("Adding room")
			w.logger.Debug("Adding room",
				zap.String("room", room.Displayname()),
			)
			w.rooms = append(w.rooms[:i], w.rooms[i+1:]...)
			break
		}
	}
	w.rooms = append([]roomListElement{NewRoomListElement(room, event)}, w.rooms...)
	w.rooms = append([]roomListElement{NewRoomListElement(room, event, w.theme)}, w.rooms...)
}

// Layout implements the layout.Widget interface
func (w *RoomList) Layout(gtx Gtx) Dims {
	return w.list.Layout(gtx, len(w.rooms), func(gtx Gtx, index int) Dims {
func (w *RoomList) Layout(gtx g) d {
	return w.list.Layout(gtx, len(w.rooms), func(gtx g, index int) d {
		return w.rooms[index].Layout(gtx)
	})
}

// roomListElement is a element in the room list
type roomListElement struct {
	theme     *material.Theme
	room      matrix.Room
	lastEvent matrix.Event
	click     *gesture.Click
}

func NewRoomListElement(room matrix.Room, event matrix.Event) roomListElement {
func NewRoomListElement(room matrix.Room, event matrix.Event, theme *material.Theme) roomListElement {
	return roomListElement{
		room:      room,
		lastEvent: event,
		theme:     theme,
		click:     new(gesture.Click),
	}
}

func (w *roomListElement) Layout(gtx Gtx) Dims {
	return IconListElement{Click: w.click, Icon: w.layoutIcon, Content: func(gtx Gtx) Dims {
		return column().Layout(gtx,
func (w *roomListElement) Layout(gtx g) d {
	return fwidget.IconListElement{Click: w.click, Icon: w.layoutIcon, Content: func(gtx g) d {
		return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
			layout.Rigid(w.layoutUpper),
			layout.Rigid(w.layoutLastEvent),
		)
	}}.Layout(gtx)
}

func (w *roomListElement) layoutIcon(gtx Gtx) Dims {
func (w *roomListElement) layoutIcon(gtx g) d {
	var initial string
	for _, c := range w.room.Displayname() {
		initial = string(unicode.ToUpper(c))
		break
	}

	return InitialSign{Initial: initial}.Layout(gtx)
	return fwidget.InitialSign{Initial: initial, Theme: w.theme}.Layout(gtx)
}

func (w *roomListElement) layoutUpper(gtx Gtx) Dims {
	bgtextcol := rgb(0xbbbbbb)
	return baseline().Layout(gtx,
		layout.Rigid(func(gtx Gtx) Dims {
			lbl := material.H6(theme, w.room.Displayname())
func (w *roomListElement) layoutUpper(gtx g) d {
	bgtextcol := util.RGB(0xbbbbbb)
	return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Baseline}.Layout(gtx,
		layout.Rigid(func(gtx g) d {
			lbl := material.H6(w.theme, w.room.Displayname())
			lbl.MaxLines = 1
			return lbl.Layout(gtx)
		}),
		layout.Flexed(1, func(gtx Gtx) Dims {
		layout.Flexed(1, func(gtx g) d {
			gtx.Constraints.Min.X = gtx.Constraints.Max.X
			in := layout.Inset{Left: unit.Dp(2)}
			return in.Layout(gtx, func(gtx Gtx) Dims {
				lbl := material.Caption(theme, formatTime(eventTime(w.lastEvent)))
			return in.Layout(gtx, func(gtx g) d {
				lbl := material.Caption(w.theme, util.FormatTime(util.EventTime(w.lastEvent)))
				lbl.MaxLines = 1
				lbl.Color = bgtextcol
				lbl.Alignment = text.End


@@ 203,11 221,11 @@ func (w *roomListElement) layoutUpper(gtx Gtx) Dims {
	)
}

func (w *roomListElement) layoutLastEvent(gtx Gtx) Dims {
	bgtextcol := rgb(0xbbbbbb)
func (w *roomListElement) layoutLastEvent(gtx g) d {
	bgtextcol := util.RGB(0xbbbbbb)
	in := layout.Inset{Top: unit.Dp(6)}
	return in.Layout(gtx, func(gtx Gtx) Dims {
		lbl := material.Body2(theme, formatEvent(w.lastEvent))
	return in.Layout(gtx, func(gtx g) d {
		lbl := material.Body2(w.theme, formatEvent(w.lastEvent))
		lbl.Color = bgtextcol
		lbl.MaxLines = 1
		return lbl.Layout(gtx)

R cmd/frost/util.go => widget/background.go +4 -94
@@ 1,90 1,15 @@
package main
package widget

import (
	"fmt"
	"image/color"
	"time"

	"gioui.org/f32"
	"gioui.org/unit"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/unit"
	"gioui.org/widget"
	"git.sr.ht/~f4814n/matrix"
	"github.com/sirupsen/logrus"
	"gioui.org/op/clip"
	"gioui.org/f32"
)

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 centerRowOpts() layout.Flex {
	return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}
}

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 column() layout.Flex {
	return layout.Flex{Axis: layout.Vertical}
}

func baseline() layout.Flex {
	return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Baseline}
}

// 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)
}

type fill struct {
	color color.RGBA
}

func (f fill) Layout(gtx layout.Context) layout.Dimensions {
	cs := gtx.Constraints
	d := cs.Min
	dr := f32.Rectangle{
		Max: f32.Point{X: float32(d.X), Y: float32(d.Y)},
	}
	paint.ColorOp{Color: f.color}.Add(gtx.Ops)
	paint.PaintOp{Rect: dr}.Add(gtx.Ops)
	return layout.Dimensions{Size: d, Baseline: d.Y}
}

func eventTime(event matrix.Event) time.Time {
	switch event := event.(type) {
	case matrix.RoomEvent:
		return event.OriginServerTS
	case matrix.StateEvent:
		return event.OriginServerTS
	}
	return time.Time{}
}

type Background struct {
	Color  color.RGBA
	Radius unit.Value


@@ 118,18 43,3 @@ func (b *Background) Layout(gtx layout.Context, w layout.Widget) layout.Dimensio
	return dims
}

func mustIcon(data []byte) *widget.Icon {
	ico, err := widget.NewIcon(data)
	if err != nil {
		panic(err)
	}
	return ico
}

func logEvent(log *logrus.Entry, event interface{}) {
	log.WithField("event", fmt.Sprintf("%#v", event)).Trace("Received event")
}

func logStart(log *logrus.Entry) {
	log.Debug("Starting view")
}

A widget/clipcircle.go => widget/clipcircle.go +31 -0
@@ 0,0 1,31 @@
package widget

import (
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/f32"
)

type ClipCircle struct {
}

func (cc ClipCircle) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
	macro := op.Record(gtx.Ops)
	dims := w(gtx)
	macroCall := macro.Stop()
	max := dims.Size.X
	if dy := dims.Size.Y; dy > max {
		max = dy
	}
	szf := float32(max)
	rr := szf * .5
	stack := op.Push(gtx.Ops)
	clip.RRect{
		Rect: f32.Rectangle{Max: f32.Point{X: szf, Y: szf}},
		NE:   rr, NW: rr, SE: rr, SW: rr,
	}.Add(gtx.Ops)
	macroCall.Add(gtx.Ops)
	stack.Pop()
	return dims
}

A widget/fill.go => widget/fill.go +23 -0
@@ 0,0 1,23 @@
package widget

import (
	"image/color"
	"gioui.org/layout"
	"gioui.org/f32"
	"gioui.org/op/paint"
)

type Fill struct {
	Color color.RGBA
}

func (f Fill) Layout(gtx layout.Context) layout.Dimensions {
	cs := gtx.Constraints
	d := cs.Min
	dr := f32.Rectangle{
		Max: f32.Point{X: float32(d.X), Y: float32(d.Y)},
	}
	paint.ColorOp{Color: f.Color}.Add(gtx.Ops)
	paint.PaintOp{Rect: dr}.Add(gtx.Ops)
	return layout.Dimensions{Size: d, Baseline: d.Y}
}

R cmd/frost/widgets.go => widget/list.go +49 -74
@@ 1,91 1,33 @@
package main
package widget

import (
	"image"
	"image/color"

	"gioui.org/f32"
	"gioui.org/gesture"
	"git.sr.ht/~f4814n/frost/util"
	"gioui.org/io/pointer"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"gioui.org/unit"
	"gioui.org/gesture"
)

// InitialSign is a widget which shows a Initial on a colored background
type InitialSign struct {
	Initial string
}

func (w InitialSign) Layout(gtx Gtx) Dims {
	cc := clipCircle{}
	return cc.Layout(gtx, func(gtx Gtx) Dims {
		return layout.Stack{Alignment: layout.Center}.Layout(gtx,
			// Background Color
			layout.Stacked(func(gtx Gtx) Dims {
				sz := image.Point{X: gtx.Px(unit.Dp(48)), Y: gtx.Px(unit.Dp(48))}
				gtx.Constraints = layout.Exact(gtx.Constraints.Constrain(sz))
				color := initialColor(w.Initial[0])
				return fill{color}.Layout(gtx)
			}),
			// Initial
			layout.Stacked(func(gtx Gtx) Dims {
				lbl := material.H5(theme, w.Initial)
				lbl.Color = rgb(0xffffff)
				return lbl.Layout(gtx)
			}),
		)
	})
}

func initialColor(initial byte) color.RGBA {
	return contactColors[initial%6]
}

type clipCircle struct {
}

func (cc clipCircle) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
	macro := op.Record(gtx.Ops)
	dims := w(gtx)
	macroCall := macro.Stop()
	max := dims.Size.X
	if dy := dims.Size.Y; dy > max {
		max = dy
	}
	szf := float32(max)
	rr := szf * .5
	stack := op.Push(gtx.Ops)
	clip.RRect{
		Rect: f32.Rectangle{Max: f32.Point{X: szf, Y: szf}},
		NE:   rr, NW: rr, SE: rr, SW: rr,
	}.Add(gtx.Ops)
	macroCall.Add(gtx.Ops)
	stack.Pop()
	return dims
}

type IconListElement struct {
	Icon    layout.Widget
	Content layout.Widget
	Click   *gesture.Click
}

func (w IconListElement) Layout(gtx Gtx) Dims {
func (w IconListElement) Layout(gtx g) d {
	in := layout.Inset{
		Left:  unit.Dp(16),
		Right: unit.Dp(16),
	}

	return in.Layout(gtx, func(gtx Gtx) Dims {
	return in.Layout(gtx, func(gtx g) d {
		in := layout.Inset{Top: unit.Dp(16), Bottom: unit.Dp(16)}
		dims := in.Layout(gtx, func(gtx Gtx) Dims {
			return centerRowOpts().Layout(gtx,
				layout.Rigid(func(gtx Gtx) Dims {
		dims := in.Layout(gtx, func(gtx g) d {
			return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
				layout.Rigid(func(gtx g) d {
					in := layout.Inset{Right: unit.Dp(12)}
					return in.Layout(gtx, w.Icon)
				}),


@@ 98,14 40,47 @@ func (w IconListElement) Layout(gtx Gtx) Dims {
	})
}

type RoundImage struct {
	Image image.Image
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},
}


// InitialSign is a widget which shows a Initial on a colored background
type InitialSign struct {
	Theme *material.Theme
	Initial string
}

func (w RoundImage) Layout(gtx Gtx) Dims {
	cc := clipCircle{}
	return cc.Layout(gtx, func(gtx Gtx) Dims {
		op := paint.NewImageOp(w.Image)
		return widget.Image{Src: op, Scale: 1}.Layout(gtx)
func (w InitialSign) Layout(gtx layout.Context) layout.Dimensions {
	return ClipCircle{}.Layout(gtx, func(gtx g) d {
		return layout.Stack{Alignment: layout.Center}.Layout(gtx,
			// Background Color
			layout.Stacked(func(gtx g) d {
				sz := image.Point{X: gtx.Px(unit.Dp(48)), Y: gtx.Px(unit.Dp(48))}
				gtx.Constraints = layout.Exact(gtx.Constraints.Constrain(sz))
				color := initialColor(w.Initial[0])
				return Fill{Color: color}.Layout(gtx)
			}),
			// Initial
			layout.Stacked(func(gtx g) d {
				lbl := material.H5(w.Theme, w.Initial)
				lbl.Color = util.RGB(0xffffff)
				return lbl.Layout(gtx)
			}),
		)
	})
}

func (w InitialSign) color() color.RGBA {
	return contactColors[w.Initial[0]%6]
}

func initialColor(initial byte) color.RGBA {
	return contactColors[initial%6]
}


A widget/roundimage.go => widget/roundimage.go +20 -0
@@ 0,0 1,20 @@
package widget

import (
	"image"
	"gioui.org/op/paint"
	"gioui.org/widget"
	"gioui.org/layout"
)

type RoundImage struct {
	Image image.Image
}

func (w RoundImage) Layout(gtx layout.Context) layout.Dimensions {
	cc := ClipCircle{}
	return cc.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
		op := paint.NewImageOp(w.Image)
		return widget.Image{Src: op, Scale: 1}.Layout(gtx)
	})
}

A widget/util.go => widget/util.go +8 -0
@@ 0,0 1,8 @@
package widget

import (
	"gioui.org/layout"
)

type d = layout.Dimensions
type g = layout.Context