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,