diff --git a/TODO.md b/TODO.md index 341f267f..815658fe 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,7 @@ +- Message selection + - Make scroll wheel usable + - Copying messages (menu & shortcut) + - Refactoring - Banners - Composer diff --git a/src/qml/Base/HListView.qml b/src/qml/Base/HListView.qml index f1a66802..1a778dd0 100644 --- a/src/qml/Base/HListView.qml +++ b/src/qml/Base/HListView.qml @@ -3,6 +3,7 @@ import QtQuick.Controls 2.12 ListView { id: listView + interactive: enableFlicking currentIndex: -1 keyNavigationWraps: true highlightMoveDuration: theme.animationDuration @@ -13,6 +14,8 @@ ListView { preferredHighlightEnd: height / 2 + currentItemHeight + property bool enableFlicking: true + readonly property int currentItemHeight: currentItem ? currentItem.height : 0 @@ -21,7 +24,9 @@ ListView { color: theme.controls.listView.highlight } - ScrollBar.vertical: ScrollBar { visible: listView.interactive } + ScrollBar.vertical: ScrollBar { + visible: listView.interactive || ! listView.enableFlicking + } add: Transition { ParallelAnimation { @@ -45,6 +50,5 @@ ListView { } } - populate: add displaced: move } diff --git a/src/qml/Base/HSelectableLabel.qml b/src/qml/Base/HSelectableLabel.qml new file mode 100644 index 00000000..d1e9675f --- /dev/null +++ b/src/qml/Base/HSelectableLabel.qml @@ -0,0 +1,119 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 + +TextEdit { + id: label + font.family: theme.fontFamily.sans + font.pixelSize: theme.fontSize.normal + color: theme.colors.text + + textFormat: Label.PlainText + tabStopDistance: 4 * 4 // 4 spaces + + readOnly: true + persistentSelection: true + + onLinkActivated: Qt.openUrlExternally(link) + + + property HSelectableLabelContainer container + property int index + + + function updateSelection() { + if (! container.reversed && + container.selectionStart <= container.selectionEnd || + + container.reversed && + container.selectionStart > container.selectionEnd) + { + var first = container.selectionStart + var firstPos = container.selectionStartPosition + var last = container.selectionEnd + var lastPos = container.selectionEndPosition + } else { + var first = container.selectionEnd + var firstPos = container.selectionEndPosition + var last = container.selectionStart + var lastPos = container.selectionStartPosition + } + + if (first == index && last == index) { + select( + label.positionAt(firstPos.x, firstPos.y), + label.positionAt(lastPos.x, lastPos.y), + ) + + } else if ((! container.reversed && first < index && index < last) || + (container.reversed && first > index && index > last)) + { + label.selectAll() + + } else if (first == index) { + label.select(positionAt(firstPos.x, firstPos.y), length) + + } else if (last == index) { + label.select(0, positionAt(lastPos.x, lastPos.y)) + + } else { + label.deselect() + } + + updateContainerSelectedTexts() + } + + function updateContainerSelectedTexts() { + container.selectedTexts[index] = selectedText + container.selectedTextsChanged() + } + + function selectWordAt(position) { + container.clearSelection() + label.cursorPosition = positionAt(position.x, position.y) + label.selectWord() + updateContainerSelectedTexts() + } + + function selectAllText() { + container.clearSelection() + label.selectAll() + updateContainerSelectedTexts() + } + + + Connections { + target: container + onSelectionInfoChanged: updateSelection() + onDeselectAll: deselect() + } + + DropArea { + anchors.fill: parent + onPositionChanged: { + if (! container.selecting) { + container.clearSelection() + container.selectionStart = index + container.selectionStartPosition = Qt.point(drag.x, drag.y) + container.selecting = true + } else { + container.selectionEnd = index + container.selectionEndPosition = Qt.point(drag.x, drag.y) + } + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: { + tapCount == 2 ? selectWordAt(eventPoint.position) : + tapCount == 3 ? selectAllText() : + container.clearSelection() + } + } + + MouseArea { + anchors.fill: label + acceptedButtons: Qt.NoButton + cursorShape: label.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor + } +} diff --git a/src/qml/Base/HSelectableLabelContainer.qml b/src/qml/Base/HSelectableLabelContainer.qml new file mode 100644 index 00000000..12559513 --- /dev/null +++ b/src/qml/Base/HSelectableLabelContainer.qml @@ -0,0 +1,72 @@ +import QtQuick 2.12 + +Item { + signal deselectAll() + + + property bool reversed: false + + readonly property bool dragging: pointHandler.active || dragHandler.active + // onDraggingChanged: print(dragging) + property bool selecting: false + property int selectionStart: -1 + property int selectionEnd: -1 + property point selectionStartPosition: Qt.point(-1, -1) + property point selectionEndPosition: Qt.point(-1, -1) + property var selectedTexts: ({}) + + readonly property var selectionInfo: [ + selectionStart, selectionStartPosition, + selectionEnd, selectionEndPosition, + ] + + readonly property alias dragPoint: dragHandler.centroid + readonly property alias dragPosition: dragHandler.centroid.position + + + function clearSelection() { + selecting = false + selectionStart = -1 + selectionEnd = -1 + selectionStartPosition = Qt.point(-1, -1) + selectionEndPosition = Qt.point(-1, -1) + deselectAll() + } + + function copySelection() { + let toCopy = [] + + for (let key of Object.keys(selectedTexts).sort()) { + if (selectedTexts[key]) toCopy.push(selectedTexts[key]) + } + + // Call some function to copy to clipboard here instead + print("Copy: <" + toCopy.join("\n\n") + ">") + } + + + Item { id: dragPoint } + + DragHandler { + id: dragHandler + target: dragPoint + onActiveChanged: { + if (active) { + target.Drag.active = true + } else { + target.Drag.drop() + target.Drag.active = false + selecting = false + } + } + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: clearSelection() + } + + PointHandler { + id: pointHandler + } +} diff --git a/src/qml/Chat/Timeline/EventContent.qml b/src/qml/Chat/Timeline/EventContent.qml index 70abd599..3faa1c8b 100644 --- a/src/qml/Chat/Timeline/EventContent.qml +++ b/src/qml/Chat/Timeline/EventContent.qml @@ -8,15 +8,6 @@ Row { spacing: theme.spacing / 2 readonly property string eventText: Utils.processedEventText(model) - readonly property real lineHeight: - ! eventText.match(//) && multiline ? 1.25 : 1.0 - readonly property bool multiline: - (eventText.match(/(\n|)/) || []).length > 0 || - contentLabel.contentWidth < ( - contentLabel.implicitWidth - - contentLabel.leftPadding - - contentLabel.rightPadding - ) Item { width: hideAvatar ? 0 : 48 @@ -64,16 +55,17 @@ Row { textFormat: Text.StyledText elide: Text.ElideRight horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft - lineHeight: eventContent.lineHeight leftPadding: theme.spacing rightPadding: leftPadding topPadding: theme.spacing / 2 * lineHeight } - HRichLabel { + HSelectableLabel { id: contentLabel width: parent.width + container: selectableLabelContainer + index: model.index text: theme.chat.message.styleInclude + eventContent.eventText + @@ -87,14 +79,14 @@ Row { " " : "") - lineHeight: eventContent.lineHeight color: theme.chat.message.body wrapMode: Text.Wrap + textFormat: Text.RichText leftPadding: theme.spacing rightPadding: leftPadding topPadding: nameLabel.visible ? 0 : bottomPadding - bottomPadding: theme.spacing / 2 * lineHeight + bottomPadding: theme.spacing / 2 } } } diff --git a/src/qml/Chat/Timeline/EventList.qml b/src/qml/Chat/Timeline/EventList.qml index 69ec24a3..c7d2b896 100644 --- a/src/qml/Chat/Timeline/EventList.qml +++ b/src/qml/Chat/Timeline/EventList.qml @@ -7,95 +7,149 @@ Rectangle { color: theme.chat.eventList.background - HListView { - id: eventList - clip: true - Component.onCompleted: shortcuts.flickTarget = eventList - - function canCombine(item, itemAfter) { - if (! item || ! itemAfter) return false - - return Boolean( - ! canTalkBreak(item, itemAfter) && - ! canDayBreak(item, itemAfter) && - item.sender_id === itemAfter.sender_id && - Utils.minutesBetween(item.date, itemAfter.date) <= 5 - ) - } - - function canTalkBreak(item, itemAfter) { - if (! item || ! itemAfter) return false - - return Boolean( - ! canDayBreak(item, itemAfter) && - Utils.minutesBetween(item.date, itemAfter.date) >= 20 - ) - } - - function canDayBreak(item, itemAfter) { - if (itemAfter && itemAfter.event_type == "RoomCreateEvent") - return true - - if (! item || ! itemAfter || ! item.date || ! itemAfter.date) - return false - - return item.date.getDate() != itemAfter.date.getDate() - } - - model: HListModel { - keyField: "client_id" - source: - modelSources[["Event", chatPage.userId, chatPage.roomId]] || [] - } - - property bool ownEventsOnRight: - width < theme.chat.eventList.ownEventsOnRightUnderWidth - - delegate: EventDelegate {} - + HSelectableLabelContainer { + id: selectableLabelContainer anchors.fill: parent - anchors.leftMargin: theme.spacing - anchors.rightMargin: theme.spacing + reversed: eventList.verticalLayoutDirection == ListView.BottomToTop - topMargin: theme.spacing - bottomMargin: theme.spacing - verticalLayoutDirection: ListView.BottomToTop + onDragPositionChanged: { + let boost = 20 * ( + dragPosition.y < 50 ? + -dragPosition.y : -(height - dragPosition.y) + ) - // Keep x scroll pages cached, to limit images having to be - // reloaded from network. - cacheBuffer: height * 4 - - // Declaring this as "alias" provides the on... signal - property real yPos: visibleArea.yPosition - property bool canLoad: true - onYPosChanged: if (canLoad && yPos < 0.1) Qt.callLater(loadPastEvents) - - function loadPastEvents() { - // try/catch blocks to hide pyotherside error when the - // component is destroyed but func is still running - - try { - eventList.canLoad = false - - py.callClientCoro( - chatPage.userId, "load_past_events", [chatPage.roomId], - moreToLoad => { - try { - eventList.canLoad = moreToLoad - } catch (err) { - return - } - } - ) - } catch (err) { - return - } + dragFlicker.speed = + dragPosition.x == 0 && dragPosition.y == 0 ? 0 : + dragPosition.y < 50 ? 1000 + boost: + dragPosition.y > height - 50 ? -1000 + -boost : + 0 } - property string inviter: chatPage.roomInfo.inviter || "" - // When an invited room becomes joined, we should now be able to fetch - // past events. - onInviterChanged: canLoad = true + Timer { + id: dragFlicker + interval: 100 + running: speed != 0 + repeat: true + + onTriggered: { + if (eventList.verticalOvershoot != 0) return + if (speed < 0 && eventList.atYEnd) return + if (eventList.atYBeggining) { + if (bouncedStart) { return } else { bouncedStart = true } + } + + eventList.flick(0, speed * acceleration) + acceleration = Math.min(8, acceleration * 1.05) + } + onRunningChanged: if (! running) { + acceleration = 1.0 + bouncedStart = false + eventList.cancelFlick() + eventList.returnToBounds() + } + + property real speed: 0.0 + property real acceleration: 1.0 + property bool bouncedStart: false + } + + HListView { + id: eventList + clip: true + enableFlicking: false + + anchors.fill: parent + anchors.leftMargin: theme.spacing + anchors.rightMargin: theme.spacing + + topMargin: theme.spacing + bottomMargin: theme.spacing + verticalLayoutDirection: ListView.BottomToTop + + // Keep x scroll pages cached, to limit images having to be + // reloaded from network. + cacheBuffer: height * 4 + + onYPosChanged: + if (canLoad && yPos < 0.1) Qt.callLater(loadPastEvents) + + // When an invited room becomes joined, we should now be able to + // fetch past events. + onInviterChanged: canLoad = true + + Component.onCompleted: shortcuts.flickTarget = eventList + + + property string inviter: chatPage.roomInfo.inviter || "" + property real yPos: visibleArea.yPosition + property bool canLoad: true + + property bool ownEventsOnRight: + width < theme.chat.eventList.ownEventsOnRightUnderWidth + + + function canCombine(item, itemAfter) { + if (! item || ! itemAfter) return false + + return Boolean( + ! canTalkBreak(item, itemAfter) && + ! canDayBreak(item, itemAfter) && + item.sender_id === itemAfter.sender_id && + Utils.minutesBetween(item.date, itemAfter.date) <= 5 + ) + } + + function canTalkBreak(item, itemAfter) { + if (! item || ! itemAfter) return false + + return Boolean( + ! canDayBreak(item, itemAfter) && + Utils.minutesBetween(item.date, itemAfter.date) >= 20 + ) + } + + function canDayBreak(item, itemAfter) { + if (itemAfter && itemAfter.event_type == "RoomCreateEvent") + return true + + if (! item || ! itemAfter || ! item.date || ! itemAfter.date) + return false + + return item.date.getDate() != itemAfter.date.getDate() + } + + function loadPastEvents() { + // try/catch blocks to hide pyotherside error when the + // component is destroyed but func is still running + + try { + eventList.canLoad = false + + py.callClientCoro( + chatPage.userId, "load_past_events", [chatPage.roomId], + moreToLoad => { + try { + eventList.canLoad = moreToLoad + } catch (err) { + return + } + } + ) + } catch (err) { + return + } + } + + + model: HListModel { + keyField: "client_id" + source: modelSources[[ + "Event", chatPage.userId, chatPage.roomId + ]] || [] + } + + delegate: EventDelegate {} + } } HNoticePage { diff --git a/src/qml/utils.js b/src/qml/utils.js index 27afddfd..4b5b435e 100644 --- a/src/qml/utils.js +++ b/src/qml/utils.js @@ -169,8 +169,10 @@ function getItem(array, mainKey, value) { } -function smartVerticalFlick(flickable, baseVelocity, fastMultiply=3) { - if (! flickable.interactive) { return } +function smartVerticalFlick(flickable, baseVelocity, fastMultiply=4) { + if (! flickable.interactive && flickable.enableFlicking) return + if (flickable.verticalOvershoot != 0) return + if (baseVelocity > 0 && flickable.atYEnd) return baseVelocity = -baseVelocity let vel = -flickable.verticalVelocity