mirror of
https://github.com/diamondburned/cchat-gtk.git
synced 2024-09-07 18:56:21 +00:00
Added attachments support
This commit is contained in:
parent
3f06e53e1d
commit
e35837ee2b
17
PLAN.md
Normal file
17
PLAN.md
Normal 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
9
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
|
||||
|
|
6
go.sum
6
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=
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
345
internal/ui/messages/input/attachment/attachment.go
Normal file
345
internal/ui/messages/input/attachment/attachment.go
Normal 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
|
||||
}
|
116
internal/ui/messages/input/attachment/progress.go
Normal file
116
internal/ui/messages/input/attachment/progress.go
Normal 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
|
||||
}
|
42
internal/ui/messages/input/attachment/reusable_reader.go
Normal file
42
internal/ui/messages/input/attachment/reusable_reader.go
Normal 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
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
`<span alpha="25%%">%s</span>`, html.EscapeString("<empty>"),
|
||||
)
|
||||
|
||||
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(`<span color="red">` + html.EscapeString(m.sendString) + `</span>`)
|
||||
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(`<span color="red">%s</span>`, 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(
|
||||
`<span size="small" color="red"><b>Error:</b> %s</span>`,
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue