mirror of
https://github.com/diamondburned/cchat-gtk.git
synced 2025-11-27 23:05:46 +00:00
This commit uses the GtkFileChooserNative over Gtk's own. It also slightly refactors the message container API and fixes minor UI appearances, especially adding the separator.
326 lines
8.6 KiB
Go
326 lines
8.6 KiB
Go
package input
|
|
|
|
import (
|
|
"time"
|
|
|
|
"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/username"
|
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
|
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion"
|
|
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput"
|
|
"github.com/gotk3/gotk3/gtk"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
// Controller is an interface to control message containers.
|
|
type Controller interface {
|
|
AddPresendMessage(msg PresendMessage) (onErr func(error))
|
|
LatestMessageFrom(userID string) (messageID string, ok bool)
|
|
}
|
|
|
|
type InputView struct {
|
|
*Field
|
|
Completer *completion.Completer
|
|
}
|
|
|
|
var textCSS = primitives.PrepareCSS(`
|
|
.message-input {
|
|
padding-top: 2px;
|
|
padding-bottom: 2px;
|
|
}
|
|
|
|
.message-input, .message-input * {
|
|
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
|
|
}
|
|
|
|
.message-input * {
|
|
transition: linear 50ms background-color;
|
|
border: 1px solid alpha(@theme_fg_color, 0.2);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.message-input:focus * {
|
|
border-color: @theme_selected_bg_color;
|
|
}
|
|
`)
|
|
|
|
var inputBoxCSS = primitives.PrepareClassCSS("input-box", `
|
|
.input-box {
|
|
background-color: @theme_bg_color;
|
|
}
|
|
`)
|
|
|
|
func NewView(ctrl Controller) *InputView {
|
|
text, _ := gtk.TextViewNew()
|
|
text.SetSensitive(false)
|
|
text.SetWrapMode(gtk.WRAP_WORD_CHAR)
|
|
text.SetVAlign(gtk.ALIGN_START)
|
|
text.SetProperty("top-margin", 4)
|
|
text.SetProperty("bottom-margin", 4)
|
|
text.SetProperty("left-margin", 8)
|
|
text.SetProperty("right-margin", 8)
|
|
text.SetProperty("monospace", true)
|
|
text.Show()
|
|
|
|
primitives.AddClass(text, "message-input")
|
|
primitives.AttachCSS(text, textCSS)
|
|
|
|
// Bind the text event handler to text first.
|
|
c := completion.NewCompleter(text)
|
|
|
|
// Bind the input callback later.
|
|
f := NewField(text, ctrl)
|
|
f.Show()
|
|
|
|
return &InputView{f, c}
|
|
}
|
|
|
|
func (v *InputView) SetMessenger(session cchat.Session, messenger cchat.Messenger) {
|
|
v.Field.SetMessenger(session, messenger)
|
|
|
|
if messenger == nil {
|
|
return
|
|
}
|
|
|
|
// Ignore ok; completer can be nil.
|
|
// TODO: this is possibly racy vs the above SetMessenger.
|
|
if sender := messenger.AsSender(); sender != nil {
|
|
v.Completer.SetCompleter(sender.AsCompleter())
|
|
}
|
|
}
|
|
|
|
// wrapSpellCheck is a no-op but is replaced by gspell in ./spellcheck.go.
|
|
var wrapSpellCheck = func(textView *gtk.TextView) {}
|
|
|
|
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 // const
|
|
buffer *gtk.TextBuffer // const
|
|
|
|
send *gtk.Button
|
|
attach *gtk.Button
|
|
|
|
ctrl Controller
|
|
|
|
// Embed a state field which allows us to easily reset it.
|
|
fieldState
|
|
}
|
|
|
|
type fieldState struct {
|
|
UserID string
|
|
Messenger cchat.Messenger
|
|
Sender cchat.Sender
|
|
upload bool // true if server supports files
|
|
editor cchat.Editor
|
|
typing cchat.TypingIndicator
|
|
|
|
editingID string // never empty
|
|
lastTyped time.Time
|
|
typerDura time.Duration
|
|
}
|
|
|
|
func (s *fieldState) Reset() {
|
|
*s = fieldState{}
|
|
}
|
|
|
|
var inputFieldCSS = primitives.PrepareClassCSS("input-field", `
|
|
.input-field {
|
|
margin: 3px 5px;
|
|
margin-top: 1px;
|
|
}
|
|
`)
|
|
|
|
var scrolledInputCSS = primitives.PrepareClassCSS("scrolled-input", `
|
|
.scrolled-input {
|
|
margin: 0 5px;
|
|
}
|
|
`)
|
|
|
|
func NewField(text *gtk.TextView, ctrl Controller) *Field {
|
|
field := &Field{text: text, ctrl: ctrl}
|
|
field.buffer, _ = text.GetBuffer()
|
|
|
|
field.Username = username.NewContainer()
|
|
field.Username.Show()
|
|
|
|
field.TextScroll = scrollinput.NewV(text, 150)
|
|
field.TextScroll.Show()
|
|
scrolledInputCSS(field.TextScroll)
|
|
|
|
field.attach, _ = gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON)
|
|
field.attach.SetRelief(gtk.RELIEF_NONE)
|
|
field.attach.SetSensitive(false)
|
|
// Only show this if the server supports it (upload == true).
|
|
primitives.AddClass(field.attach, "attach-button")
|
|
|
|
field.send, _ = gtk.ButtonNewFromIconName("mail-send-symbolic", gtk.ICON_SIZE_BUTTON)
|
|
field.send.SetRelief(gtk.RELIEF_NONE)
|
|
field.send.Show()
|
|
primitives.AddClass(field.send, "send-button")
|
|
|
|
field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
|
|
field.FieldBox.PackStart(field.Username, false, false, 0)
|
|
field.FieldBox.PackStart(field.attach, false, false, 0)
|
|
field.FieldBox.PackStart(field.TextScroll, true, true, 0)
|
|
field.FieldBox.PackStart(field.send, false, false, 0)
|
|
field.FieldBox.Show()
|
|
inputFieldCSS(field.FieldBox)
|
|
|
|
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()
|
|
inputBoxCSS(field.Box)
|
|
|
|
text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment())
|
|
text.SetFocusVAdjustment(field.TextScroll.GetVAdjustment())
|
|
// Bind text events.
|
|
text.Connect("key-press-event", field.keyDown)
|
|
// Bind the send button.
|
|
field.send.Connect("clicked", field.sendInput)
|
|
// Bind the attach button.
|
|
field.attach.Connect("clicked", func() { gts.SpawnUploader("", field.Attachments.AddFiles) })
|
|
|
|
// 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 + field.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. The View should already change to a different stack, but we're
|
|
// doing this just in case.
|
|
f.text.SetSensitive(false)
|
|
|
|
f.fieldState.Reset()
|
|
f.Username.Reset()
|
|
|
|
// reset the input
|
|
f.clearText()
|
|
}
|
|
|
|
// SetMessenger changes the messenger of the input field. If nil, the input
|
|
// will be disabled. Reset() should be called first.
|
|
func (f *Field) SetMessenger(session cchat.Session, messenger cchat.Messenger) {
|
|
// Update the left username container in the input.
|
|
f.Username.Update(session, messenger)
|
|
f.UserID = session.ID()
|
|
|
|
// Set the sender.
|
|
if messenger != nil {
|
|
f.Messenger = messenger
|
|
f.Sender = messenger.AsSender()
|
|
f.text.SetSensitive(true)
|
|
|
|
// Allow editor to be nil.
|
|
f.editor = f.Messenger.AsEditor()
|
|
// Allow typer to be nil.
|
|
f.typing = f.Messenger.AsTypingIndicator()
|
|
|
|
// See if we can upload files.
|
|
f.SetAllowUpload(f.Sender != nil && f.Sender.CanAttach())
|
|
|
|
// Populate the duration state if typer is not nil.
|
|
if f.typing != nil {
|
|
f.typerDura = f.typing.TypingTimeout()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *Field) SetAllowUpload(allow bool) {
|
|
f.upload = allow
|
|
|
|
// Don't allow clicks on the attachment button if allow is false.
|
|
f.attach.SetSensitive(allow)
|
|
// Disable the attachmetn carousel for good measure, which also prevents
|
|
// drag-and-drops.
|
|
f.Attachments.SetEnabled(allow)
|
|
|
|
// Show the attachment button if we can, else hide it.
|
|
if f.upload {
|
|
f.attach.Show()
|
|
} else {
|
|
f.attach.Hide()
|
|
}
|
|
}
|
|
|
|
// Editable returns whether or not the input field can be edited.
|
|
func (f *Field) Editable(msgID string) bool {
|
|
return f.editor != nil && f.editor.IsEditable(msgID)
|
|
}
|
|
|
|
func (f *Field) StartEditing(msgID string) bool {
|
|
// Do we support message editing? If not, exit.
|
|
if !f.Editable(msgID) {
|
|
return false
|
|
}
|
|
|
|
// Try and request the old message content for editing.
|
|
content, err := f.editor.RawContent(msgID)
|
|
if err != nil {
|
|
// TODO: show error
|
|
log.Error(errors.Wrap(err, "Failed to get message content"))
|
|
return false
|
|
}
|
|
|
|
// Set the current editing state and set the input after requesting the
|
|
// content.
|
|
f.editingID = msgID
|
|
f.buffer.SetText(content)
|
|
|
|
return true
|
|
}
|
|
|
|
// StopEditing cancels the current editing message. It returns a false and does
|
|
// nothing if the editor is not editing anything.
|
|
func (f *Field) StopEditing() bool {
|
|
if f.editingID == "" {
|
|
return false
|
|
}
|
|
|
|
f.editingID = ""
|
|
f.clearText()
|
|
|
|
return true
|
|
}
|
|
|
|
// 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.
|
|
func (f *Field) getText() string {
|
|
start, end := f.buffer.GetBounds()
|
|
text, _ := f.buffer.GetText(start, end, false)
|
|
return text
|
|
}
|
|
|
|
func (f *Field) textLen() int {
|
|
return f.buffer.GetCharCount()
|
|
}
|