~f4814n/frost

7a90fe0c17241e5e9fbefba61371fcab6a2811f6 — Fabian Geiselhart 3 months ago 03d6b5a
Implement theming
M cmd/frost/app.go => cmd/frost/app.go +85 -25
@@ 6,15 6,12 @@ import (

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

	"gioui.org/font/gofont"

	"gioui.org/app"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"gioui.org/x/component"
	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/component/cache"


@@ 74,7 71,7 @@ type App struct {

	modalLayer *component.ModalLayer
	drawer     *component.ModalNavDrawer
	theme      *material.Theme
	theme      fwidget.Theme
}

type registeredView struct {


@@ 104,7 101,7 @@ func newApp() *App {
		platform:       platform,
		logger:         logger.Named("frost"),
		modalLayer:     component.NewModal(),
		theme:          material.NewTheme(gofont.Collection()),
		theme:          fwidget.NewTheme(),
		userListButton: new(widget.Clickable),
	}



@@ 142,14 139,27 @@ func (a *App) run() error {
		go a.sync.Run(rx, tx)

		id, rx, tx := a.mux.Register()
		a.singleView = false
		a.views[0] = registeredView{
			id:   id,
			View: roomlist.New(a.cli, a.modalLayer, a.theme, a.logger.Named("roomlist")),
			id: id,
			View: roomlist.New(
				a.cli,
				a.modalLayer,
				a.theme,
				a.logger.Named("roomlist"),
			),
		}
		go a.views[0].Run(rx, tx)
	} else {
		a.singleView = true
		id, rx, tx := a.mux.Register()
		a.views[0] = registeredView{id: id, View: login.New(a.theme, a.logger.Named("login"))}
		a.views[0] = registeredView{
			id: id,
			View: login.New(
				a.theme.WithContrast(fwidget.PrimaryDefault),
				a.logger.Named("login"),
			),
		}
		go a.views[0].Run(rx, tx)
	}



@@ 175,7 185,7 @@ func (a *App) handleEvents() error {
				Right:  e.Insets.Right,
			}.Layout(gtx, func(gtx g) d {
				a.Layout(gtx)
				return a.modalLayer.Layout(gtx, a.theme)
				return a.modalLayer.Layout(gtx, a.theme.WithContrast(fwidget.PrimaryDefault))
			})
			e.Frame(gtx.Ops)



@@ 197,12 207,44 @@ func (a *App) handleEvents() error {
			a.bar.Title = e.Room.Displayname()

			view := roomhistory.New(
				self, e.Room, a.theme, a.logger.Named("roomhistory"),
				self,
				e.Room,
				a.theme,
				a.logger.Named("roomhistory"),
			)
			a.stopView(2)
			a.startView(1, view)
		case frost.InvalidationEvent:
			a.window.Invalidate()
		case login.StartEvent:
			event := e
			err := a.cli.Login(event.Username, event.Password)
			if err != nil {
				a.logger.Debug("Failed to log in", zap.Error(err))
				a.tx <- login.ErrorEvent{Err: err}
				return nil
			}
			a.logger.Debug("Successfully authenticated")

			a.config.Session = &platform.SessionConfig{
				MxID:     a.cli.MxID(),
				DeviceID: a.cli.DeviceID(),
			}
			err = a.platform.FlushConfig(a.config)
			if err != nil {
				a.logger.Debug("Failed to flush config", zap.Error(err))
			}

			a.singleView = false
			a.startView(0, roomlist.New(
				a.cli,
				a.modalLayer,
				a.theme,
				a.logger.Named("roomlist"),
			))

			_, rx, tx := a.mux.Register()
			go a.sync.Run(rx, tx)
		}
	}



@@ 258,7 300,11 @@ func (a *App) update(gtx g) {

	if a.userListButton.Clicked() {
		if a.views[2].View == nil {
			view := memberlist.New(a.currentRoom, a.theme, a.logger.Named("memberlist"))
			view := memberlist.New(
				a.currentRoom,
				a.theme.WithContrast(fwidget.PrimaryDefault),
				a.logger.Named("memberlist"),
			)
			a.startView(2, view)
		} else {
			a.stopView(2)


@@ 288,7 334,7 @@ func (a *App) updateBar(f format) {
	if a.views[1].View != nil && a.views[2].View == nil {
		a.bar.SetActions([]component.AppBarAction{
			component.SimpleIconAction(
				a.theme,
				a.theme.WithContrast(fwidget.PrimaryDefault),
				a.userListButton,
				util.MustIcon(icons.ActionViewList),
				component.OverflowAction{


@@ 320,19 366,33 @@ func (a *App) updateBar(f format) {
}

func (a *App) Layout(gtx layout.Context) layout.Dimensions {
	return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
		layout.Rigid(fwidget.LayoutTheme(a.bar.Layout, a.theme)),
		layout.Flexed(1, func(gtx g) d {
			switch {
			case a.format == small:
				return a.layoutSmall(gtx)
			case a.format == medium:
				return a.layoutMedium(gtx)
			case a.format == large:
				return a.layoutLarge(gtx)
			default:
				panic("invalid format")
			}
	return layout.Stack{}.Layout(gtx,
		layout.Expanded(fwidget.Fill{Color: a.theme.Colors[fwidget.Default].Bg}.Layout),
		layout.Stacked(func(gtx g) d {
			gtx.Constraints.Min = gtx.Constraints.Max

			return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
				layout.Rigid(
					fwidget.LayoutTheme(
						a.bar.Layout,
						a.theme.WithContrast(fwidget.PrimaryDefault),
					),
				),
				layout.Flexed(1, func(gtx g) d {
					switch {
					case a.singleView:
						return a.focused().Layout(gtx)
					case a.format == small:
						return a.layoutSmall(gtx)
					case a.format == medium:
						return a.layoutMedium(gtx)
					case a.format == large:
						return a.layoutLarge(gtx)
					default:
						panic("invalid format")
					}
				}),
			)
		}),
	)
}

M go.sum => go.sum +1 -0
@@ 3,6 3,7 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7
gioui.org v0.0.0-20200726090130-3b95e2918359/go.mod h1:jiUwifN9cRl/zmco43aAqh0aV+s9GbhG13KcD+gEpkU=
gioui.org v0.0.0-20210116085804-99bfa6a33cdf h1:BPm0FHRKpTbpoe4DY2vKIVzWWni+IHvmltECoYRUYWk=
gioui.org v0.0.0-20210116085804-99bfa6a33cdf/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw=
gioui.org v0.0.0-20210118095710-eea1dbc17620 h1:Xjq1nITuYwA7HbvfDT3Wi/J42kQ61aIhdRgx3xSh/io=
gioui.org/cmd v0.0.0-20201020094634-d5bdf0756a5a h1:BPgJqeQSxuX6CzFQ2EGDj4Kr2ZzwiqwdLyAYLXgxR+k=
gioui.org/cmd v0.0.0-20201020094634-d5bdf0756a5a/go.mod h1:dlmJnCEkOpRaChYxRmJZ5S4jk6y7DCfWnec39xGbUYk=
gioui.org/x v0.0.0-20210116203642-b812805638ca h1:nkUukjo7RjLu9L3HYHE/r/mtCi1svCi6b+c30jLq9BQ=

M view/login/view.go => view/login/view.go +1 -1
@@ 3,9 3,9 @@ package login
import (
	"image"

	"gioui.org/x/component"
	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/frost/util"
	"gioui.org/x/component"

	"go.uber.org/zap"


M view/roomhistory/event.go => view/roomhistory/event.go +0 -221
@@ 1,16 1,6 @@
package roomhistory

import (
	"fmt"
	"time"

	fwidget "git.sr.ht/~f4814n/frost/widget"

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

	"gioui.org/layout"
	"gioui.org/unit"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/matrix"
)



@@ 19,214 9,3 @@ type Show struct {
}

func (s Show) FrostEvent() {}

type statusEvent struct {
	Text    string
	Invalid bool
}

func newStatusEvent(ev matrix.StateEvent) statusEvent {
	senderName := ev.Sender.Displayname()

	var (
		text    string
		invalid bool
	)

	switch ev.Type {
	case "m.room.member":
		text = fmt.Sprintf("%s changed their profile", ev.StateKey)
	case "m.room.create":
		text = fmt.Sprintf("%s created the room", senderName)
	case "m.room.power_levels":
		text = fmt.Sprintf("Room power levels were changed by %s", senderName)
	case "m.room.join_rules":
		text = fmt.Sprintf("Join rules were changed by %s", senderName)
	case "m.room.name":
		text = fmt.Sprintf("%s changed the room name to \"%s\"", senderName, ev.Room.Displayname())
	case "m.room.topic":
		var str string
		if topic, ok := ev.Content["topic"]; ok {
			str = fmt.Sprintf("%s changed the room topic to \"%s\"", senderName, topic)
		} else {
			str = fmt.Sprintf("%s removed the room topic", senderName)
		}
		text = str
	case "m.room.guest_access":
		var str string
		if access, ok := ev.Content["guest_access"]; ok && access == "can_join" || access == "forbidden" {
			if access == "can_join" {
				str = fmt.Sprintf("%s allowed guest to join", senderName)
			} else {
				str = fmt.Sprintf("%s disallowed guests to join", senderName)
			}
		} else {
			str = "[Unformattable Event]"
			invalid = true
		}
		text = str
	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 {
			str = "[Unformattable Event]"
			invalid = true
		}
		text = str
	case "m.room.canonical_alias":
		var str string
		if alias, ok := ev.Content["canonical_alias"]; ok {
			str = fmt.Sprintf("%s changed the canonical alias to \"%s\"", senderName, alias)
		} else {
			str = fmt.Sprintf("%s set no or removed the canonical alias", senderName)
		}
		text = str
	default:
		invalid = true
		text = "[Unformattable Event]"
	}

	return statusEvent{
		Text:    text,
		Invalid: invalid,
	}
}

func (e statusEvent) Layout(gtx g, theme *material.Theme) d {
	align := layout.Center
	bgcol := util.NRGB(0xeeeeee)

	return align.Layout(gtx, func(gtx g) d {
		bg := fwidget.Background{
			Color: bgcol,
			Inset: layout.Inset{
				Top:    unit.Dp(8),
				Bottom: unit.Dp(8),
				Left:   unit.Dp(12),
				Right:  unit.Dp(12),
			},
			Radius: unit.Dp(10),
		}

		return bg.Layout(gtx, func(gtx g) d {
			lbl := material.Body1(theme, e.Text)
			if e.Invalid {
				lbl.Color = util.NRGB(0xff0000)
			} else {
				lbl.Color = theme.Palette.ContrastBg
			}
			return lbl.Layout(gtx)
		})
	})
}

type messageEvent struct {
	Time    time.Time
	Text    string
	Sender  matrix.Member
	Own     bool
	Invalid bool
}

func newMessageEvent(own bool, matrixEvent matrix.RoomEvent) messageEvent {
	event := messageEvent{
		Time:   matrixEvent.OriginServerTS,
		Sender: matrixEvent.Sender,
		Own:    own,
	}

	if matrixEvent.Type == "m.room.encrypted" {
		var err error
		matrixEvent, err = matrixEvent.Decrypt()
		if err != nil {
			event.Text = err.Error()
			event.Invalid = true
		}
	}

	if body, ok := matrixEvent.Content["body"]; matrixEvent.Type == "m.room.message" && ok {
		event.Text = body.(string)
	}

	return event
}

func (e messageEvent) Layout(gtx g, theme *material.Theme) d {
	align := layout.W
	bgcol := theme.Palette.ContrastBg
	if e.Own {
		align = layout.E
		bgcol = util.NRGB(0xeeeeee)
	}

	return align.Layout(gtx, func(gtx g) d {
		bg := fwidget.Background{
			Color: bgcol,
			Inset: layout.Inset{
				Top:    unit.Dp(8),
				Bottom: unit.Dp(8),
				Left:   unit.Dp(12),
				Right:  unit.Dp(12),
			},
			Radius: unit.Dp(10),
		}

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

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

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

				layout.Rigid(func(gtx g) d {
					gtx.Constraints.Min.X = msgWidth
					f := layout.Flex{
						Axis:      layout.Horizontal,
						Spacing:   layout.SpaceBetween,
						Alignment: layout.Middle,
					}

					var children []layout.FlexChild
					child := layout.Rigid(func(gtx g) d {
						time := util.FormatTime(e.Time)
						lbl := material.Caption(theme, time)
						lbl.Color = timecol
						return lbl.Layout(gtx)
					})
					children = append(children, child)

					return f.Layout(gtx, children...)
				}),
			)
		})
	})
}

func (e messageEvent) layoutRoomEventContent(gtx g, theme *material.Theme, msgWidth *int) d {
	lbl := material.Body2(theme, e.Text)
	if e.Own {
		lbl.Color = util.NRGB(0x000000)
	} else {
		lbl.Color = util.NRGB(0xffffff)
	}

	if e.Invalid {
		lbl.Color = util.NRGB(0xff0000)
	}

	dims := lbl.Layout(gtx)
	dims.Size.Y += gtx.Px(unit.Dp(4))
	*msgWidth = dims.Size.X
	return dims
}

M view/roomhistory/roomhistory.go => view/roomhistory/roomhistory.go +211 -110
@@ 2,13 2,13 @@ package roomhistory

import (
	"errors"
	"time"
	"fmt"
	"sync"

	fwidget "git.sr.ht/~f4814n/frost/widget"

	"gioui.org/layout"
	"gioui.org/unit"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/frost"
	"git.sr.ht/~f4814n/matrix"
	"go.uber.org/zap"


@@ 25,35 25,47 @@ type sentRoomEvent struct {

func (e sentRoomEvent) FrostEvent() {}

const (
	tagViewRoomMembers int = iota
	tagRoomSettings
)

type view struct {
	sync.Mutex

	rx, tx chan frost.Event

	theme *material.Theme
	theme fwidget.Theme

	matrixEvents chan matrix.Event
	pastEvents   chan matrix.Event

	cli         *matrix.Client
	room        matrix.Room
	history     matrix.History
	roomHistory *roomHistory
	self    matrix.User
	room    matrix.Room
	history matrix.History

	list   *layout.List
	events []interface{} // TODO Concrete type. Is either message or statusMessage

	messageComposer *messageComposer

	logger *zap.Logger
}

func New(self matrix.User, room matrix.Room, theme *material.Theme, logger *zap.Logger) frost.View {
type message struct {
	fwidget.Message
	event   matrix.RoomEvent
	invalid bool
}

type statusMessage struct {
	fwidget.StatusMessage
	event   matrix.StateEvent
	invalid bool
}

func New(self matrix.User, room matrix.Room, theme fwidget.Theme, logger *zap.Logger) frost.View {
	view := &view{
		matrixEvents:    make(chan matrix.Event, 100),
		pastEvents:      make(chan matrix.Event, 100),
		room:            room,
		roomHistory:     newRoomHistory(self.ID, theme, logger),
		self:            self,
		list:            &layout.List{Axis: layout.Vertical, ScrollToEnd: true},
		history:         room.History(),
		logger:          logger,
		theme:           theme,


@@ 71,23 83,98 @@ func (r *view) Run(rx, tx chan frost.Event) {
	r.room.Notify(r.matrixEvents)

	// TODO Only load history which is actually needed
	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
	go func() {
		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
			}
			continue
		}
	}()

		for _, e := range r.history.Events {
			r.pastEvents <- e
	for {
		select {
		case event := <-r.rx:
			switch event := event.(type) {
			case sentRoomEvent:
				r.Lock()
				r.messageComposer.Reenable(event.Err)
				r.Unlock()
			}
		case event := <-r.matrixEvents:
			switch event.(type) {
			case matrix.RoomEvent:
				r.addEvent(event, true)
			case matrix.StateEvent:
				r.addEvent(event, true)
			}
		case event := <-r.pastEvents:
			r.addEvent(event, false)
		}
	}
}

func (r *view) addEvent(event matrix.Event, appendToEnd bool) {
	switch event := event.(type) {
	case matrix.RoomEvent:
		e := message{
			Message: fwidget.Message{
				Time:   event.OriginServerTS,
				Sender: event.Sender.Displayname(),
			},
			event: event,
		}

		if event.Type == "m.room.encrypted" {
			var err error
			event, err = event.Decrypt()
			if err != nil {
				e.invalid = true
				e.Text = err.Error()
			}
		}

		if body, ok := event.Content["body"]; event.Type == "m.room.message" && ok {
			e.Text = body.(string)
		} else if e.Text != "" {
			e.invalid = true
			e.Text = "unformattable"
		}

		if appendToEnd {
			r.events = append(r.events, e)
		} else {
			r.events = append([]interface{}{e}, r.events...)
		}
	case matrix.StateEvent:
		text, invalid := statusEventText(event)
		e := statusMessage{
			StatusMessage: fwidget.StatusMessage{
				Text: text,
			},
			event:   event,
			invalid: invalid,
		}

		if appendToEnd {
			r.events = append(r.events, e)
		} else {
			r.events = append([]interface{}{e}, r.events...)
		}
	default:
		panic("Invalid event added")
	}
}

func (r *view) update(gtx g) {
	if r.messageComposer.Submitted() {
		r.messageComposer.Disable()


@@ 100,39 187,19 @@ func (r *view) update(gtx g) {
			r.rx <- sentRoomEvent{Err: err}
		}()
	}

	for {
		select {
		case event := <-r.rx:
			switch event := event.(type) {
			case sentRoomEvent:
				r.messageComposer.Reenable(event.Err)
			}
		case event := <-r.matrixEvents:
			switch event.(type) {
			// EphemeralEvents should not be added to the room History
			case matrix.EphemeralEvent:
			case matrix.ToDeviceEvent:
			case matrix.AccountDataEvent:
			default:
				r.roomHistory.AddEvent(event)
			}
		case event := <-r.pastEvents:
			r.roomHistory.AddPastEvent(event)
		default:
			return
		}
	}
}

func (r *view) Stop() {
}

func (r *view) Layout(gtx g) d {
	r.Lock()
	defer r.Unlock()

	r.update(gtx)

	return layout.Flex{Alignment: layout.Start, Axis: layout.Vertical}.Layout(gtx,
		layout.Flexed(1, r.roomHistory.Layout),
		layout.Flexed(1, r.layoutHistory),
		layout.Rigid(func(gtx g) d {
			in := layout.Inset{
				Top:    unit.Dp(8),


@@ 141,81 208,115 @@ func (r *view) Layout(gtx g) d {
				Bottom: unit.Dp(4),
			}

			return in.Layout(gtx, fwidget.LayoutTheme(r.messageComposer.Layout, r.theme))
			return in.Layout(gtx,
				fwidget.LayoutTheme(
					r.messageComposer.Layout,
					r.theme.WithContrast(fwidget.PrimaryDefault),
				),
			)
		}),
	)
}

// roomHistory is a widget that displays the chat history of a room
// TODO Decide where and how different matrix.Event s are handled
type roomHistory struct {
	user   string
	list   *layout.List
	events []fwidget.ThemedWidget
	logger *zap.Logger
	theme  *material.Theme
}
func (r *view) layoutHistory(gtx g) d {
	return r.list.Layout(gtx, len(r.events), func(gtx g, index int) d {
		in := layout.Inset{Top: unit.Dp(16), Left: unit.Dp(16), Right: unit.Dp(40)}

func newRoomHistory(user string, theme *material.Theme, logger *zap.Logger) *roomHistory {
	return &roomHistory{
		user:   user,
		list:   &layout.List{Axis: layout.Vertical, ScrollToEnd: true},
		logger: logger,
		theme:  theme,
	}
}
		switch message := r.events[index].(type) {
		case message:
			theme := r.theme.WithContrast(fwidget.PrimaryDark)
			align := layout.W

func (w *roomHistory) AddEvent(event matrix.Event) {
	switch event := event.(type) {
	case matrix.RoomEvent:
		w.events = append(w.events, newMessageEvent(event.Sender.ID == w.user, event).Layout)
	case matrix.StateEvent:
		w.events = append(w.events, newStatusEvent(event).Layout)
	}
}
			if message.event.Sender.User == r.self {
				theme = r.theme.WithContrast(fwidget.SecondaryDefault)
				align = layout.E
			}

func (w *roomHistory) AddPastEvent(event matrix.Event) {
	switch event := event.(type) {
	case matrix.RoomEvent:
		w.events = append([]fwidget.ThemedWidget{newMessageEvent(event.Sender.ID == w.user, event).Layout}, w.events...)
	case matrix.StateEvent:
		w.events = append([]fwidget.ThemedWidget{newStatusEvent(event).Layout}, w.events...)
	}
}
			if message.invalid {
				theme = r.theme.WithContrast(fwidget.Error)
			}

// Layout implements the layout.Widget interface
func (w *roomHistory) Layout(gtx g) d {
	if w.list == nil {
		return d{}
	}
	return w.list.Layout(gtx, len(w.events), w.element)
}
			return in.Layout(gtx, func(gtx g) d {
				return align.Layout(gtx, fwidget.LayoutTheme(message.Layout, theme))
			})
		case statusMessage:
			theme := r.theme.WithContrast(fwidget.Accent)
			align := layout.Center

			if message.invalid {
				theme = r.theme.WithContrast(fwidget.Error)
			}

func (w *roomHistory) element(gtx g, index int) d {
	in := layout.Inset{Top: unit.Dp(16), Left: unit.Dp(16), Right: unit.Dp(40)}
	return in.Layout(gtx, fwidget.LayoutTheme(w.events[index], w.theme))
			return in.Layout(gtx, func(gtx g) d {
				return align.Layout(gtx, fwidget.LayoutTheme(message.Layout, theme))
			})
		default:
			panic("invalid type in list of events")
		}
	})
}

func ownEvent(me string, ev matrix.Event) bool {
	switch ev := ev.(type) {
	case matrix.AccountDataEvent:
		return true
	case matrix.RoomEvent:
		return ev.Sender.ID == me
	case matrix.StateEvent:
		return ev.Sender.ID == me
	}
func statusEventText(ev matrix.StateEvent) (string, bool) {
	senderName := ev.Sender.Displayname()

	return false
}
	var (
		text    string
		invalid bool
	)

func timeEvent(ev matrix.Event) time.Time {
	switch ev := ev.(type) {
	case matrix.RoomEvent:
		return ev.OriginServerTS
	case matrix.StateEvent:
		return ev.OriginServerTS
	switch ev.Type {
	case "m.room.member":
		text = fmt.Sprintf("%s changed their profile", ev.StateKey)
	case "m.room.create":
		text = fmt.Sprintf("%s created the room", senderName)
	case "m.room.power_levels":
		text = fmt.Sprintf("Room power levels were changed by %s", senderName)
	case "m.room.join_rules":
		text = fmt.Sprintf("Join rules were changed by %s", senderName)
	case "m.room.name":
		text = fmt.Sprintf("%s changed the room name to \"%s\"", senderName, ev.Room.Displayname())
	case "m.room.topic":
		var str string
		if topic, ok := ev.Content["topic"]; ok {
			str = fmt.Sprintf("%s changed the room topic to \"%s\"", senderName, topic)
		} else {
			str = fmt.Sprintf("%s removed the room topic", senderName)
		}
		text = str
	case "m.room.guest_access":
		var str string
		if access, ok := ev.Content["guest_access"]; ok && access == "can_join" || access == "forbidden" {
			if access == "can_join" {
				str = fmt.Sprintf("%s allowed guest to join", senderName)
			} else {
				str = fmt.Sprintf("%s disallowed guests to join", senderName)
			}
		} else {
			str = "[Unformattable Event]"
			invalid = true
		}
		text = str
	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 {
			str = "[Unformattable Event]"
			invalid = true
		}
		text = str
	case "m.room.canonical_alias":
		var str string
		if alias, ok := ev.Content["canonical_alias"]; ok {
			str = fmt.Sprintf("%s changed the canonical alias to \"%s\"", senderName, alias)
		} else {
			str = fmt.Sprintf("%s set no or removed the canonical alias", senderName)
		}
		text = str
	default:
		return time.Time{}
		invalid = true
		text = "[Unformattable Event]"
	}

	return text, invalid
}

M view/roomlist/roomlist.go => view/roomlist/roomlist.go +5 -3
@@ 30,7 30,7 @@ type view struct {

	cli *matrix.Client

	theme *material.Theme
	theme fwidget.Theme

	logger *zap.Logger



@@ 38,7 38,7 @@ type view struct {
	rooms []roomListElement
}

func New(cli *matrix.Client, modalLayer *component.ModalLayer, theme *material.Theme, logger *zap.Logger) frost.View {
func New(cli *matrix.Client, modalLayer *component.ModalLayer, theme fwidget.Theme, logger *zap.Logger) frost.View {
	return &view{
		cli:    cli,
		theme:  theme,


@@ 82,7 82,9 @@ func (l *view) handleMatrixEvent(event matrix.Event) {
		}
	}

	l.rooms = append([]roomListElement{NewRoomListElement(room, l.theme)}, l.rooms...)
	l.rooms = append([]roomListElement{
		NewRoomListElement(room, l.theme.WithContrast(fwidget.PrimaryDefault)),
	}, l.rooms...)
	l.rooms[0].MatrixEvent(event)

	l.tx <- frost.InvalidationEvent{}

M widget/list.go => widget/list.go +10 -2
@@ 102,7 102,7 @@ func (w InitialSign) Layout(gtx layout.Context) layout.Dimensions {
			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])
				color := initialColor(w.Initial)
				return Fill{Color: color}.Layout(gtx)
			}),
			// Initial


@@ 119,6 119,14 @@ func (w InitialSign) color() color.NRGBA {
	return contactColors[w.Initial[0]%6]
}

func initialColor(initial byte) color.NRGBA {
func initialColor(s string) color.NRGBA {
	var initial rune

	for _, c := range s {
		if c == '@' {
			continue
		}
		initial = c
	}
	return contactColors[initial%6]
}

A widget/message.go => widget/message.go +105 -0
@@ 0,0 1,105 @@
package widget

import (
	"image/color"
	"time"

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

	"gioui.org/layout"
	"gioui.org/unit"
	"gioui.org/widget/material"
)

type Message struct {
	Time   time.Time
	Text   string
	Sender string
}

func (m Message) Layout(gtx g, th *material.Theme) d {
	bg := Background{
		Color: th.Palette.ContrastBg,
		Inset: layout.Inset{
			Top:    unit.Dp(8),
			Bottom: unit.Dp(8),
			Left:   unit.Dp(8),
			Right:  unit.Dp(8),
		},
		Radius: unit.Dp(8),
	}

	var msgWidth int

	return bg.Layout(gtx, func(gtx g) d {
		th.Palette.Fg = th.Palette.ContrastFg
		th.Palette.Bg = th.Palette.ContrastBg

		return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
			layout.Rigid(func(gtx g) d {
				lbl := material.Body2(th, m.Sender)
				lbl.Color = initialColor(m.Sender)
				return lbl.Layout(gtx)
			}),

			layout.Rigid(func(gtx g) d {
				lbl := material.Body2(th, m.Text)
				dims := lbl.Layout(gtx)
				dims.Size.Y += gtx.Px(unit.Dp(4))
				msgWidth = dims.Size.X
				return dims
			}),

			layout.Rigid(func(gtx g) d {
				gtx.Constraints.Min.X = msgWidth
				f := layout.Flex{
					Axis:      layout.Horizontal,
					Spacing:   layout.SpaceBetween,
					Alignment: layout.Middle,
				}

				var children []layout.FlexChild
				child := layout.Rigid(func(gtx g) d {
					time := util.FormatTime(m.Time)
					lbl := material.Caption(th, time)
					lbl.Color = grey(th.Palette.Fg)
					return lbl.Layout(gtx)
				})

				children = append(children, child)

				return f.Layout(gtx, children...)
			}),
		)
	})
}

func grey(color color.NRGBA) color.NRGBA {
	if color.A > 128 {
		color.A -= 80
	} else {
		color.A += 80
	}
	return color
}

type StatusMessage struct {
	Text string
}

func (m StatusMessage) Layout(gtx g, th *material.Theme) d {
	bg := Background{
		Color: th.Palette.ContrastBg,
		Inset: layout.Inset{
			Top:    unit.Dp(8),
			Bottom: unit.Dp(8),
			Left:   unit.Dp(12),
			Right:  unit.Dp(12),
		},
		Radius: unit.Dp(10),
	}

	return bg.Layout(gtx, func(gtx g) d {
		return material.Body1(th, m.Text).Layout(gtx)
	})
}

M widget/theme.go => widget/theme.go +79 -0
@@ 1,6 1,11 @@
package widget

import (
	"image/color"

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

	"gioui.org/font/gofont"
	"gioui.org/layout"
	"gioui.org/widget/material"
)


@@ 12,3 17,77 @@ func LayoutTheme(widget ThemedWidget, theme *material.Theme) layout.Widget {
		return widget(gtx, theme)
	}
}

const (
	PrimaryLight byte = iota
	PrimaryDark
	PrimaryDefault

	SecondaryLight
	SecondaryDark
	SecondaryDefault

	Default

	Accent

	Error
)

type Theme struct {
	Colors [9]ContrastPair

	th *material.Theme
}

func NewTheme() Theme {
	th := Theme{
		th: material.NewTheme(gofont.Collection()),
	}

	th.Colors[PrimaryDefault] = ContrastPair{Bg: util.NRGB(0x6200EE), Fg: util.NRGB(0xFFFFFF)}
	th.Colors[PrimaryDark] = ContrastPair{Bg: util.NRGB(0x3700B3), Fg: util.NRGB(0xFFFFFF)}
	th.Colors[SecondaryDefault] = ContrastPair{Bg: util.NRGB(0x03DAC6), Fg: util.NRGB(0x000000)}
	th.Colors[SecondaryDark] = ContrastPair{Bg: util.NRGB(0x018786), Fg: util.NRGB(0x000000)}
	th.Colors[Default] = ContrastPair{Bg: util.NRGB(0xFFFFFF), Fg: util.NRGB(0x000000)}
	th.Colors[Accent] = ContrastPair{Bg: util.NRGB(0xe0e0e0), Fg: util.NRGB(0x000000)}
	th.Colors[Error] = ContrastPair{Bg: util.NRGB(0xB00020), Fg: util.NRGB(0xFFFFFF)}

	return th
}

func NewDarkTheme() Theme {
	th := Theme{
		th: material.NewTheme(gofont.Collection()),
	}

	th.Colors[PrimaryDefault] = ContrastPair{Bg: util.NRGB(0x6200EE), Fg: util.NRGB(0xFFFFFF)}
	th.Colors[PrimaryDark] = ContrastPair{Bg: util.NRGB(0x3700B3), Fg: util.NRGB(0xFFFFFF)}
	th.Colors[SecondaryDefault] = ContrastPair{Bg: util.NRGB(0x03DAC6), Fg: util.NRGB(0x000000)}
	th.Colors[SecondaryDark] = ContrastPair{Bg: util.NRGB(0x018786), Fg: util.NRGB(0x000000)}
	th.Colors[Default] = ContrastPair{Bg: util.NRGB(0x2c2c2c), Fg: util.NRGB(0xFFFFFF)}
	th.Colors[Accent] = ContrastPair{Bg: util.NRGB(0x797979), Fg: util.NRGB(0xFFFFFF)}
	th.Colors[Error] = ContrastPair{Bg: util.NRGB(0xB00020), Fg: util.NRGB(0xFFFFFF)}

	return th
}

func (th Theme) With(normal, contrast byte) *material.Theme {
	t := *th.th
	t.Palette = material.Palette{
		ContrastFg: th.Colors[contrast].Fg,
		ContrastBg: th.Colors[contrast].Bg,
		Fg:         th.Colors[normal].Fg,
		Bg:         th.Colors[normal].Bg,
	}

	return &t
}

func (th Theme) WithContrast(c byte) *material.Theme {
	return th.With(Default, c)
}

type ContrastPair struct {
	Fg, Bg color.NRGBA
}