1
0
Fork 0
mirror of https://github.com/diamondburned/cchat-gtk.git synced 2025-03-21 09:29:20 +00:00

Added message editing

BUG: gtk_box_pack: assertion _gtk_widget_get_parent (child) == NULL failed when editing
This commit is contained in:
diamondburned (Forefront) 2020-06-17 15:58:38 -07:00
parent be88670bb6
commit 2422a90467
9 changed files with 224 additions and 61 deletions

View file

@ -6,6 +6,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/messages/autoscroll"
"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/service/menu"
"github.com/gotk3/gotk3/gtk"
)
@ -18,7 +19,7 @@ type GridMessage interface {
// Attach should only be called once.
Attach(grid *gtk.Grid, row int)
// AttachMenu should override the stored constructor.
AttachMenu(constructor func() []gtk.IMenuItem) // save memory
AttachMenu(items []menu.Item) // save memory
}
func AttachRow(grid *gtk.Grid, row int, widgets ...gtk.IWidget) {
@ -50,6 +51,8 @@ type Container interface {
// AddPresendMessage adds and displays an unsent message.
AddPresendMessage(msg input.PresendMessage) PresendGridMessage
// LatestMessageFrom returns the last message ID with that author.
LatestMessageFrom(authorID string) (msgID string, ok bool)
}
// Controller is for menu actions.

View file

@ -11,6 +11,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/messages/message"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"github.com/diamondburned/imgutil"
"github.com/gotk3/gotk3/gtk"
)
@ -129,7 +130,7 @@ func (m *FullMessage) Attach(grid *gtk.Grid, row int) {
container.AttachRow(grid, row, m.Avatar, m.MainBox)
}
func (m *FullMessage) AttachMenu(items func() []gtk.IMenuItem) {
func (m *FullMessage) AttachMenu(items []menu.Item) {
// Bind to parent's container as well.
m.GenericContainer.AttachMenu(items)

View file

@ -115,11 +115,32 @@ func (c *GridStore) getOffsetted(id string, offset int) GridMessage {
return c.messages[c.messageIDs[ix]].GridMessage
}
// LatestMessageFrom returns the latest message with the given user ID. This is
// used for the input prompt.
func (c *GridStore) LatestMessageFrom(userID string) (msgID string, ok bool) {
// FindMessage already looks from the latest messages.
var msg = c.FindMessage(func(msg GridMessage) bool {
return msg.AuthorID() == userID
})
if msg == nil {
return "", false
}
return msg.ID(), true
}
// FindMessage iterates backwards and returns the message if isMessage() returns
// true on that message.
func (c *GridStore) FindMessage(isMessage func(msg GridMessage) bool) GridMessage {
for i := len(c.messageIDs) - 1; i >= 0; i-- {
if msg := c.messages[c.messageIDs[i]].GridMessage; isMessage(msg) {
msg := c.messages[c.messageIDs[i]]
// Ignore sending messages.
if msg.presend != nil {
continue
}
// Check.
if msg := msg.GridMessage; isMessage(msg) {
return msg
}
}

View file

@ -2,13 +2,16 @@ package input
import (
"github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion"
"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 {
@ -68,8 +71,12 @@ type Field struct {
UserID string
Sender cchat.ServerMessageSender
editor cchat.ServerMessageEditor
ctrl Controller
// editing state
editingID string // never empty
}
const inputmargin = 4
@ -115,6 +122,7 @@ func (f *Field) Reset() {
f.UserID = ""
f.Sender = nil
f.editor = nil
f.username.Reset()
// reset the input
@ -126,14 +134,60 @@ func (f *Field) Reset() {
func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSender) {
// Update the left username container in the input.
f.username.Update(session, sender)
f.UserID = session.ID()
// Set the sender.
if sender != nil {
f.Sender = sender
f.text.SetSensitive(true)
// Do we support message editing?
if editor, ok := sender.(cchat.ServerMessageEditor); ok {
f.editor = editor
}
}
}
// Editable returns whether or not the input field has editing capabilities.
func (f *Field) Editable() bool {
return f.editor != nil
}
func (f *Field) StartEditing(msgID string) bool {
// Do we support message editing? If not, exit.
if !f.Editable() {
return false
}
// Try and request the old message content for editing.
content, err := f.editor.RawMessageContent(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
}
// yankText cuts the text from the input field and returns it.
func (f *Field) yankText() string {
start, end := f.buffer.GetBounds()
@ -145,3 +199,19 @@ func (f *Field) yankText() string {
return text
}
// clearText wipes the input field
func (f *Field) clearText() {
f.buffer.Delete(f.buffer.GetBounds())
}
// 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()
}

View file

@ -31,8 +31,43 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool {
}
// Else, send the message.
f.SendInput()
f.sendInput()
return true
// If Arrow Up is pressed, then we might want to edit the latest message if
// any.
case gdk.KEY_Up:
// We don't support message editing, so passthrough events.
if !f.Editable() {
return false
}
// Do we have input? If we do, then we shouldn't touch it.
if f.textLen() > 0 {
return false
}
// Try and find the latest message ID that is ours.
id, ok := f.ctrl.LatestMessageFrom(f.UserID)
if !ok {
// No messages found, so we can passthrough normally.
return false
}
// Start editing.
f.StartEditing(id)
// TODO: add a visible indicator to indicate a message being edited.
// Take the event.
return true
// There are multiple things to do here when we press the Escape key.
case gdk.KEY_Escape:
// First, we'd want to cancel editing if we have one.
if f.editingID != "" {
return f.StopEditing() // always returns true
}
}
// Passthrough.

View file

@ -1,7 +1,7 @@
package input
import (
"strconv"
"fmt"
"sync/atomic"
"time"
@ -14,9 +14,15 @@ import (
var globalID uint64
// SendInput yanks the text from the input field and sends it to the backend.
// This function is not thread-safe.
func (f *Field) SendInput() {
// generateNonce creates a nonce that should prevent collision
func (f *Field) generateNonce() string {
return fmt.Sprintf(
"cchat-gtk/%s/%X/%X",
f.UserID, time.Now().UnixNano(), atomic.AddUint64(&globalID, 1),
)
}
func (f *Field) sendInput() {
if f.Sender == nil {
return
}
@ -26,13 +32,25 @@ func (f *Field) SendInput() {
return
}
// Are we editing anything?
if id := f.editingID; f.Editable() && id != "" {
go func() {
if err := f.editor.EditMessage(id, text); err != nil {
log.Error(errors.Wrap(err, "Failed to edit message"))
}
}()
f.StopEditing()
return
}
f.SendMessage(SendMessageData{
time: time.Now(),
content: text,
author: f.username.GetLabel(),
authorID: f.UserID,
authorURL: f.username.GetIconURL(),
nonce: "__cchat-gtk_" + strconv.FormatUint(atomic.AddUint64(&globalID, 1), 10),
nonce: f.generateNonce(),
})
}

View file

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"github.com/diamondburned/cchat/text"
"github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
@ -54,7 +55,7 @@ type GenericContainer struct {
Username *gtk.Label
Content *gtk.Label
MenuItems func() []gtk.IMenuItem
MenuItems []menu.Item
}
var _ Container = (*GenericContainer)(nil)
@ -109,19 +110,16 @@ func NewEmptyContainer() *GenericContainer {
Timestamp: ts,
Username: user,
Content: content,
MenuItems: func() []gtk.IMenuItem { return nil },
}
gc.Content.Connect("populate-popup", func(l *gtk.Label, menu *gtk.Menu) {
gc.Content.Connect("populate-popup", func(l *gtk.Label, m *gtk.Menu) {
// Add a menu separator before we add our custom stuff.
sep, _ := gtk.SeparatorMenuItemNew()
sep.Show()
menu.Append(sep)
m.Append(sep)
// Append the new items after the separator.
for _, item := range gc.MenuItems() {
menu.Append(item)
}
menu.LoadItems(m, gc.MenuItems)
})
return gc
@ -178,6 +176,6 @@ func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) {
// AttachMenu connects signal handlers to handle a list of menu items from
// the container.
func (m *GenericContainer) AttachMenu(newItems func() []gtk.IMenuItem) {
func (m *GenericContainer) AttachMenu(newItems []menu.Item) {
m.MenuItems = newItems
}

View file

@ -11,7 +11,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/messages/container/cozy"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/input"
"github.com/diamondburned/cchat-gtk/internal/ui/messages/sadface"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/service/menu"
"github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors"
)
@ -154,17 +154,20 @@ func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) {
presend.SetSentError(err)
// Only attach the menu once. Further retries do not need to be
// reattached.
presend.AttachMenu(func() []gtk.IMenuItem {
return []gtk.IMenuItem{
primitives.MenuItem("Retry", func() {
presend.SetLoading()
v.retryMessage(msg, presend)
}),
}
presend.AttachMenu([]menu.Item{
menu.SimpleItem("Retry", func() {
presend.SetLoading()
v.retryMessage(msg, presend)
}),
})
}
}
// LatestMessageFrom returns the last message ID with that author.
func (v *View) LatestMessageFrom(userID string) (msgID string, ok bool) {
return v.Container.LatestMessageFrom(userID)
}
// retryMessage sends the message.
func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendGridMessage) {
var sender = v.InputView.Sender
@ -184,29 +187,33 @@ func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendG
// BindMenu attaches the menu constructor into the message with the needed
// states and callbacks.
func (v *View) BindMenu(msg container.GridMessage) {
// Don't bind anything if we don't have anything.
if !v.state.hasActions() {
return
// Add 1 for the edit menu item.
var mitems = make([]menu.Item, 0, len(v.state.actions)+1)
// Do we have editing capabilities? If yes, append a button to allow it.
if v.InputView.Editable() {
mitems = append(mitems, menu.SimpleItem(
"Edit", func() { v.InputView.StartEditing(msg.ID()) },
))
}
msg.AttachMenu(func() []gtk.IMenuItem {
var mitems = make([]gtk.IMenuItem, len(v.state.actions))
for i, action := range v.state.actions {
mitems[i] = primitives.MenuItem(action, v.menuItemActivate(msg.ID()))
}
return mitems
})
// Do we have any custom actions? If yes, append it.
for _, action := range v.state.actions {
mitems = append(mitems, v.makeActionItem(action, msg.ID()))
}
msg.AttachMenu(mitems)
}
// menuItemActivate creates a new callback that's called on menu item
// makeActionItem creates a new menu callback that's called on menu item
// activation.
func (v *View) menuItemActivate(msgID string) func(m *gtk.MenuItem) {
return func(m *gtk.MenuItem) {
go func(action string) {
func (v *View) makeActionItem(action, msgID string) menu.Item {
return menu.SimpleItem(action, func() {
go func() {
// Run, get the error, and try to log it. The logger will ignore nil
// errors.
err := v.state.actioner.DoMessageAction(action, msgID)
log.Error(errors.Wrap(err, "Failed to do action "+action))
}(m.GetLabel())
}
}()
})
}

View file

@ -1,5 +1,7 @@
package menu
// TODO: move this package outside service
import (
"github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
@ -30,34 +32,30 @@ func (m *LazyMenu) Reset() {
m.items = nil
}
func (m *LazyMenu) popup(w gtk.IWidget, ev *gdk.Event) {
// Is this a right click? Exit if not.
if !gts.EventIsRightClick(ev) {
return
}
func (m *LazyMenu) PopupAtPointer(ev *gdk.Event) {
// Do nothing if there are no menu items.
if len(m.items) == 0 {
return
}
var menu, _ = gtk.MenuNew()
for _, item := range m.items {
mb, _ := gtk.MenuItemNewWithLabel(item.Name)
mb.Connect("activate", item.Func)
mb.Show()
if item.Extra != nil {
item.Extra(mb)
}
menu.Append(mb)
}
menu, _ := gtk.MenuNew()
LoadItems(menu, m.items)
menu.PopupAtPointer(ev)
}
func (m *LazyMenu) popup(w gtk.IWidget, ev *gdk.Event) {
// Is this a right click? Run the menu if yes.
if gts.EventIsRightClick(ev) {
m.PopupAtPointer(ev)
}
}
func LoadItems(menu *gtk.Menu, items []Item) {
for _, item := range items {
menu.Append(item.ToMenuItem())
}
}
type Item struct {
Name string
Func func()
@ -67,3 +65,15 @@ type Item struct {
func SimpleItem(name string, fn func()) Item {
return Item{Name: name, Func: fn}
}
func (item Item) ToMenuItem() *gtk.MenuItem {
mb, _ := gtk.MenuItemNewWithLabel(item.Name)
mb.Connect("activate", item.Func)
mb.Show()
if item.Extra != nil {
item.Extra(mb)
}
return mb
}