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:
parent
be88670bb6
commit
2422a90467
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue