Added attachments support

This commit is contained in:
diamondburned 2020-07-10 16:26:07 -07:00
parent 3f06e53e1d
commit e35837ee2b
23 changed files with 943 additions and 155 deletions

17
PLAN.md Normal file
View File

@ -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.

9
go.mod
View File

@ -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

6
go.sum
View File

@ -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=

View File

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

View File

@ -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)) {

View File

@ -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()

View File

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

View File

@ -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 {

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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.

View File

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

View File

@ -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")

View File

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