diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..780cadb
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,17 @@
+## Sidebar refactoring
+
+Maybe put services separately in the left sidebar like so:
+
+![](https://miro.medium.com/max/1600/1*DSH66RN5DA5UQdZ2xE2I-g.png)
+
+## Behavioral changes
+
+Top-level server loads can probably lazy-load, but independent servers can
+probably be all loaded at once. This might not be a good idea for guild folders.
+
+cchat-gtk should also store what's expanded into a config. This is pretty
+trivial to do.
+
+## Spellcheck
+
+Write a Golang gspell binding and use that.
diff --git a/go.mod b/go.mod
index 8e32a9e..0fc8765 100644
--- a/go.mod
+++ b/go.mod
@@ -4,13 +4,16 @@ go 1.14
replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d
+replace github.com/diamondburned/cchat-discord => ../cchat-discord/
+
require (
github.com/Xuanwo/go-locale v0.2.0
github.com/alecthomas/chroma v0.7.3
- github.com/diamondburned/cchat v0.0.42
+ github.com/diamondburned/cchat v0.0.43
github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c
- github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3
- github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b
+ github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
+ github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
+ github.com/disintegration/imaging v1.6.2
github.com/goodsign/monday v1.0.0
github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
diff --git a/go.sum b/go.sum
index 26ffd07..d51a249 100644
--- a/go.sum
+++ b/go.sum
@@ -54,6 +54,8 @@ github.com/diamondburned/cchat v0.0.41 h1:6y32s2wWTiDw4hWN/Gna6ay3uUrRAW5V8Cj0/x
github.com/diamondburned/cchat v0.0.41/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat v0.0.42 h1:FVMLy9hOTxKju8OWDBIStrekbgTHCaH8+GVnV4LOByg=
github.com/diamondburned/cchat v0.0.42/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
+github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX2wOCU=
+github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU=
github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03 h1:F5TL7GPRU/D4ldVkS0haY3SiHPtf1Kby/4nbYpm//MQ=
github.com/diamondburned/cchat-discord v0.0.0-20200703190659-fbf95b9b6c03/go.mod h1:p0X6QUH0mxK8yEW0+a4QA77ClAmoxz8CvgbnobMtWQA=
github.com/diamondburned/cchat-discord v0.0.0-20200708083530-d0e43cc63b03 h1:Xx4ioFTurT6qTxzTL8QlsH3E5VskLxHPJ8RwmaKhObA=
@@ -66,12 +68,16 @@ github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c h1:4F7
github.com/diamondburned/cchat-discord v0.0.0-20200709041349-1e137df6de2c/go.mod h1:QHPtnxNrnMFCYB/b9kUP93D30Kf3AuGmkM91tScIpB8=
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3 h1:xr07/2cwINyrMqh92pQQJVDfQqG0u6gHAK+ZcGfpSew=
github.com/diamondburned/cchat-mock v0.0.0-20200704044009-f587c4904aa3/go.mod h1:SRu3OOeggELFr2Wd3/+SpYV1eNcvSk2LBhM70NOZSG8=
+github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E=
+github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b/go.mod h1:+bAf0m2o5qH54DmYJ/lR1HeITV53ol0JaoKyFFx3m3E=
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI=
github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516 h1:6j4oZahbNdVhSEInRfeYbgDpx1FXDfJy6CcUVyWOuVY=
github.com/diamondburned/imgutil v0.0.0-20200704034004-40dbfc732516/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b h1:iYKHGvWzNFBIRTSY8Pd5g301YDGWMfs3fh1VS0iBSj0=
github.com/diamondburned/imgutil v0.0.0-20200708012333-53c9e45dd28b/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
+github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo=
+github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249 h1:yP7kJ+xCGpDz6XbcfACJcju4SH1XDPwlrvbofz3lP8I=
github.com/diamondburned/ningen v0.1.1-0.20200621014632-6babb812b249/go.mod h1:xW9hpBZsGi8KpAh10TyP+YQlYBo+Xc+2w4TR6N0951A=
github.com/diamondburned/ningen v0.1.1-0.20200708090333-227e90d19851 h1:xf1aLPnwK/Yn2z7dBIgQROSVOEc2wtivgnnwBItdEVM=
diff --git a/internal/gts/gts.go b/internal/gts/gts.go
index 17855f3..a020c59 100644
--- a/internal/gts/gts.go
+++ b/internal/gts/gts.go
@@ -1,10 +1,13 @@
package gts
import (
+ "fmt"
+ "image"
"os"
"time"
"github.com/diamondburned/cchat-gtk/internal/log"
+ "github.com/disintegration/imaging"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk"
@@ -21,6 +24,9 @@ var App struct {
Header *gtk.HeaderBar
}
+// Clipboard is initialized on init().
+var Clipboard *gtk.Clipboard
+
// NewModalDialog returns a new modal dialog that's transient for the main
// window.
func NewModalDialog() (*gtk.Dialog, error) {
@@ -56,15 +62,19 @@ func AddAppAction(name string, call func()) {
App.AddAction(action)
}
-func AddWindowAction(name string, call func()) {
- action := glib.SimpleActionNew(name, nil)
- action.Connect("activate", call)
- App.Window.AddAction(action)
-}
+// Commented because this is not a good function to use. Components should use
+// AddAppAction instead.
+
+// func AddWindowAction(name string, call func()) {
+// action := glib.SimpleActionNew(name, nil)
+// action.Connect("activate", call)
+// App.Window.AddAction(action)
+// }
func init() {
gtk.Init(&Args)
App.Application, _ = gtk.ApplicationNew(AppID, 0)
+ Clipboard, _ = gtk.ClipboardGet(gdk.SELECTION_CLIPBOARD)
}
type WindowHeaderer interface {
@@ -176,3 +186,81 @@ func EventIsRightClick(ev *gdk.Event) bool {
keyev := gdk.EventButtonNewFromEvent(ev)
return keyev.Type() == gdk.EVENT_BUTTON_PRESS && keyev.Button() == gdk.BUTTON_SECONDARY
}
+
+func RenderPixbuf(img image.Image) *gdk.Pixbuf {
+ var nrgba *image.NRGBA
+ if n, ok := img.(*image.NRGBA); ok {
+ nrgba = n
+ } else {
+ nrgba = imaging.Clone(img)
+ }
+
+ pix, err := gdk.PixbufNewFromData(
+ nrgba.Pix, gdk.COLORSPACE_RGB,
+ true, // NRGBA has alpha.
+ 8, // 8-bit aka 1-byte per sample.
+ nrgba.Rect.Dx(),
+ nrgba.Rect.Dy(), // We already know the image size.
+ nrgba.Stride,
+ )
+
+ if err != nil {
+ panic(fmt.Sprintf("Failed to create pixbuf from *NRGBA: %v", err))
+ }
+
+ return pix
+}
+
+func SpawnUploader(dirpath string, callback func(absolutePaths []string)) {
+ dialog, _ := gtk.FileChooserDialogNewWith2Buttons(
+ "Upload File", App.Window,
+ gtk.FILE_CHOOSER_ACTION_OPEN,
+ "Cancel", gtk.RESPONSE_CANCEL,
+ "Upload", gtk.RESPONSE_ACCEPT,
+ )
+
+ BindPreviewer(dialog)
+
+ if dirpath == "" {
+ p, err := os.Getwd()
+ if err != nil {
+ p = glib.GetUserDataDir()
+ }
+ dirpath = p
+ }
+
+ dialog.SetLocalOnly(false)
+ dialog.SetCurrentFolder(dirpath)
+ dialog.SetSelectMultiple(true)
+
+ defer dialog.Close()
+
+ if res := dialog.Run(); res != gtk.RESPONSE_ACCEPT {
+ return
+ }
+
+ names, _ := dialog.GetFilenames()
+ callback(names)
+}
+
+// BindPreviewer binds the file chooser dialog with a previewer.
+func BindPreviewer(fc *gtk.FileChooserDialog) {
+ img, _ := gtk.ImageNew()
+
+ fc.SetPreviewWidget(img)
+ fc.Connect("update-preview",
+ func(fc *gtk.FileChooserDialog, img *gtk.Image) {
+ file := fc.GetPreviewFilename()
+
+ b, err := gdk.PixbufNewFromFileAtScale(file, 256, 256, true)
+ if err != nil {
+ fc.SetPreviewWidgetActive(false)
+ return
+ }
+
+ img.SetFromPixbuf(b)
+ fc.SetPreviewWidgetActive(true)
+ },
+ img,
+ )
+}
diff --git a/internal/gts/httputil/httputil.go b/internal/gts/httputil/httputil.go
index 0939aba..4d0fab9 100644
--- a/internal/gts/httputil/httputil.go
+++ b/internal/gts/httputil/httputil.go
@@ -15,15 +15,11 @@ import (
"github.com/pkg/errors"
)
-var dskcached *http.Client
+var basePath = filepath.Join(os.TempDir(), "cchat-gtk-sabotaging-the-desktop-experience")
-func init() {
- var basePath = filepath.Join(os.TempDir(), "cchat-gtk-pridemonth")
-
- http.DefaultClient.Timeout = 15 * time.Second
-
- dskcached = &(*http.DefaultClient)
- dskcached.Transport = httpcache.NewTransport(
+var dskcached = http.Client{
+ Timeout: 15 * time.Second,
+ Transport: httpcache.NewTransport(
diskcache.NewWithDiskv(diskv.New(diskv.Options{
BasePath: basePath,
TempDir: filepath.Join(basePath, "tmp"),
@@ -32,11 +28,7 @@ func init() {
Compression: diskv.NewZlibCompressionLevel(2),
CacheSizeMax: 25 * 1024 * 1024, // 25 MiB in memory
})),
- )
-}
-
-func secs(dura time.Duration) int64 {
- return int64(dura / time.Second)
+ ),
}
func AsyncStreamUncached(url string, fn func(r io.Reader)) {
diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go
index a412bae..16871f4 100644
--- a/internal/gts/httputil/image.go
+++ b/internal/gts/httputil/image.go
@@ -10,7 +10,6 @@ import (
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/glib"
- "github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
@@ -18,9 +17,6 @@ type ImageContainer interface {
SetFromPixbuf(*gdk.Pixbuf)
SetFromAnimation(*gdk.PixbufAnimation)
Connect(string, interface{}, ...interface{}) (glib.SignalHandle, error)
-
- // for internal use
- pbgetter
}
type ImageContainerSizer interface {
@@ -83,14 +79,6 @@ func AsyncImageSized(img ImageContainerSizer, url string, w, h int, procs ...img
go syncImage(ctx, l, url, procs, gif)
}
-type pbgetter interface {
- GetPixbuf() *gdk.Pixbuf
- GetAnimation() *gdk.PixbufAnimation
- GetStorageType() gtk.ImageType
-}
-
-var _ pbgetter = (*gtk.Image)(nil)
-
func connectDestroyer(img ImageContainer, cancel func()) {
img.Connect("destroy", func(img ImageContainer) {
cancel()
diff --git a/internal/humanize/humanize.go b/internal/humanize/humanize.go
index 6a91492..3524961 100644
--- a/internal/humanize/humanize.go
+++ b/internal/humanize/humanize.go
@@ -1,6 +1,7 @@
package humanize
import (
+ "strings"
"time"
"github.com/goodsign/monday"
@@ -61,3 +62,9 @@ func timeAgo(t time.Time, truncs []truncator) string {
return ""
}
+
+// Error returns a short error string.
+func Error(err error) string {
+ parts := strings.Split(err.Error(), ":")
+ return strings.TrimSpace(parts[len(parts)-1])
+}
diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go
index c773631..c980a79 100644
--- a/internal/ui/dialog/dialog.go
+++ b/internal/ui/dialog/dialog.go
@@ -6,22 +6,27 @@ import (
"github.com/gotk3/gotk3/gtk"
)
-func ShowModal(body gtk.IWidget, title, button string, callback func()) {
- NewModal(body, title, title, callback).Show()
+type Modal struct {
+ *gtk.Dialog
+ Cancel *gtk.Button
+ Action *gtk.Button
+ Header *gtk.HeaderBar
}
-func NewModal(body gtk.IWidget, title, button string, callback func()) *gtk.Dialog {
- cancel, _ := gtk.ButtonNew()
+func ShowModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) {
+ NewModal(body, title, title, clicked).Show()
+}
+
+func NewModal(body gtk.IWidget, title, button string, clicked func(m *Modal)) *Modal {
+ cancel, _ := gtk.ButtonNewWithMnemonic("_Cancel")
cancel.Show()
cancel.SetHAlign(gtk.ALIGN_START)
cancel.SetRelief(gtk.RELIEF_NONE)
- cancel.SetLabel("Cancel")
- action, _ := gtk.ButtonNew()
+ action, _ := gtk.ButtonNewWithMnemonic(button)
action.Show()
action.SetHAlign(gtk.ALIGN_END)
action.SetRelief(gtk.RELIEF_NONE)
- action.SetLabel(button)
header, _ := gtk.HeaderBarNew()
header.Show()
@@ -32,11 +37,17 @@ func NewModal(body gtk.IWidget, title, button string, callback func()) *gtk.Dial
header.PackEnd(action)
dialog := newCSD(body, header)
+ modald := &Modal{
+ dialog,
+ cancel,
+ action,
+ header,
+ }
cancel.Connect("clicked", dialog.Destroy)
- action.Connect("clicked", callback)
+ action.Connect("clicked", func() { clicked(modald) })
- return dialog
+ return modald
}
func NewCSD(body, header gtk.IWidget) *gtk.Dialog {
diff --git a/internal/ui/imgview/imgview.go b/internal/ui/imgview/imgview.go
index 406ed8b..1e84b9c 100644
--- a/internal/ui/imgview/imgview.go
+++ b/internal/ui/imgview/imgview.go
@@ -108,26 +108,28 @@ func PromptOpen(uri string) {
// Style the label.
primitives.AttachCSS(l, warnLabelCSS)
- open := func() {
+ open := func(m *dialog.Modal) {
+ // Close the dialog.
+ m.Destroy()
+ // Open the link.
if err := open.Start(uri); err != nil {
log.Error(errors.Wrap(err, "Failed to open URL after confirm"))
}
}
// Prompt the user if they want to open the URL.
- dlg := dialog.NewModal(l, "Caution", "Open", open)
+ dlg := dialog.NewModal(l, "Caution", "_Open", open)
dlg.SetSizeRequest(350, 100)
+ // Style the button to have a color.
+ primitives.SuggestAction(dlg.Action)
+
// Add a class to the dialog to allow theming.
primitives.AddClass(dlg, "url-warning")
- // On link click, close the dialog.
+ // On link click, close the dialog, open the link ourselves, then return.
l.Connect("activate-link", func(l *gtk.Label, uri string) bool {
- // Close the dialog.
- dlg.Destroy()
- // Open the link anyway.
- open()
- // Return true since we handled the event.
+ open(dlg)
return true
})
diff --git a/internal/ui/messages/container/compact/message.go b/internal/ui/messages/container/compact/message.go
index 366e82f..695fbad 100644
--- a/internal/ui/messages/container/compact/message.go
+++ b/internal/ui/messages/container/compact/message.go
@@ -5,7 +5,6 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
- "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
@@ -33,10 +32,6 @@ func NewMessage(msg cchat.MessageCreate) Message {
msgc := message.NewContainer(msg)
message.FillContainer(msgc, msg)
- primitives.AddClass(msgc.Timestamp, "compact-timestamp")
- primitives.AddClass(msgc.Username, "compact-username")
- primitives.AddClass(msgc.Content, "compact-content")
-
return Message{msgc}
}
diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go
index 730f8b7..4d57eb9 100644
--- a/internal/ui/messages/container/cozy/message_collapsed.go
+++ b/internal/ui/messages/container/cozy/message_collapsed.go
@@ -32,7 +32,7 @@ func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage {
gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2)
// Set Content's padding accordingly to FullMessage's main box.
- gc.Content.SetMarginEnd(container.ColumnSpacing * 2)
+ gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2)
return &CollapsedMessage{
GenericContainer: gc,
diff --git a/internal/ui/messages/input/attachment/attachment.go b/internal/ui/messages/input/attachment/attachment.go
new file mode 100644
index 0000000..417c991
--- /dev/null
+++ b/internal/ui/messages/input/attachment/attachment.go
@@ -0,0 +1,345 @@
+package attachment
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/png"
+ "io"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+
+ "github.com/diamondburned/cchat"
+ "github.com/diamondburned/cchat-gtk/internal/gts"
+ "github.com/diamondburned/cchat-gtk/internal/log"
+ "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
+ "github.com/disintegration/imaging"
+ "github.com/gotk3/gotk3/gdk"
+ "github.com/gotk3/gotk3/gtk"
+ "github.com/pkg/errors"
+)
+
+var pngEncoder = png.Encoder{
+ CompressionLevel: png.BestCompression,
+}
+
+const FileIconSize = 72
+
+// File represents a middle format that can be used to create a
+// MessageAttachment.
+type File struct {
+ Prog *Progress
+ Name string
+ Size int64
+}
+
+// NewFile creates a new attachment file with a progress state.
+func NewFile(name string, size int64, open Open) File {
+ return File{
+ Prog: NewProgress(NewReusableReader(open), size),
+ Name: name,
+ Size: size,
+ }
+}
+
+// AsAttachment turns File into a MessageAttachment. This method will always
+// make a new MessageAttachment and will never return an old one.
+//
+// The reason being MessageAttachment should never be reused, as it hides the
+// fact that the io.Reader is reusable.
+func (f *File) AsAttachment() cchat.MessageAttachment {
+ return cchat.MessageAttachment{
+ Name: f.Name,
+ Reader: f.Prog,
+ }
+}
+
+type Container struct {
+ *gtk.Revealer
+ Scroll *gtk.ScrolledWindow
+ Box *gtk.Box
+
+ // states
+ files []File
+ items map[string]gtk.IWidget
+}
+
+var attachmentsCSS = primitives.PrepareCSS(`
+ .attachments { padding: 5px; padding-bottom: 0 }
+`)
+
+func New() *Container {
+ box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
+ box.Show()
+
+ primitives.AddClass(box, "attachments")
+ primitives.AttachCSS(box, attachmentsCSS)
+
+ scr, _ := gtk.ScrolledWindowNew(nil, nil)
+ scr.SetPolicy(gtk.POLICY_EXTERNAL, gtk.POLICY_NEVER)
+ scr.SetProperty("kinetic-scrolling", false)
+ scr.Add(box)
+ scr.Show()
+
+ // Scroll left/right when the wheel goes up/down.
+ scr.Connect("scroll-event", func(s *gtk.ScrolledWindow, ev *gdk.Event) bool {
+ // Magic thing I found out while print-debugging. DeltaY shows the same
+ // offset whether you scroll with Shift or not, which makes sense.
+ // DeltaX is always 0.
+
+ var adj = scr.GetHAdjustment()
+
+ switch ev := gdk.EventScrollNewFromEvent(ev); ev.DeltaY() {
+ case 1:
+ adj.SetValue(adj.GetValue() + adj.GetStepIncrement())
+ case -1:
+ adj.SetValue(adj.GetValue() - adj.GetStepIncrement())
+ default:
+ // Not handled.
+ return false
+ }
+
+ // Handled.
+ return true
+ })
+
+ rev, _ := gtk.RevealerNew()
+ rev.SetRevealChild(false)
+ rev.Add(scr)
+
+ return &Container{
+ Revealer: rev,
+ Scroll: scr,
+ Box: box,
+ items: map[string]gtk.IWidget{},
+ }
+}
+
+// SetMarginStart sets the inner margin of the attachments carousel.
+func (c *Container) SetMarginStart(margin int) {
+ c.Box.SetMarginStart(margin)
+}
+
+// Files returns the list of attachments
+func (c *Container) Files() []File {
+ return c.files
+}
+
+// Reset does NOT close files.
+func (c *Container) Reset() {
+ // Reset states. We do not touch the old files slice, as other callers may
+ // be referencing and using it.
+ c.files = nil
+
+ // Clear all items.
+ for _, item := range c.items {
+ c.Box.Remove(item)
+ }
+
+ // Reset the map.
+ c.items = map[string]gtk.IWidget{}
+
+ // Hide the window.
+ c.SetRevealChild(false)
+}
+
+// AddFiles is used for the file chooser's callback.
+func (c *Container) AddFiles(paths []string) {
+ for _, path := range paths {
+ if err := c.AddFile(path); err != nil {
+ log.Error(errors.Wrap(err, "Failed to add file"))
+ }
+ }
+}
+
+// AddFile is used for the file picker.
+func (c *Container) AddFile(path string) error {
+ // Check the file and get the size.
+ s, err := os.Stat(path)
+ if err != nil {
+ return errors.Wrap(err, "Failed to stat file")
+ }
+
+ var filename = c.append(
+ filepath.Base(path), s.Size(),
+ func() (io.ReadCloser, error) { return os.Open(path) },
+ )
+
+ // Maybe try making a preview. A nil image is fine, so we can skip the error
+ // check.
+ // TODO: add a filesize check
+ i, _ := imaging.Open(path, imaging.AutoOrientation(true))
+ c.addPreview(filename, i)
+
+ return nil
+}
+
+// AddPixbuf is used for adding pixbufs from the clipboard.
+func (c *Container) AddPixbuf(pb *gdk.Pixbuf) error {
+ // Pixbuf's colorspace is only RGB. This is indicated with
+ // GDK_COLORSPACE_RGB.
+ if pb.GetColorspace() != gdk.COLORSPACE_RGB {
+ return errors.New("Pixbuf has unsupported colorspace")
+ }
+
+ // Assert that the pixbuf has alpha, as we're using RGBA.
+ if !pb.GetHasAlpha() {
+ return errors.New("Pixbuf has no alpha channel")
+ }
+
+ // Assert that there are 4 channels: red, green, blue and alpha.
+ if pb.GetNChannels() != 4 {
+ return errors.New("Pixbuf has unexpected channel count")
+ }
+
+ // Assert that there are 8 bits in a channel/sample.
+ if pb.GetBitsPerSample() != 8 {
+ return errors.New("Pixbuf has unexpected bits per sample")
+ }
+
+ var img = &image.NRGBA{
+ Pix: pb.GetPixels(),
+ Stride: pb.GetRowstride(),
+ Rect: image.Rect(0, 0, pb.GetWidth(), pb.GetHeight()),
+ }
+
+ // Store the image in memory.
+ var buf bytes.Buffer
+
+ if err := pngEncoder.Encode(&buf, img); err != nil {
+ return errors.Wrap(err, "Failed to encode PNG")
+ }
+
+ var filename = c.append(
+ fmt.Sprintf("clipboard_%d.png", len(c.files)+1), int64(buf.Len()),
+ func() (io.ReadCloser, error) {
+ return ioutil.NopCloser(bytes.NewReader(buf.Bytes())), nil
+ },
+ )
+
+ c.addPreview(filename, img)
+
+ return nil
+}
+
+// -- internal methods --
+
+// append guarantees there's no collision. It returns the unique filename.
+func (c *Container) append(name string, sz int64, open Open) string {
+ // Show the preview window.
+ c.SetRevealChild(true)
+
+ // Guarantee that the filename will never collide.
+ for _, file := range c.files {
+ if file.Name == name {
+ // Hopefully this works? I'm not sure. But this will keep prepending
+ // an underscore.
+ name = "_" + name
+ }
+ }
+
+ c.files = append(c.files, NewFile(name, sz, open))
+ return name
+}
+
+func (c *Container) remove(name string) {
+ for i, file := range c.files {
+ if file.Name == name {
+ c.files = append(c.files[:i], c.files[i+1:]...)
+ break
+ }
+ }
+
+ if w, ok := c.items[name]; ok {
+ c.Box.Remove(w)
+ delete(c.items, name)
+ }
+
+ // Collapse the container if there's nothing.
+ if len(c.items) == 0 {
+ c.SetRevealChild(false)
+ }
+}
+
+var previewCSS = primitives.PrepareCSS(`
+ .attachment-preview {
+ background-color: alpha(@theme_fg_color, 0.2);
+ border-radius: 4px;
+ }
+`)
+
+var deleteAttBtnCSS = primitives.PrepareCSS(`
+ .delete-attachment {
+ /* Remove styling from the Gtk themes */
+ border: none;
+ box-shadow: none;
+
+ /* Add our own styling */
+ border-radius: 999px 999px;
+ transition: linear 100ms all;
+ background-color: alpha(@theme_bg_color, 0.50);
+ }
+ .delete-attachment:hover {
+ background-color: alpha(red, 0.5);
+ }
+`)
+
+func (c *Container) addPreview(name string, src image.Image) {
+ // Make a fallback image first.
+ gimg, _ := gtk.ImageNew()
+ primitives.SetImageIcon(gimg, "image-x-generic-symbolic", FileIconSize/3)
+ gimg.SetSizeRequest(FileIconSize, FileIconSize)
+ gimg.SetVAlign(gtk.ALIGN_CENTER)
+ gimg.SetHAlign(gtk.ALIGN_CENTER)
+ gimg.SetTooltipText(name)
+ gimg.Show()
+ primitives.AddClass(gimg, "attachment-preview")
+ primitives.AttachCSS(gimg, previewCSS)
+
+ // Determine if we could generate an image preview.
+ if src != nil {
+ // Get the minimum dimension.
+ var w, h = minsize(src.Bounds().Dx(), src.Bounds().Dy(), FileIconSize)
+
+ var img *image.NRGBA
+ // Downscale the image.
+ img = imaging.Resize(src, w, h, imaging.Lanczos)
+
+ // Crop to a square.
+ img = imaging.CropCenter(img, FileIconSize, FileIconSize)
+
+ // Copy the image to a pixbuf.
+ gimg.SetFromPixbuf(gts.RenderPixbuf(img))
+ }
+
+ // BLOAT!!! Make an overlay of an event box that, when hovered, will show
+ // something that allows closing the image.
+ del, _ := gtk.ButtonNewFromIconName("window-close", gtk.ICON_SIZE_DIALOG)
+ del.SetVAlign(gtk.ALIGN_CENTER)
+ del.SetHAlign(gtk.ALIGN_CENTER)
+ del.SetTooltipText("Remove " + name)
+ del.Connect("clicked", func() { c.remove(name) })
+ del.Show()
+ primitives.AddClass(del, "delete-attachment")
+ primitives.AttachCSS(del, deleteAttBtnCSS)
+
+ ovl, _ := gtk.OverlayNew()
+ ovl.SetSizeRequest(FileIconSize, FileIconSize)
+ ovl.Add(gimg)
+ ovl.AddOverlay(del)
+ ovl.Show()
+
+ c.items[name] = ovl
+ c.Box.PackStart(ovl, false, false, 0)
+}
+
+func minsize(w, h, maxsz int) (int, int) {
+ if w < h {
+ // return the scaled width as max
+ // h*max/w is the same as h/w*max but with more accuracy
+ return maxsz, h * maxsz / w
+ }
+
+ return w * maxsz / h, maxsz
+}
diff --git a/internal/ui/messages/input/attachment/progress.go b/internal/ui/messages/input/attachment/progress.go
new file mode 100644
index 0000000..d125f63
--- /dev/null
+++ b/internal/ui/messages/input/attachment/progress.go
@@ -0,0 +1,116 @@
+package attachment
+
+import (
+ "errors"
+ "io"
+
+ "github.com/diamondburned/cchat-gtk/internal/gts"
+ "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
+ "github.com/gotk3/gotk3/gtk"
+ "github.com/gotk3/gotk3/pango"
+)
+
+type MessageUploader struct {
+ *gtk.Grid
+}
+
+// NewMessageUploader creates a new MessageUploader. It returns nil if there are
+// no files.
+func NewMessageUploader(files []File) *MessageUploader {
+ m := &MessageUploader{}
+
+ m.Grid, _ = gtk.GridNew()
+ m.Grid.SetHExpand(true)
+ m.Grid.SetColumnSpacing(4)
+ m.Grid.SetRowSpacing(2)
+ m.Grid.SetRowHomogeneous(true)
+
+ primitives.AddClass(m.Grid, "upload-progress")
+
+ for i, file := range files {
+ var pbar = NewProgressBar(file)
+
+ m.Grid.Attach(pbar.Name, 0, i, 1, 1)
+ m.Grid.Attach(pbar.PBar, 1, i, 1, 1)
+ }
+
+ return m
+}
+
+type ProgressBar struct {
+ PBar *gtk.ProgressBar
+ Name *gtk.Label
+}
+
+func NewProgressBar(file File) *ProgressBar {
+ bar, _ := gtk.ProgressBarNew()
+ bar.SetVAlign(gtk.ALIGN_CENTER)
+ bar.Show()
+
+ name, _ := gtk.LabelNew(file.Name)
+ name.SetMaxWidthChars(45)
+ name.SetSingleLineMode(true)
+ name.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
+ name.SetXAlign(1)
+ name.Show()
+
+ // Override the upload read callback.
+ file.Prog.u = func(fraction float64) {
+ gts.ExecAsync(func() {
+ if fraction == -1 {
+ // Pulse the bar around, as we don't know the total bytes.
+ bar.Pulse()
+ } else {
+ // We know the progress, so use the percentage.
+ bar.SetFraction(fraction)
+ }
+ })
+ }
+
+ return &ProgressBar{bar, name}
+}
+
+// Progress wraps around a ReadCloser and implements a progress state for a
+// reader.
+type Progress struct {
+ u func(float64) // read callback, arg is percentage
+ r io.Reader
+ s float64 // total, const
+ n uint64 // cumulative
+}
+
+// NewProgress creates a new upload progress state.
+func NewProgress(r io.Reader, size int64) *Progress {
+ return &Progress{
+ r: r,
+ s: float64(size),
+ n: 0,
+ }
+}
+
+// frac returns the current percentage, or -1 is there is no total.
+func (p *Progress) frac() float64 {
+ if p.s > 0 {
+ return float64(p.n) / p.s
+ }
+ return -1
+}
+
+func (p *Progress) Read(b []byte) (int, error) {
+ // Read and cumulate total bytes read if there are no errors or if the error
+ // is not fatal (EOF).
+ n, err := p.r.Read(b)
+ if err == nil || errors.Is(err, io.EOF) {
+ p.n += uint64(n)
+ } else {
+ // If we have an unexpected error, then we should reset the bytes read
+ // to 0.
+ p.n = 0
+ }
+
+ if p.u != nil {
+ p.u(p.frac())
+ }
+
+ return n, err
+}
diff --git a/internal/ui/messages/input/attachment/reusable_reader.go b/internal/ui/messages/input/attachment/reusable_reader.go
new file mode 100644
index 0000000..0dc8fe2
--- /dev/null
+++ b/internal/ui/messages/input/attachment/reusable_reader.go
@@ -0,0 +1,42 @@
+package attachment
+
+import (
+ "io"
+
+ "github.com/pkg/errors"
+)
+
+type Open = func() (io.ReadCloser, error)
+
+// ReusableReader provides an API which allows a reader to be used multiple
+// times. It is NOT thread-safe to use.
+type ReusableReader struct {
+ open func() (io.ReadCloser, error)
+ src io.ReadCloser
+}
+
+var _ io.Reader = (*ReusableReader)(nil)
+
+// NewReusableReader creates a new reader that is reusable after a read failure
+// or a close. The given open() callback MUST be reproducible.
+func NewReusableReader(open Open) *ReusableReader {
+ return &ReusableReader{open, nil}
+}
+
+func (r *ReusableReader) Read(b []byte) (int, error) {
+ if r.src == nil {
+ o, err := r.open()
+ if err != nil {
+ return 0, errors.Wrap(err, "Failed to open reader")
+ }
+ r.src = o
+ }
+
+ n, err := r.src.Read(b)
+ if err != nil { // err could be EOF or anything unexpected
+ r.src.Close()
+ r.src = nil
+ }
+
+ return n, err
+}
diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go
index 157eec6..1103f40 100644
--- a/internal/ui/messages/input/input.go
+++ b/internal/ui/messages/input/input.go
@@ -2,7 +2,9 @@ package input
import (
"github.com/diamondburned/cchat"
+ "github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
+ "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/username"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
@@ -23,19 +25,35 @@ type InputView struct {
}
var textCSS = primitives.PrepareCSS(`
+ .message-input {
+ padding-top: 2px;
+ padding-bottom: 2px;
+ }
+
.message-input, .message-input * {
background-color: transparent;
}
.message-input * {
background-color: @theme_base_color;
+ transition: linear 50ms background-color;
+
+ /* Legacy styling
border: 1px solid alpha(@theme_fg_color, 0.2);
border-radius: 4px;
- transition: linear 50ms border-color;
+ */
}
.message-input:focus * {
+ background-color: mix(
+ @theme_base_color,
+ @theme_selected_bg_color,
+ 0.15
+ );
+
+ /* Legacy styling
border-color: @theme_selected_bg_color;
+ */
}
`)
@@ -74,12 +92,18 @@ func (v *InputView) SetSender(session cchat.Session, sender cchat.ServerMessageS
}
type Field struct {
+ // Box contains the field box and the attachment container.
*gtk.Box
+ Attachments *attachment.Container
+
+ // FieldBox contains the username container and the input field. It spans
+ // horizontally.
+ FieldBox *gtk.Box
Username *username.Container
TextScroll *gtk.ScrolledWindow
- text *gtk.TextView
- buffer *gtk.TextBuffer
+ text *gtk.TextView // const
+ buffer *gtk.TextBuffer // const
UserID string
Sender cchat.ServerMessageSender
@@ -87,60 +111,80 @@ type Field struct {
ctrl Controller
- // editing state
+ // states
editingID string // never empty
+ sendings []PresendMessage
}
-var scrollinputCSS = primitives.PrepareCSS(`
- .scrolled-input {
- margin: 5px;
- }
+var inputFieldCSS = primitives.PrepareCSS(`
+ .input-field { margin: 3px 5px }
`)
func NewField(text *gtk.TextView, ctrl Controller) *Field {
- username := username.NewContainer()
- username.Show()
+ field := &Field{text: text, ctrl: ctrl}
+ field.buffer, _ = text.GetBuffer()
- buf, _ := text.GetBuffer()
+ field.Username = username.NewContainer()
+ field.Username.Show()
- sw := scrollinput.NewV(text, 150)
- sw.Show()
+ field.TextScroll = scrollinput.NewV(text, 150)
+ field.TextScroll.Show()
+ primitives.AddClass(field.TextScroll, "scrolled-input")
- primitives.AddClass(sw, "scrolled-input")
- primitives.AttachCSS(sw, scrollinputCSS)
+ attach, _ := gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON)
+ attach.SetRelief(gtk.RELIEF_NONE)
+ attach.Show()
+ primitives.AddClass(attach, "attach-button")
- box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
- box.PackStart(username, false, false, 0)
- box.PackStart(sw, true, true, 0)
- box.Show()
+ send, _ := gtk.ButtonNewFromIconName("mail-send-symbolic", gtk.ICON_SIZE_BUTTON)
+ send.SetRelief(gtk.RELIEF_NONE)
+ send.Show()
+ primitives.AddClass(send, "send-button")
- field := &Field{
- Box: box,
- Username: username,
- // typing: typing,
- TextScroll: sw,
- text: text,
- buffer: buf,
- ctrl: ctrl,
- }
+ // Keep this number the same as size-allocate below -------v
+ field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
+ field.FieldBox.PackStart(field.Username, false, false, 0)
+ field.FieldBox.PackStart(attach, false, false, 0)
+ field.FieldBox.PackStart(field.TextScroll, true, true, 0)
+ field.FieldBox.PackStart(send, false, false, 0)
+ field.FieldBox.Show()
+ primitives.AddClass(field.FieldBox, "input-field")
+ primitives.AttachCSS(field.FieldBox, inputFieldCSS)
- text.SetFocusHAdjustment(sw.GetHAdjustment())
- text.SetFocusVAdjustment(sw.GetVAdjustment())
+ field.Attachments = attachment.New()
+ field.Attachments.Show()
+
+ field.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
+ field.Box.PackStart(field.Attachments, false, false, 0)
+ field.Box.PackStart(field.FieldBox, false, false, 0)
+ field.Box.Show()
+
+ text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment())
+ text.SetFocusVAdjustment(field.TextScroll.GetVAdjustment())
+ // Bind text events.
text.Connect("key-press-event", field.keyDown)
+ // Bind the send button.
+ send.Connect("clicked", field.sendInput)
+ // Bind the attach button.
+ attach.Connect("clicked", func() { gts.SpawnUploader("", field.Attachments.AddFiles) })
- // // Connect to the field's revealer. On resize, we want the autocompleter to
- // // have the right padding too.
- // f.username.Connect("size-allocate", func(w gtk.IWidget) {
- // // Set the autocompleter's left margin to be the same.
- // c.SetMarginStart(w.ToWidget().GetAllocatedWidth())
- // })
+ // Connect to the field's revealer. On resize, we want the attachments
+ // carousel to have the same padding too.
+ field.Username.Connect("size-allocate", func(w gtk.IWidget) {
+ // Calculate the left width: from the left of the message box to the
+ // right of the attach button, covering the username container.
+ var leftWidth = 5*2 + attach.GetAllocatedWidth() + w.ToWidget().GetAllocatedWidth()
+ // Set the autocompleter's left margin to be the same.
+ field.Attachments.SetMarginStart(leftWidth)
+ })
return field
}
// Reset prepares the field before SetSender() is called.
func (f *Field) Reset() {
- // Paranoia.
+ // Paranoia. The View should already change to a different stack, but we're
+ // doing this just in case.
f.text.SetSensitive(false)
f.UserID = ""
@@ -149,7 +193,7 @@ func (f *Field) Reset() {
f.Username.Reset()
// reset the input
- f.buffer.Delete(f.buffer.GetBounds())
+ f.clearText()
}
// SetSender changes the sender of the input field. If nil, the input will be
@@ -213,21 +257,10 @@ func (f *Field) StopEditing() bool {
return true
}
-// yankText cuts the text from the input field and returns it.
-func (f *Field) yankText() string {
- start, end := f.buffer.GetBounds()
-
- text, _ := f.buffer.GetText(start, end, false)
- if text != "" {
- f.buffer.Delete(start, end)
- }
-
- return text
-}
-
-// clearText wipes the input field
+// clearText resets the input field
func (f *Field) clearText() {
f.buffer.Delete(f.buffer.GetBounds())
+ f.Attachments.Reset()
}
// getText returns the text from the input, but it doesn't cut it.
diff --git a/internal/ui/messages/input/keydown.go b/internal/ui/messages/input/keydown.go
index c0d8458..a0314a2 100644
--- a/internal/ui/messages/input/keydown.go
+++ b/internal/ui/messages/input/keydown.go
@@ -1,8 +1,11 @@
package input
import (
+ "github.com/diamondburned/cchat-gtk/internal/gts"
+ "github.com/diamondburned/cchat-gtk/internal/log"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
+ "github.com/pkg/errors"
)
const shiftMask = uint(gdk.SHIFT_MASK)
@@ -21,9 +24,9 @@ func convEvent(ev *gdk.Event) (key, mask uint) {
func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
var key, mask = convEvent(ev)
- switch key {
+ switch {
// If Enter is pressed.
- case gdk.KEY_Return:
+ case key == gdk.KEY_Return:
// If Shift is being held, insert a new line.
if bithas(mask, shiftMask) {
f.buffer.InsertAtCursor("\n")
@@ -36,7 +39,7 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
// If Arrow Up is pressed, then we might want to edit the latest message if
// any.
- case gdk.KEY_Up:
+ case key == gdk.KEY_Up:
// Do we have input? If we do, then we shouldn't touch it.
if f.textLen() > 0 {
return false
@@ -63,11 +66,33 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
return true
// There are multiple things to do here when we press the Escape key.
- case gdk.KEY_Escape:
+ case key == gdk.KEY_Escape:
// First, we'd want to cancel editing if we have one.
if f.editingID != "" {
return f.StopEditing() // always returns true
}
+
+ // Second... Nothing yet?
+
+ // Ctrl+V is paste.
+ case key == gdk.KEY_v && bithas(mask, cntrlMask):
+ // Is there an image in the clipboard?
+ if !gts.Clipboard.WaitIsImageAvailable() {
+ // No.
+ return false
+ }
+ // Yes.
+
+ p, err := gts.Clipboard.WaitForImage()
+ if err != nil {
+ log.Error(errors.Wrap(err, "Failed to get image from clipboard"))
+ return true // interrupt as technically valid
+ }
+
+ if err := f.Attachments.AddPixbuf(p); err != nil {
+ log.Error(errors.Wrap(err, "Failed to add image to attachment list"))
+ return true
+ }
}
// Passthrough.
diff --git a/internal/ui/messages/input/send.go b/internal/ui/messages/input/sendable.go
similarity index 75%
rename from internal/ui/messages/input/send.go
rename to internal/ui/messages/input/sendable.go
index 8ff94c9..3feb4be 100644
--- a/internal/ui/messages/input/send.go
+++ b/internal/ui/messages/input/sendable.go
@@ -10,11 +10,13 @@ import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log"
+ "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
"github.com/diamondburned/cchat/text"
"github.com/pkg/errors"
"github.com/twmb/murmur3"
)
+// globalID used for atomically generating nonces.
var globalID uint64
// generateNonce creates a nonce that should prevent collision. This function
@@ -38,10 +40,8 @@ func (f *Field) sendInput() {
return
}
- var text = f.yankText()
- if text == "" {
- return
- }
+ // Get the input text.
+ var text = f.getText()
// Are we editing anything?
if id := f.editingID; f.Editable(id) && id != "" {
@@ -55,6 +55,14 @@ func (f *Field) sendInput() {
return
}
+ // Get the attachments.
+ var attachments = f.Attachments.Files()
+
+ // Don't send if the message is empty.
+ if text == "" && len(attachments) == 0 {
+ return
+ }
+
f.SendMessage(SendMessageData{
time: time.Now().UTC(),
content: text,
@@ -62,7 +70,11 @@ func (f *Field) sendInput() {
authorID: f.UserID,
authorURL: f.Username.GetIconURL(),
nonce: f.generateNonce(),
+ files: attachments,
})
+
+ // Clear the input field after sending.
+ f.clearText()
}
func (f *Field) SendMessage(data PresendMessage) {
@@ -84,16 +96,21 @@ type SendMessageData struct {
authorID string
authorURL string // avatar
nonce string
+ files []attachment.File
}
type PresendMessage interface {
cchat.MessageHeader // returns nonce and time
cchat.SendableMessage
cchat.MessageNonce
+ cchat.SendableMessageAttachments
+
+ // These methods are reserved for internal use.
Author() text.Rich
AuthorID() string
AuthorAvatarURL() string // may be empty
+ Files() []attachment.File
}
var _ PresendMessage = (*SendMessageData)(nil)
@@ -126,3 +143,15 @@ func (s SendMessageData) AuthorAvatarURL() string {
func (s SendMessageData) Nonce() string {
return s.nonce
}
+
+func (s SendMessageData) Files() []attachment.File {
+ return s.files
+}
+
+func (s SendMessageData) Attachments() []cchat.MessageAttachment {
+ var attachments = make([]cchat.MessageAttachment, len(s.files))
+ for i, file := range s.files {
+ attachments[i] = file.AsAttachment()
+ }
+ return attachments
+}
diff --git a/internal/ui/messages/input/username/username.go b/internal/ui/messages/input/username/username.go
index 89ca69b..53e6b2e 100644
--- a/internal/ui/messages/input/username/username.go
+++ b/internal/ui/messages/input/username/username.go
@@ -37,9 +37,7 @@ var (
)
var usernameCSS = primitives.PrepareCSS(`
- .username-view {
- margin: 8px 10px;
- }
+ .username-view { margin: 0 5px }
`)
func NewContainer() *Container {
@@ -54,7 +52,6 @@ func NewContainer() *Container {
box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
box.PackStart(avatar, false, false, 0)
box.PackStart(label, false, false, 0)
- box.SetVAlign(gtk.ALIGN_START)
box.Show()
primitives.AddClass(box, "username-view")
diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go
index 0f0332e..12355f2 100644
--- a/internal/ui/messages/message/message.go
+++ b/internal/ui/messages/message/message.go
@@ -54,7 +54,10 @@ type GenericContainer struct {
Timestamp *gtk.Label
Username *gtk.Label
- Content *gtk.Label
+ Content gtk.IWidget // conceal widget implementation
+
+ contentBox *gtk.Box // basically what is in Content
+ ContentBody *gtk.Label
MenuItems []menu.Item
}
@@ -100,46 +103,54 @@ func NewEmptyContainer() *GenericContainer {
user.SetVAlign(gtk.ALIGN_START)
user.Show()
- content, _ := gtk.LabelNew("")
- content.SetLineWrap(true)
- content.SetLineWrapMode(pango.WRAP_WORD_CHAR)
- content.SetXAlign(0) // left align
- content.SetSelectable(true)
- content.Show()
+ ctbody, _ := gtk.LabelNew("")
+ ctbody.SetLineWrap(true)
+ ctbody.SetLineWrapMode(pango.WRAP_WORD_CHAR)
+ ctbody.SetXAlign(0) // left align
+ ctbody.SetSelectable(true)
+ ctbody.Show()
+
+ // Wrap the content label inside a content box.
+ ctbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
+ ctbox.PackStart(ctbody, false, false, 0)
+ ctbox.Show()
// Causes bugs with selections.
- // content.Connect("grab-notify", func(l *gtk.Label, grabbed bool) {
+ // ctbody.Connect("grab-notify", func(l *gtk.Label, grabbed bool) {
// if grabbed {
// // Hack to stop the label from selecting everything after being
// // refocused.
- // content.SetSelectable(false)
- // gts.ExecAsync(func() { content.SetSelectable(true) })
+ // ctbody.SetSelectable(false)
+ // gts.ExecAsync(func() { ctbody.SetSelectable(true) })
// }
// })
// Add CSS classes.
primitives.AddClass(ts, "message-time")
primitives.AddClass(user, "message-author")
- primitives.AddClass(content, "message-content")
+ primitives.AddClass(ctbody, "message-content")
// Attach the timestamp CSS.
primitives.AttachCSS(ts, timestampCSS)
gc := &GenericContainer{
- Timestamp: ts,
- Username: user,
- Content: content,
+ Timestamp: ts,
+ Username: user,
+ Content: ctbox,
+ contentBox: ctbox,
+ ContentBody: ctbody,
}
- gc.Content.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) {
+ // Bind the custom popup menu to the content label.
+ gc.ContentBody.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) {
menu.MenuSeparator(m)
menu.MenuItems(m, gc.MenuItems)
})
// Make up for the lack of inline images with an image popover that's shown
// when links are clicked.
- imgview.BindTooltip(gc.Content)
+ imgview.BindTooltip(gc.ContentBody)
return gc
}
@@ -190,7 +201,7 @@ func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
markup += " " + rich.Small("(edited)")
}
- m.Content.SetMarkup(markup)
+ m.ContentBody.SetMarkup(markup)
}
// AttachMenu connects signal handlers to handle a list of menu items from
diff --git a/internal/ui/messages/message/sending.go b/internal/ui/messages/message/sending.go
index e9120f6..28dbc48 100644
--- a/internal/ui/messages/message/sending.go
+++ b/internal/ui/messages/message/sending.go
@@ -1,9 +1,18 @@
package message
import (
+ "fmt"
"html"
+ "github.com/diamondburned/cchat-gtk/internal/humanize"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
+ "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment"
+ "github.com/gotk3/gotk3/gtk"
+ "github.com/gotk3/gotk3/pango"
+)
+
+var EmptyContentPlaceholder = fmt.Sprintf(
+ `%s`, html.EscapeString(""),
)
type PresendContainer interface {
@@ -16,7 +25,10 @@ type PresendContainer interface {
// implemented for stateful mutability of the generic message container.
type GenericPresendContainer struct {
*GenericContainer
- sendString string // to be cleared on SetDone()
+
+ // states; to be cleared on SetDone()
+ presend input.PresendMessage
+ uploads *attachment.MessageUploader
}
var _ PresendContainer = (*GenericPresendContainer)(nil)
@@ -33,7 +45,9 @@ func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *Generi
p := &GenericPresendContainer{
GenericContainer: c,
- sendString: msg.Content(),
+
+ presend: msg,
+ uploads: attachment.NewMessageUploader(msg.Files()),
}
p.SetLoading()
@@ -41,26 +55,85 @@ func WrapPresendContainer(c *GenericContainer, msg input.PresendMessage) *Generi
}
func (m *GenericPresendContainer) SetSensitive(sensitive bool) {
- m.Content.SetSensitive(sensitive)
+ m.contentBox.SetSensitive(sensitive)
}
func (m *GenericPresendContainer) SetDone(id string) {
+ // Apply the received ID.
m.id = id
+ // Set the sensitivity from false in SetLoading back to true.
m.SetSensitive(true)
- m.sendString = ""
- m.Content.SetTooltipText("")
+ // Reset the state to be normal. Especially setting presend to nil should
+ // free it from memory.
+ m.presend = nil
+ m.uploads = nil
+ m.contentBox.SetTooltipText("")
+
+ // Remove everything in the content box.
+ m.clearBox()
+
+ // Re-add the content label.
+ m.contentBox.Add(m.ContentBody)
}
func (m *GenericPresendContainer) SetLoading() {
m.SetSensitive(false)
- m.Content.SetText(m.sendString)
- m.Content.SetTooltipText("")
+ m.contentBox.SetTooltipText("")
- // m.CBuffer.SetText(m.sendString)
+ // Clear everything inside the content container.
+ m.clearBox()
+
+ // Add the content label.
+ m.contentBox.Add(m.ContentBody)
+
+ // Add the attachment progress box back in, if any.
+ if m.uploads != nil {
+ m.uploads.Show() // show the bars
+ m.contentBox.Add(m.uploads)
+ }
+
+ if content := m.presend.Content(); content != "" {
+ m.ContentBody.SetText(content)
+ } else {
+ // Use a placeholder content if the actual content is empty.
+ m.ContentBody.SetMarkup(EmptyContentPlaceholder)
+ }
}
func (m *GenericPresendContainer) SetSentError(err error) {
m.SetSensitive(true) // allow events incl right clicks
- m.Content.SetMarkup(`` + html.EscapeString(m.sendString) + ``)
- m.Content.SetTooltipText(err.Error())
+ m.contentBox.SetTooltipText(err.Error())
+
+ // Remove everything again.
+ m.clearBox()
+
+ // Re-add the label.
+ m.contentBox.Add(m.ContentBody)
+
+ // Style the label appropriately by making it red.
+ var content = html.EscapeString(m.presend.Content())
+ if content == "" {
+ content = EmptyContentPlaceholder
+ }
+ m.ContentBody.SetMarkup(fmt.Sprintf(`%s`, content))
+
+ // Add a smaller label indicating an error.
+ errl, _ := gtk.LabelNew("")
+ errl.SetXAlign(0)
+ errl.SetLineWrap(true)
+ errl.SetLineWrapMode(pango.WRAP_WORD_CHAR)
+ errl.SetMarkup(fmt.Sprintf(
+ `Error: %s`,
+ html.EscapeString(humanize.Error(err)),
+ ))
+
+ errl.Show()
+ m.contentBox.Add(errl)
+}
+
+// clearBox clears everything inside the content container.
+func (m *GenericPresendContainer) clearBox() {
+ m.contentBox.GetChildren().Foreach(func(v interface{}) {
+ m.contentBox.Remove(v.(gtk.IWidget))
+ })
}
diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go
index 0be9db0..f234291 100644
--- a/internal/ui/primitives/primitives.go
+++ b/internal/ui/primitives/primitives.go
@@ -83,6 +83,17 @@ func AddClass(styleCtx StyleContexter, classes ...string) {
}
}
+type StyleContextFocuser interface {
+ StyleContexter
+ GrabFocus()
+}
+
+// SuggestAction styles the element to have the suggeested action class.
+func SuggestAction(styleCtx StyleContextFocuser) {
+ AddClass(styleCtx, "suggested-action")
+ styleCtx.GrabFocus()
+}
+
type Bin interface {
GetChild() (gtk.IWidget, error)
}
@@ -241,11 +252,7 @@ func PrepareCSS(css string) *gtk.CssProvider {
return p
}
-type StyleContextGetter interface {
- GetStyleContext() (*gtk.StyleContext, error)
-}
-
-func AttachCSS(ctx StyleContextGetter, prov *gtk.CssProvider) {
+func AttachCSS(ctx StyleContexter, prov *gtk.CssProvider) {
s, _ := ctx.GetStyleContext()
s.AddProvider(prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
}
diff --git a/internal/ui/service/auth/auth.go b/internal/ui/service/auth/auth.go
index 38f7fa9..ba313b6 100644
--- a/internal/ui/service/auth/auth.go
+++ b/internal/ui/service/auth/auth.go
@@ -11,7 +11,7 @@ import (
)
type Dialog struct {
- *gtk.Dialog
+ *dialog.Modal
Auther cchat.Authenticator
onAuth func(cchat.Session)
@@ -58,8 +58,8 @@ func NewDialog(name text.Rich, auther cchat.Authenticator, auth func(cchat.Sessi
body: box,
label: label,
}
- d.Dialog = dialog.NewModal(stack, "Log in to "+name.Content, "Log in", d.ok)
- d.Dialog.SetDefaultSize(400, 300)
+ d.Modal = dialog.NewModal(stack, "Log in to "+name.Content, "Log in", d.ok)
+ d.Modal.SetDefaultSize(400, 300)
d.spin(nil)
d.Show()
@@ -93,7 +93,7 @@ func (d *Dialog) spin(err error) {
d.body.Add(d.request)
}
-func (d *Dialog) ok() {
+func (d *Dialog) ok(m *dialog.Modal) {
// Disable the buttons.
d.Dialog.SetSensitive(false)
diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go
index ccd60fc..cb03361 100644
--- a/internal/ui/service/session/session.go
+++ b/internal/ui/service/session/session.go
@@ -77,6 +77,7 @@ func newRow(parent breadcrumb.Breadcrumber, name text.Rich, ctrl Controller) *Ro
// Make a commander button that's hidden by default in case.
cmdbtn, _ := gtk.ButtonNewFromIconName("utilities-terminal-symbolic", gtk.ICON_SIZE_BUTTON)
buttonoverlay.Take(srow.Button, cmdbtn, server.IconSize)
+ primitives.AddClass(cmdbtn, "command-button")
row := &Row{
Row: srow,