~gioverse/chat

ref: ff42a2f8b59287707042461aa1ca1347fb8250eb chat/widget/material/message.go -rw-r--r-- 6.1 KiB
ff42a2f8Chris Waldon list: update Loader to return if more elements 1 year, 15 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
package material

import (
	"image"
	"image/color"

	"gioui.org/io/pointer"
	"gioui.org/layout"
	"gioui.org/op/paint"
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"gioui.org/x/richtext"
	chatlayout "git.sr.ht/~gioverse/chat/layout"
	"git.sr.ht/~gioverse/chat/ninepatch"
	chatwidget "git.sr.ht/~gioverse/chat/widget"
	"golang.org/x/exp/shiny/materialdesign/icons"
)

// Note: the values choosen are a best-guess heuristic, open to change.
var (
	DefaultMaxImageHeight  = unit.Dp(400)
	DefaultMaxMessageWidth = unit.Dp(600)
	DefaultAvatarSize      = unit.Dp(24)
	DefaultDangerColor     = color.NRGBA{R: 200, A: 255}
)

// ErrorIcon is the material design outlined error indicator.
var ErrorIcon *widget.Icon = func() *widget.Icon {
	icon, _ := widget.NewIcon(icons.AlertErrorOutline)
	return icon
}()

// FailedToSend is the message that is displayed to the user when there was a
// problem sending a chat message.
const FailedToSend = "Sending failed"

type (
	C = layout.Context
	D = layout.Dimensions
)

// UserInfoStyle defines the presentation of information about a user.
// It can present the user's name and avatar with a space between them.
type UserInfoStyle struct {
	// Username configures the presentation of the user name text.
	Username material.LabelStyle
	// Avatar defines the image shown as the user's avatar.
	Avatar Image
	// Spacer is inserted between the username and avatar fields.
	layout.Spacer
	// Local controls the Left-to-Right ordering of layout. If false,
	// the Left-to-Right order will be:
	//   - Avatar
	//   - Spacer
	//   - Username
	// If true, the order is reversed.
	Local bool
}

// UserInfo constructs a UserInfoStyle with sensible defaults.
func UserInfo(th *material.Theme, interact *chatwidget.UserInfo, username string, avatar image.Image) UserInfoStyle {
	interact.Avatar.Cache(avatar)
	return UserInfoStyle{
		Username: material.Body1(th, username),
		Avatar: Image{
			Image: widget.Image{
				Src:      interact.Avatar.Op(),
				Fit:      widget.Cover,
				Position: layout.Center,
			},
			Radii:  unit.Dp(8),
			Width:  DefaultAvatarSize,
			Height: DefaultAvatarSize,
		},
		Spacer: layout.Spacer{Width: unit.Dp(8)},
	}
}

// Layout the user information.
func (ui UserInfoStyle) Layout(gtx C) D {
	return layout.Flex{
		Axis:      layout.Horizontal,
		Alignment: layout.Middle,
	}.Layout(gtx,
		chatlayout.Reverse(ui.Local,
			layout.Rigid(ui.Avatar.Layout),
			layout.Rigid(ui.Spacer.Layout),
			layout.Rigid(ui.Username.Layout),
		)...,
	)
}

// MessageStyle configures the presentation of a chat message.
type MessageStyle struct {
	// Interaction holds the stateful parts of this message.
	Interaction *chatwidget.Message
	// MaxMessageWidth constrains the display width of the message's background.
	MaxMessageWidth unit.Value
	// MaxImageHeight constrains the maximum height of an image message. The image
	// will be scaled to fit within this height.
	MaxImageHeight unit.Value
	// ContentPadding separates the Content field from the edges of the background.
	ContentPadding layout.Inset
	// BubbleStyle configures a chat bubble beneath the message. If NinePatch is
	// non-nil, this field is ignored.
	BubbleStyle
	// Ninepatch provides a ninepatch stretchable image background. Only used if
	// non-nil.
	*ninepatch.NinePatch
	// Content is the actual styled text of the message.
	Content richtext.TextStyle
	// Image is the optional image content of the message.
	Image
}

// Message constructs a MessageStyle with sensible defaults.
func Message(th *material.Theme, interact *chatwidget.Message, content string, img image.Image) MessageStyle {
	interact.Image.Cache(img)
	l := material.Body1(th, "")
	return MessageStyle{
		BubbleStyle: Bubble(th),
		Content: richtext.Text(&interact.InteractiveText, th.Shaper, richtext.SpanStyle{
			Font:    l.Font,
			Size:    l.TextSize,
			Color:   th.Fg,
			Content: content,
		}),
		ContentPadding: layout.UniformInset(unit.Dp(8)),
		Image: Image{
			Width:  unit.Dp(400),
			Height: unit.Dp(400),
			Image: widget.Image{
				Src:      interact.Image.Op(),
				Fit:      widget.Cover,
				Position: layout.Center,
			},
			Radii: unit.Dp(8),
		},
		MaxMessageWidth: DefaultMaxMessageWidth,
		MaxImageHeight:  DefaultMaxImageHeight,
		Interaction:     interact,
	}
}

// WithNinePatch sets the message surface to a ninepatch image.
func (c MessageStyle) WithNinePatch(th *material.Theme, np ninepatch.NinePatch) MessageStyle {
	c.NinePatch = &np
	var (
		b = np.Image.Bounds()
	)
	// TODO(jfm): refine into more robust solution for picking the text color,
	// as needed.
	//
	// Currently, we pick the middle pixel and use a heuristic formula to get
	// relative luminance.
	//
	// Only considers color.NRGBA colors.
	if cl, ok := np.Image.At(b.Dx()/2, b.Dy()/2).(color.NRGBA); ok {
		if Luminance(cl) < 0.5 {
			for i := range c.Content.Styles {
				c.Content.Styles[i].Color = th.Bg
			}
		}
	}
	return c
}

// WithBubbleColor sets the message bubble color and selects a contrasted text color.
func (c MessageStyle) WithBubbleColor(th *material.Theme, col color.NRGBA, luminance float64) MessageStyle {
	c.BubbleStyle.Color = col
	if luminance < .5 {
		for i := range c.Content.Styles {
			c.Content.Styles[i].Color = th.Bg
		}
	}
	return c
}

// Layout the message atop its background.
func (m MessageStyle) Layout(gtx C) D {
	gtx.Constraints.Max.X = int(float32(gtx.Constraints.Max.X) * 0.8)
	max := gtx.Px(m.MaxMessageWidth)
	if gtx.Constraints.Max.X > max {
		gtx.Constraints.Max.X = max
	}
	if m.Image.Src == (paint.ImageOp{}) {
		surface := m.BubbleStyle.Layout
		if m.NinePatch != nil {
			surface = m.NinePatch.Layout
		}
		return surface(gtx, func(gtx C) D {
			return m.ContentPadding.Layout(gtx, func(gtx C) D {
				return m.Content.Layout(gtx)
			})
		})
	}
	defer pointer.CursorNameOp{Name: pointer.CursorPointer}.Add(gtx.Ops)
	return material.Clickable(gtx, &m.Interaction.Clickable, func(gtx C) D {
		gtx.Constraints.Max.Y = gtx.Px(m.MaxImageHeight)
		return m.Image.Layout(gtx)
	})
}

// Luminance computes the relative brightness of a color, normalized between
// [0,1]. Ignores alpha.
func Luminance(c color.NRGBA) float64 {
	return (float64(float64(0.299)*float64(c.R) + float64(0.587)*float64(c.G) + float64(0.114)*float64(c.B))) / 255
}