feat(wallet): implement DerivationPathInput control

The enforcing of the derivation path editing rules is done in a structured
way by handling all the changes on the array of `Element` stored in
d.elements and then recomposing the HTML string to be displayed after
every change.

Main limitation is the workaround in `onTextChanged` that regenerates
the text in order to dismiss foreign characters introduced by pasting
which I couldn't find a way to disable without disabling also the ability
to copy content to clipboard.

Highlights:
- Implement DerivationPathInput control that intercepts the modifiable
keyboard events in order to edit the visible TextEdit.text while
respecting the requirements of the derivation path editing
- Implement a JS Controller that handles the logic of the
  decomposing and recomposing the derivation path string
- Add anew StatusQ with the TextEdit basic look and feel to be used
  in DerivationPathInput control without duplicating the style
- Allow passing modifiable events that are not generating characters in
  order to allow copy to clipboard
- Disable add account when control is in error state
- Limit to maximum 5 elements in the derivation path

Testing:
- Integrate the control with StoryBook for a quick preview of the
  control
- Add unit tests for the Controller basic functionality and regression
  for the main control Item
- Removed forcing x64 architecture on apple arm64 hardware from the
  storybook build configuration

Note: initially the implementation was suppose to be simple parse the
derivation path string edit elements and format it. However, I could not
find a quick way fix the circular dependency issue between editing the
text and reformatting it. The solution was to use a one way from the
structured data to the formatted string which complicates the
implementation logic.

Closes: #9890
This commit is contained in:
Stefan 2023-03-15 23:34:48 +01:00 committed by Stefan Dunca
parent 3566d64f39
commit 81c3463816
13 changed files with 1343 additions and 42 deletions

View File

@ -12,7 +12,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
if (APPLE)
set(MACOS_VERSION_MIN_FLAGS -mmacosx-version-min=10.14)
set(CMAKE_OSX_ARCHITECTURES "x86_64")
endif()
find_package(

View File

@ -173,6 +173,10 @@ ListModel {
title: "StatusEmojiAndColorComboBox"
section: "Components"
}
ListElement {
title: "DerivationPathInput"
section: "Components"
}
ListElement {
title: "BrowserSettings"
section: "Settings"

View File

@ -135,5 +135,8 @@
"StatusCommunityCard": [
"https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A416159",
"https://www.figma.com/file/17fc13UBFvInrLgNUKJJg5/Kuba%E2%8E%9CDesktop?node-id=8159%3A416160"
],
"DerivationPathInput": [
"https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?node-id=12272%3A269692&t=YiipgcxOhdOvqprP-0"
]
}

View File

@ -0,0 +1,388 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ.Core 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Popups 0.1
import utils 1.0
import AppLayouts.Wallet.addaccount.panels 1.0
SplitView {
Pane {
SplitView.fillWidth: true
SplitView.fillHeight: true
ColumnLayout {
id: controlLayout
anchors.fill: parent
DerivationPathInput {
id: devTxtEdit
initialDerivationPath: initialBasePath + (initialBasePath.split("'").length > 4 ? "/0" : "/0'")
initialBasePath: stdBaseListView.currentIndex >= 0
? standardBasePathModel.get(stdBaseListView.currentIndex).derivationPath
: "m/44'/60'/0'/0"
levelsLimit: levelsLimitSpinBox.value
onEditingFinished: { lastEvent.text = "Editing finished" }
input.rightComponent: StatusIcon {
icon: "chevron-down"
color: Theme.palette.baseColor1
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
derivationPathSelection.popup(0, devTxtEdit.height + Style.current.halfPadding)
}
}
}
StatusMenu {
id: derivationPathSelection
ColumnLayout {
StatusBaseText {
text: "Test Popup"
Layout.margins: 10
}
StatusBaseText {
text: "Some more content here"
Layout.margins: 10
}
}
}
}
// Vertical separator
ColumnLayout {}
RowLayout {
Label {
text: "Levels limit"
}
SpinBox {
id: levelsLimitSpinBox
from: 0
to: 20
value: 0
}
}
RowLayout {
Layout.preferredHeight: customDerivationPathInput.height * 1.5
Label {
text: "Custom path:"
}
TextInput {
id: customDerivationPathInput
Layout.minimumWidth: 100
}
}
RowLayout {
Layout.preferredHeight: customDerivationPathBaseInput.height * 1.5
Label {
text: "Custom base:"
}
TextInput {
id: customDerivationPathBaseInput
Layout.minimumWidth: 100
}
}
Button {
text: "Set custom derivation path"
hoverEnabled: true
highlighted: hovered
onClicked: {
devTxtEdit.resetDerivationPath(customDerivationPathBaseInput.text, customDerivationPathInput.text)
}
}
Label {
text: devTxtEdit.errorMessage
visible: devTxtEdit.errorMessage.length > 0
Layout.alignment: Qt.AlignLeft
Layout.fillWidth: true
font.pixelSize: 22
font.italic: true
color: "red"
}
RowLayout {
Label { text: "Output: " }
Label { id: base; text: devTxtEdit.derivationPath }
}
RowLayout {
Label { text: "Last event: " }
Label { id: lastEvent; text: "" }
}
}
Border {
target: customDerivationPathInput
}
Border {
target: customDerivationPathBaseInput
}
Border {
target: devTxtEdit
}
Border {
target: devTxtEdit
radius: 0
border.color: "#22FF0000"
}
}
Pane {
SplitView.minimumWidth: 300
SplitView.fillWidth: true
SplitView.minimumHeight: 300
ListView {
id: stdBaseListView
anchors.fill: parent
model: standardBasePathModel
onCurrentIndexChanged: {
const newBasePath = standardBasePathModel.get(currentIndex).derivationPath
devTxtEdit.resetDerivationPath(newBasePath, newBasePath + (newBasePath.split("'").length > 3 ? "/0" : "/0'"))
}
delegate: ItemDelegate {
width: stdBaseListView.width
implicitHeight: delegateRowLayout.implicitHeight
highlighted: ListView.isCurrentItem
RowLayout {
id: delegateRowLayout
anchors.fill: parent
Column {
Layout.margins: 5
spacing: 3
Label {
text: name
Layout.alignment: Qt.AlignLeft
Layout.fillWidth: true
}
Label {
text: derivationPath
Layout.alignment: Qt.AlignRight
Layout.fillWidth: true
}
}
}
Border {
anchors.fill: delegateRowLayout
anchors.margins: 1
z: delegateRowLayout.z - 1
}
onClicked: stdBaseListView.currentIndex = index
}
}
ListModel {
id: standardBasePathModel
ListElement {
name: "Custom"
derivationPath: "m/44'"
}
ListElement {
name: "Ethereum"
derivationPath: "m/44'/60'/0'/0"
}
ListElement {
name: "Ethereum Classic"
derivationPath: "m/44'/61'/0'/0"
}
ListElement {
name: "Ethereum Testnet (Ropsten)"
derivationPath: "m/44'/1'/0'/0"
}
ListElement {
name: "Ethereum (Ledger)"
derivationPath: "m/44'/60'/0'"
}
ListElement {
name: "Ethereum Classic (Ledger)"
derivationPath: "m/44'/60'/160720'/0"
}
ListElement {
name: "Ethereum Classic (Ledger, Vintage MEW)"
derivationPath: "m/44'/60'/160720'/0'"
}
ListElement {
name: "Ethereum (Ledger Live)"
derivationPath: "m/44'/60'"
}
ListElement {
name: "Ethereum Classic (Ledger Live)"
derivationPath: "m/44'/61'"
}
ListElement {
name: "Ethereum (KeepKey)"
derivationPath: "m/44'/60'"
}
ListElement {
name: "Ethereum Classic (KeepKey)"
derivationPath: "m/44'/61'"
}
ListElement {
name: "RSK Mainnet"
derivationPath: "m/44'/137'/0'/0"
}
ListElement {
name: "Expanse"
derivationPath: "m/44'/40'/0'/0"
}
ListElement {
name: "Ubiq"
derivationPath: "m/44'/108'/0'/0"
}
ListElement {
name: "Ellaism"
derivationPath: "m/44'/163'/0'/0"
}
ListElement {
name: "EtherGem"
derivationPath: "m/44'/1987'/0'/0"
}
ListElement {
name: "Callisto"
derivationPath: "m/44'/820'/0'/0"
}
ListElement {
name: "Ethereum Social"
derivationPath: "m/44'/1128'/0'/0"
}
ListElement {
name: "Musicoin"
derivationPath: "m/44'/184'/0'/0"
}
ListElement {
name: "EOS Classic"
derivationPath: "m/44'/2018'/0'/0"
}
ListElement {
name: "Akroma"
derivationPath: "m/44'/200625'/0'/0"
}
ListElement {
name: "Ether Social Network"
derivationPath: "m/44'/31102'/0'/0"
}
ListElement {
name: "PIRL"
derivationPath: "m/44'/164'/0'/0"
}
ListElement {
name: "GoChain"
derivationPath: "m/44'/6060'/0'/0"
}
ListElement {
name: "Ether-1"
derivationPath: "m/44'/1313114'/0'/0"
}
ListElement {
name: "Atheios"
derivationPath: "m/44'/1620'/0'/0"
}
ListElement {
name: "TomoChain"
derivationPath: "m/44'/889'/0'/0"
}
ListElement {
name: "Mix Blockchain"
derivationPath: "m/44'/76'/0'/0"
}
ListElement {
name: "Iolite"
derivationPath: "m/44'/1171337'/0'/0"
}
ListElement {
name: "ThunderCore"
derivationPath: "m/44'/1001'/0'/0"
}
}
}
component Border: Rectangle {
property Item target: null
Component.onCompleted: setTargetAsParent()
onTargetChanged: setTargetAsParent()
function setTargetAsParent() {
if(!!target) {
parent = target
}
}
x: !!target ? -radius : 0
y: !!target ? -radius : 0
width: !!target ? target.width+2*radius : 0
height: !!target ? target.height+2*radius : 0
z: !!target ? target.z - 1 : 0
color: "transparent"
border.color: "black"
border.width: 1
radius: 5
}
}

View File

@ -0,0 +1,144 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQml 2.14
import QtTest 1.0
import AppLayouts.Wallet.addaccount.panels 1.0
import AppLayouts.Wallet.addaccount.panels.DerivationPathInput 1.0
Item {
id: root
width: 600
height: 400
TestCase {
name: "DerivationPathInputControllerTests"
Component {
id: controllerComponent
Controller {
enabledColor: "white"
frozenColor: "black"
errorColor: "red"
}
}
property Controller controller: null
function init() {
controller = createTemporaryObject(controllerComponent, root)
}
function test_parseRegularBases_data() {
return [
{tag: "Ethereum Standard", base: "m/44'/60'/0'/0", expected: ["m/44'/", "60", "'/", "0", "'/", "0"]},
{tag: "Custom", base: "m/44'/", expected: ["m/44'/"]},
{tag: "Ethereum Ledger", base: "m/44'/60'/0'", expected: ["m/44'/", "60", "'/", "0", "'"]},
{tag: "Ethereum Ledger Live", base: "m/44'/60'", expected: ["m/44'/", "60", "'"]},
]
}
function test_parseRegularBases(data) {
let res = controller.parseDerivationPath(data.base)
compare(res.length, data.expected.length, `expect same length: ${JSON.stringify(res)} vs ${JSON.stringify(data.expected)}`)
for (const i in res) {
compare(data.expected[i], res[i].content)
}
}
function test_parseRegularDerivationPath_data() {
return [
{tag: "Ethereum one address index", derivationPath: "m/44'/60'/0'/0/1", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", "1"]},
{tag: "Ethereum two address index", derivationPath: "m/44'/60'/0'/0/1/2", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", "1", "/", "2"]},
{tag: "Ethereum Ledger", derivationPath: "m/44'/60'/0'/1/2", expected: ["m/44'/", "60", "'/", "0", "'/", "1", "/", "2"]},
{tag: "Ethereum Ledger Live", derivationPath: "m/44'/60'/1'/2/3", expected: ["m/44'/", "60", "'/", "1", "'/", "2", "/", "3"]},
{tag: "Empty entries", derivationPath: "m/44'/'/'//", expected: ["m/44'/", "", "'/", "", "'/", "", "/", ""]},
{tag: "Wrong entries", derivationPath: "m/44'/T<'/.?'/;/wrong", expected: ["m/44'/", "T<", "'/", ".?", "'/", ";", "/", "wrong"]}
]
}
function test_parseRegularDerivationPath(data) {
let res = controller.parseDerivationPath(data.derivationPath)
compare(res.length, data.expected.length)
for (const i in res) {
compare(data.expected[i], res[i].content)
}
}
function test_completeDerivationPath_data() {
return [
{name: "Ethereum", base: "m/44'/60'/0'/0", derivationPath: "m/44'/60'/0'/0/1", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", "1"]},
{name: "Ending in separator", base: "m/44'/60'/0'/0", derivationPath: "m/44'/60'/0'/0/", expected: ["m/44'/", "60", "'/", "0", "'/", "0", "/", ""]},
{name: "Custom", base: "m/44'", derivationPath: "m/44'", expected: ["m/44'/", ""]},
]
}
function test_completeDerivationPath(data) {
let res = controller.completeDerivationPath(data.base, data.derivationPath)
verify(res.errorMessage.length === 0, `expect no error message, got "${res.errorMessage}"`)
compare(res.elements.length, data.expected.length, `expect same length for test data entry ${data.name}, ${JSON.stringify(res.elements)} vs ${JSON.stringify(data.expected)}`)
for (const i in res) {
compare(data.expected[i], res[i].content)
}
}
function test_parseRegularWrongDerivationPath_data() {
return [
{name: "Ethereum", derivationPath: "m'/46'/60'/0'/0/1"},
{name: "Ethereum", derivationPath: "m/44'/60/0'/0/1/2"},
{name: "Ethereum Ledger", derivationPath: "m/44'/60'/0/1/2"},
{name: "Incomplete", derivationPath: "m/44"},
{name: "Ethereum", derivationPath: "'/46'/60'/0'/0/1"},
]
}
function test_parseRegularWrongDerivationPath(data) {
let res = controller.parseDerivationPath(data.derivationPath)
compare(res, null)
}
Component {
id: testDataElementsComponent
Item {
Element { id: base; content: "m/44'/"; startIndex: 0; endIndex: 7; contentType: Element.ContentType.Base }
Element { id: coin; content: "60"; startIndex: base.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Number }
Element { id: coinAccSep; content: "'/"; startIndex: coin.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Separator }
Element { id: acc; content: "777"; startIndex: coinAccSep.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Number }
Element { id: accChgSep; content: "'/"; startIndex: acc.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Separator }
Element { id: chg; content: "0"; startIndex: accChgSep.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Number }
Element { id: chgAccIdxSep; content: "/"; startIndex: chg.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Separator }
Element { id: accIdx1; content: "1"; startIndex: chgAccIdxSep.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Number }
Element { id: accIdxSep1; content: "/"; startIndex: accIdx1.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Separator }
Element { id: accIdx2; content: "2"; startIndex: accIdxSep1.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Number }
Element { id: accIdxSep2; content: "/"; startIndex: accIdx2.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Separator }
Element { id: accIdx3; content: "2"; startIndex: accIdxSep2.endIndex; endIndex: startIndex + content.length; contentType: Element.ContentType.Number }
}
}
function test_generateHtml() {
let res = controller.generateHtmlFromElements(testDataElementsComponent.createObject(root).resources)
verify(/^<style>(?:\.[a-zA-Z]{[a-zA-Z0-9#:;]+})+<\/style>(?:<span\sclass=["']\w["']>[\/\d'm]+<\/span>)+$/.test(res), `The generated html is valid and optimum (no extra spaces or CSS long names) - "${res}"`)
}
}
TestCase {
name: "DerivationPathInputRegressionTests"
Component {
id: regressionControlComponent
DerivationPathInput {
initialDerivationPath: "m/44'/60'/0'/0/1"
initialBasePath: "m/44'/60'/0'/0"
}
}
property DerivationPathInput controller: null
// Controller.Component.onCompleted was initializing Component.d.referenceElements after DerivationPathInput.onCompleted was processing the DerivationPathInput.initialDerivationPath, hence the output was wrong (m/44'/60001)
function test_successfulInitializationOfControllerBeforeItem() {
const control = createTemporaryObject(regressionControlComponent, root)
compare("m/44'/60'/0'/0/1", control.derivationPath)
}
}
}

View File

@ -272,6 +272,9 @@ Item {
if(focus) edit.forceActiveFocus()
}
implicitWidth: contentLayout.implicitWidth + root.leftPadding + root.rightPadding
implicitHeight: contentLayout.implicitHeight
Rectangle {
id: background
anchors.fill: parent
@ -308,6 +311,7 @@ Item {
root.editClicked()
}
RowLayout {
id: contentLayout
spacing: 8
anchors {
fill: parent
@ -334,8 +338,12 @@ Item {
Layout.fillHeight: true
Layout.topMargin: root.topPadding
Layout.bottomMargin: root.bottomPadding
contentWidth: edit.paintedWidth
contentHeight: edit.paintedHeight
implicitWidth: edit.implicitWidth
implicitHeight: edit.implicitHeight
boundsBehavior: Flickable.StopAtBounds
QC.ScrollBar.vertical: QC.ScrollBar {
interactive: multiline
@ -359,6 +367,7 @@ Item {
font.family: Theme.palette.baseFont.name
color: root.enabled ? Theme.palette.directColor1 : Theme.palette.baseColor1
wrapMode: root.multiline ? Text.WrapAtWordBoundaryOrAnywhere : TextEdit.NoWrap
Keys.onReturnPressed: {
root.keyPressed(event)
event.accepted = !multiline && !acceptReturn

View File

@ -49,61 +49,88 @@ GridLayout {
text: qsTr("Account")
}
StatusInput {
id: derivationPath
RowLayout {
Layout.preferredWidth: d.oneHalfWidth
Layout.columnSpan: 2
text: root.store.addAccountModule.derivationPath
onTextChanged: {
let t = text
if (t.endsWith("\n")) {
t = t.replace("\n", "")
}
if(root.store.derivationPathRegEx.test(t)) {
root.store.changeDerivationPathPostponed(t)
}
else {
root.store.addAccountModule.derivationPath = t
}
}
DerivationPathInput {
id: derivationPathInput
multiline: false
input.rightComponent: StatusIcon {
icon: "chevron-down"
color: Theme.palette.baseColor1
Layout.fillWidth: true
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
derivationPathSelection.popup(derivationPath.x, derivationPath.y + derivationPath.height + Style.current.halfPadding)
initialDerivationPath: root.store.addAccountModule.derivationPath
initialBasePath: root.store.selectedRootPath
levelsLimit: 4 // Allow only 5 separators in the derivation path
onDerivationPathChanged: {
let t = derivationPath
if(t.length > 0) {
if(root.store.derivationPathRegEx.test(t)) {
root.store.changeDerivationPathPostponed(t)
} else {
root.store.addAccountModule.derivationPath = t
}
}
}
onEditingFinished: {
root.store.submitAddAccount(null)
}
input.rightComponent: StatusIcon {
icon: "chevron-down"
color: Theme.palette.baseColor1
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
derivationPathSelection.popup(0, derivationPathInput.height + Style.current.halfPadding)
}
}
}
DerivationPathSelection {
id: derivationPathSelection
roots: root.store.roots
translation: root.store.translation
selectedRootPath: root.store.selectedRootPath
onSelected: {
root.store.changeRootDerivationPath(rootPath)
}
}
}
onKeyPressed: {
root.store.submitAddAccount(event)
Connections {
target: root.store
function onSelectedRootPathChanged() {
derivationPathInput.resetDerivationPath(root.store.selectedRootPath, root.store.addAccountModule.derivationPath)
}
}
DerivationPathSelection {
id: derivationPathSelection
roots: root.store.roots
translation: root.store.translation
selectedRootPath: root.store.selectedRootPath
onSelected: {
root.store.changeRootDerivationPath(rootPath)
Connections {
target: root.store.addAccountModule
function onDerivationPathChanged() {
derivationPathInput.resetDerivationPath(root.store.selectedRootPath, root.store.addAccountModule.derivationPath)
}
}
// Propagate the error state to the store
Binding {
target: root.store
property: "derivationPathEditingNotValid"
value: derivationPathInput.errorMessage !== ""
}
}
StatusListItem {
id: generatedAddress
Layout.preferredWidth: d.oneHalfWidth
Layout.preferredHeight: derivationPath.height
Layout.preferredHeight: derivationPathInput.height
color: "transparent"
border.width: 1
border.color: Theme.palette.baseColor2
@ -146,12 +173,31 @@ GridLayout {
}
}
StatusBaseText {
RowLayout {
Layout.preferredWidth: d.oneHalfWidth
Layout.columnSpan: 2
font.pixelSize: Constants.addAccountPopup.labelFontSize2
color: Theme.palette.baseColor1
text: root.store.translation(root.store.selectedRootPath, true)
StatusBaseText {
id: basePathName
Layout.fillWidth: true
visible: !errorMessageText.visible
font.pixelSize: Constants.addAccountPopup.labelFontSize2
color: Theme.palette.baseColor1
text: root.store.translation(root.store.selectedRootPath, true)
}
StatusBaseText {
id: errorMessageText
Layout.fillWidth: true
visible: !!derivationPathInput.errorMessage
font.pixelSize: basePathName.font.pixelSize
color: Theme.palette.dangerColor1
text: derivationPathInput.errorMessage
}
}
AddressDetails {

View File

@ -0,0 +1,231 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import StatusQ.Controls 0.1
import StatusQ.Core.Theme 0.1
import "DerivationPathInput" as Internals
/// Custom text input with guiding markers and layout for entering derivation paths
/// \note don't allow input control to change text, this will generate too many states from all events combinations
/// \note all modifiable events affect data model only which is then used to generate the final HTML displayable input.text
/// \note we allow non-modifiable events only (e.g. cursor movement) to propagate to the input control
/// \note d.currentIndex is always on a Number type element
/// \note implementation relies that all the events are handled serially on the same thread
Item {
id: root
readonly property alias derivationPath: d.currentDerivationPath
required property string initialDerivationPath
required property string initialBasePath
property alias levelsLimit: controller.levelsLimit
property alias errorMessage: d.errorMessage
property alias input: input
signal editingFinished()
implicitWidth: input.implicitWidth
implicitHeight: input.implicitHeight
// returns true if the derivation path is valid and false otherwise. Will also set the appropriate errorMessage if the derivation path is invalid
function resetDerivationPath(basePath, newDerivationPath) {
var res = controller.completeDerivationPath(basePath, newDerivationPath)
if(res.errorMessage) {
return false
}
d.resetErrorMessage()
d.elements = res.elements
d.updateText(d.elements)
input.cursorPosition = d.elements[d.elements.length - 1].endIndex
return true
}
QtObject {
id: d
property string currentDerivationPath: ""
property var elements: []
/// element index at cursor position
property int currentIndex: -1
property int cursorPositionToRestore: -1
property string errorMessage: ""
function resetErrorMessage() { errorMessage = "" }
readonly property bool selectionIsActive: Math.abs(input.selectionEnd - input.selectionStart) > 0
property bool expectTextUpdate: false
/// Updates input text with elements content
function updateText(elements, cursorOffset = 0) {
d.cursorPositionToRestore = input.cursorPosition + cursorOffset
expectTextUpdate = true
input.text = controller.generateHtmlFromElements(elements)
}
}
Component.onCompleted: {
resetDerivationPath(root.initialBasePath, root.initialDerivationPath)
}
Internals.Controller {
id: controller
enabledColor: root.enabled ? Theme.palette.directColor1 : Theme.palette.baseColor1
frozenColor: Theme.palette.getColor('grey5')
errorColor: Theme.palette.dangerColor1
}
StatusBaseInput {
id: input
anchors.fill: parent
edit.textFormat: TextEdit.RichText
topPadding: 11
bottomPadding: 11
valid: d.errorMessage.length === 0
readonly property var passthroughKeys: [Qt.Key_Left, Qt.Key_Right, Qt.Key_Escape]
Shortcut {
sequence: StandardKey.Copy
enabled: d.selectionIsActive
onActivated: input.copy()
}
Keys.onPressed: (event)=> {
// Stop propagation of all modifiable events
for(const key of passthroughKeys) {
if(event.key === key) {
event.accepted = false
return
}
}
if(event.modifiers !== Qt.NoModifier && event.text.length === 0) {
event.accepted = false
return
}
event.accepted = true
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.editingFinished()
} else if (event.key === Qt.Key_Delete) {
if(d.selectionIsActive) {
controller.deleteFromStartToEnd(d.elements, selectionStart, selectionEnd)
d.updateText(d.elements, selectionStart - cursorPosition)
return
}
if(d.currentIndex >= 0) {
const cursorOffset = controller.deleteInContent(d.elements, d.currentIndex, cursorPosition)
d.updateText(d.elements, cursorOffset)
}
} else if (event.key === Qt.Key_Backspace) {
if(d.currentIndex >= 0) {
const cursorOffset = controller.deleteInContent(d.elements, d.currentIndex, cursorPosition, false)
d.updateText(d.elements, cursorOffset)
}
} else if (event.key === Qt.Key_Space
|| event.key === Qt.Key_Tab) {
const nextIndex = d.currentIndex + 1
if(nextIndex < d.elements.length) {
input.cursorPosition = d.elements[nextIndex].endIndex
} else {
const newElementIndex = controller.tryAddAndFixSeparators(d.elements, nextIndex)
if(newElementIndex > -1) {
d.updateText(d.elements, d.elements[newElementIndex].endIndex - cursorPosition)
}
}
} else if (event.key === Qt.Key_Slash
|| event.key === Qt.Key_Backslash
|| event.key === Qt.Key_Apostrophe
|| event.key === Qt.Key_QuoteLeft) {
const newElementIndex = controller.tryAddAndFixSeparators(d.elements, d.currentIndex, d.elements[d.currentIndex].startIndex === cursorPosition)
if(newElementIndex > -1) {
d.updateText(d.elements, d.elements[newElementIndex].endIndex - cursorPosition)
}
} else if(event.text) {
if(d.currentIndex >= 0 && d.currentIndex < d.elements.length) {
controller.insertContent(d.elements, d.currentIndex, event.text, cursorPosition)
d.updateText(d.elements, event.text.length)
}
}
}
property int prevCursorPosition: cursorPosition
onCursorPositionChanged: {
if(d.selectionIsActive)
return
var nextIndex = -1
for(const i in d.elements) {
// Prioritize cursor on editable elements
if(cursorPosition >= d.elements[i].startIndex && d.elements[i].isFrozen ? cursorPosition < d.elements[i].endIndex : cursorPosition <= d.elements[i].endIndex) {
nextIndex = Number(i)
break
}
}
var movingLeft = cursorPosition < prevCursorPosition
if(nextIndex > -1) {
if(d.elements[nextIndex].isFrozen) {
const foundIndex = nextIndex
while(nextIndex < d.elements.length && d.elements[nextIndex].isFrozen) {
nextIndex += movingLeft ? -1 : 1
if(nextIndex < 0) {
nextIndex = foundIndex
movingLeft = false
}
}
if(nextIndex < d.elements.length) {
cursorPosition = movingLeft ? d.elements[nextIndex].endIndex : d.elements[nextIndex].startIndex
} else {
nextIndex = d.elements.length - 1
cursorPosition = d.elements[nextIndex].endIndex
}
}
} else {
// On the last element which is frozen
nextIndex = d.elements.length - 1
}
d.currentIndex = nextIndex
prevCursorPosition = cursorPosition
}
onTextChanged: {
// Ignore external text update (paste and delete events)
if(d.expectTextUpdate) {
d.expectTextUpdate = false
} else {
d.cursorPositionToRestore = prevCursorPosition
d.updateText(d.elements)
return
}
const currentText = edit.getText(0, text.length)
const errorText = controller.validateAllElements(d.elements)
if(d.cursorPositionToRestore >= 0) {
input.cursorPosition = d.cursorPositionToRestore
d.cursorPositionToRestore = -1
}
d.errorMessage = errorText
if(errorText.length > 0 || !d.elements.slice(0, -1).every(obj => obj.content.length > 0)) {
d.currentDerivationPath = ""
} else {
d.currentDerivationPath = currentText
}
}
}
}

View File

@ -0,0 +1,407 @@
import QtQuick 2.15
/// \note ensures data model always has consecutive Separator and Number after Base without duplicates except current element
/// \note for future work: split deleteInContent in deleteInContent and deleteElements and move data model to a DataModel object;
/// also fix code duplication in parseDerivationPath generating static level definitions and iterate through it
Item {
id: root
required property color enabledColor
required property color frozenColor
required property color errorColor
/// Don't allow inserting more than \c levelsLimit Number elements
property int levelsLimit: 0
readonly property string inputError: qsTr("Please enter numbers only")
readonly property string tooBigError: qsTr("Account number must be <100")
QtObject {
id: d
// d flag and named capture groups not supported in Qt 5.15. Use multiple regexes instead
readonly property var derivationPathRegex: /^m\/44[|'](?:\/(?<coin_type>.*?)[|'])?(?:\/(?<account>.*?)[|'])?(?:\/(?<change>.*?))?((?:\/.*?)?)$/
// Workaround to missing capture group offsets in Qt 5.15
readonly property var offsets: [6, 2, 2]
readonly property int addressIndexStart: 3
// Reference derivation path used to normalize separators (hardened or not). The last separator will be used
property var referenceElements: []
function initializeReferenceElementsIfRequired() {
if (referenceElements.length === 0) {
referenceElements = root.parseDerivationPath("m/44'/0'/1'/2/3")
}
}
function createElement(content, startIndex, endIndex, contentType, isFrozen = false) {
return elementComponent.createObject(root, {
content: content,
startIndex: startIndex,
endIndex: endIndex,
contentType: contentType,
isFrozen: isFrozen});
}
}
/// Returns null if the derivationPath is invalid or an array of Element objects if derivationPath is valid
function parseDerivationPath(derivationPath) {
const matches = d.derivationPathRegex.exec(derivationPath)
if (matches === null) {
return null
}
var elements = []
var groupIndex = 0
var currentIndex = 0
var nextIndex = d.offsets[groupIndex]
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Base))
// Extract the captured groups
const coin_type = matches[1]
const account = matches[2]
const change = matches[3]
const addressIndexes = matches[4] != null && matches[4].length > 0 ? matches[4].substring(1) : ""; // remove the leading slash
if (coin_type != null) {
currentIndex = nextIndex
nextIndex = currentIndex + coin_type.length
elements.push(d.createElement(coin_type, currentIndex, nextIndex, Element.ContentType.Number))
groupIndex++
currentIndex = nextIndex
nextIndex = currentIndex + d.offsets[groupIndex]
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
if(account != null) {
currentIndex = nextIndex
nextIndex = currentIndex + account.length
elements.push(d.createElement(account, currentIndex, nextIndex, Element.ContentType.Number))
groupIndex++
currentIndex = nextIndex
nextIndex = currentIndex + d.offsets[groupIndex]
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
if(change != null) {
currentIndex = nextIndex
nextIndex = currentIndex + change.length
elements.push(d.createElement(change, currentIndex, nextIndex, Element.ContentType.Number))
// Check if there are any address indexes
if (matches[4] != null && matches[4].length > 0) {
const addressIndexesParts = addressIndexes.split('/')
for (const addressIndex of addressIndexesParts) {
currentIndex = nextIndex
nextIndex = currentIndex + 1
elements.push(d.createElement(derivationPath.slice(currentIndex, nextIndex), currentIndex, nextIndex, Element.ContentType.Separator))
currentIndex = nextIndex
nextIndex = currentIndex + addressIndex.length
elements.push(d.createElement(addressIndex, currentIndex, nextIndex, Element.ContentType.Number))
}
}
} else if(addressIndexes.length > 0) {
return null
}
} else if(change || addressIndexes.length > 0) {
return null
}
} else if(account || change || addressIndexes.length > 0) {
return null
}
return elements
}
function generateHtmlFromElements(elements) {
// d class is disabled and e class is editable
var res = `<style>.d{color:${root.frozenColor};}.e{color:${root.enabledColor};}.f{color:${root.errorColor};}</style>`
const format = (content, cssClass) => `<span class="${cssClass}">${content}</span>`
var numberLevel = 0
for(var i = 0; i < elements.length; i++) {
if (elements[i].isFrozen) {
res += format(elements[i].content, "d")
} else if(validateElement(elements[i], numberLevel)) {
res += format(elements[i].content, "f")
} else {
res += format(elements[i].content, "e")
}
if(elements[i].isNumber()) {
numberLevel++
}
}
return res
}
Component {
id: completedDerivationPathComponent
QtObject {
required property var elements
required property string errorMessage
}
}
/// Matches the derivation path with a base path and freezes the base path elements
/// It also completes last separator and content if needed
/// \return CompletedDerivationPath
function completeDerivationPath(basePath, derivationPath) {
const errorResponse = () => {
const res = completedDerivationPathComponent.createObject(root, {
elements: [],
errorMessage: root.inputError
})
return res
}
const baseElements = root.parseDerivationPath(basePath)
if (!baseElements) {
console.info(`Invalid base of derivation path: ${basePath}`)
return errorResponse()
}
var elements = root.parseDerivationPath(derivationPath)
if (!elements) {
console.info(`Invalid derivation path: ${derivationPath}`)
return errorResponse()
}
if(baseElements.length > elements.length) {
console.info(`Base path elements length (${baseElements.length}) bigger than path length (${elements.length})`)
return errorResponse()
}
for(const i in elements) {
if(i < baseElements.length) {
if(!baseElements[i].isSimilar(elements[i])) {
console.warn(`Base content "${baseElements[i].content}" doesn't match derivation path content "${elements[i].content}" for index ${i}`)
return errorResponse()
}
elements[i].isFrozen = true
} else {
elements[i].isFrozen = !elements[i].isNumber()
}
}
if(elements[elements.length - 1].isBase()
&& (elements[elements.length - 1].content.slice(-1) === "'" || elements[elements.length - 1].content.slice(-1) === "")) {
elements[elements.length - 1].content += "/"
}
if(elements[elements.length - 1].isSeparator() || elements[elements.length - 1].isBase()) {
elements.push(d.createElement("", elements[elements.length - 1].endIndex, elements[elements.length - 1].endIndex, Element.ContentType.Number))
}
normalizeAndRemoveDuplicateSeparators(elements, elements.length - 1)
let res = completedDerivationPathComponent.createObject(root, {
elements: elements,
errorMessage: ""
})
return res
}
/// \return true if an element was added
function tryAddAndFixSeparators(elements, insertIndex, insertLeft = false) {
if(root.levelsLimit > 0 && countNonEmptyLevels(elements) >= root.levelsLimit) {
return -1
}
return addAndFixSeparators(elements, insertIndex, insertLeft)
}
/// \return the newly created element's index or -1 if no element was created
function addAndFixSeparators(elements, insertIndex, insertLeft = false) {
var finalInsertIndex = insertIndex
var newContentIndex = 0
if(insertLeft) {
while(finalInsertIndex >= 0 && elements[finalInsertIndex].isFrozen) {
finalInsertIndex--
}
if(finalInsertIndex >= 0) {
newContentIndex = finalInsertIndex
elements.splice(newContentIndex, 0, d.createElement("", elements[finalInsertIndex - 1].endIndex, elements[finalInsertIndex - 1].endIndex, Element.ContentType.Number))
elements.splice(finalInsertIndex + 1, 0, d.createElement("/", elements[finalInsertIndex].endIndex, elements[finalInsertIndex].endIndex + 1, Element.ContentType.Separator, true))
} else {
insertLeft = false
finalInsertIndex = insertIndex
}
}
if(!insertLeft) {
while(finalInsertIndex < elements.length && !elements[finalInsertIndex].isFrozen) {
finalInsertIndex++
}
elements.splice(finalInsertIndex, 0, d.createElement("/", elements[finalInsertIndex - 1].endIndex, elements[finalInsertIndex - 1].endIndex + 1, Element.ContentType.Separator, true))
newContentIndex = finalInsertIndex + 1
elements.splice(newContentIndex, 0, d.createElement("", elements[finalInsertIndex].endIndex, elements[finalInsertIndex].endIndex, Element.ContentType.Number))
}
normalizeAndRemoveDuplicateSeparators(elements, newContentIndex)
return findFirstEmptyContent(elements)
}
function insertContent(elements, elementIndex, text, cursorPos) {
while(elementIndex < (elements.length - 1) && (!elements[elementIndex].isNumber() || elements[elementIndex].isFrozen)) {
elementIndex++
}
if(cursorPos < elements[elementIndex].startIndex)
cursorPos = elements[elementIndex].endIndex
const element = elements[elementIndex]
const insertIdx = cursorPos - element.startIndex
element.content = element.content.slice(0, insertIdx) + text + element.content.slice(insertIdx)
element.endIndex += text.length
controller.updateFollowingIndices(elements, elementIndex)
}
/// \return cursor offset
function deleteInContent(elements, elementIndex, cursorPos, deleteRightOfCursor = true) {
const element = elements[elementIndex]
var deleteIdx = -1 // Also marks content change
var cursorOffset = 0
var startOfIndicesUpdate = elementIndex
if(deleteRightOfCursor) {
if (cursorPos === element.endIndex) {
// If at the end of the content delete next separator and merge content
if((elementIndex + 2) < elements.length && elements[elementIndex + 1].isSeparator() && !elements[elementIndex + 2].isFrozen) {
const newContent = element.content + elements[elementIndex + 2].content
elements.splice(elementIndex + 1, 2)
element.content = newContent
startOfIndicesUpdate = elementIndex
}
} else {
deleteIdx = cursorPos - element.startIndex
}
} else {
if (cursorPos === element.startIndex) {
// If at content's beginning delete left separator and merge content
var deletedChars = 0
if((elementIndex - 2) > 0 && elements[elementIndex - 1].isSeparator() && !elements[elementIndex - 2].isFrozen) {
const newContent = elements[elementIndex - 2].content + element.content
cursorOffset = -(elements[elementIndex - 1].content.length)
elements.splice(elementIndex - 2, 2)
element.content = newContent
startOfIndicesUpdate = elementIndex - 2
}
} else {
deleteIdx = cursorPos - element.startIndex - 1
cursorOffset = -1
}
}
if(deleteIdx > -1) {
element.content = element.content.slice(0, deleteIdx) + element.content.slice(deleteIdx + 1)
element.endIndex -= 1
}
controller.updateFollowingIndices(elements, startOfIndicesUpdate)
return cursorOffset
}
function countNonEmptyLevels(elements) {
var count = 0
for(var i = 0; i < (elements.length-1); i++) {
if((elements[i].isSeparator() || elements[i].isBase()) && elements[i + 1].isNumber() && !elements[i + 1].isEmptyNumber()) {
count++
}
}
return count
}
function normalizeAndRemoveDuplicateSeparators(elements, exceptIndex) {
var currentContent = ""
var markToDelete = []
for(var i = 0; i < (elements.length - 1); i++) {
if(elements[i].isEmptyNumber() && elements[i+1].isSeparator() && i !== exceptIndex) {
markToDelete.push(i)
markToDelete.push(i + 1)
}
}
// Cleanup the last separator if it is the case
if(elements.length > 2 && elements[elements.length - 2].isSeparator() && elements[elements.length - 1].isEmptyNumber() && (elements.length - 1) !== exceptIndex) {
markToDelete.push(elements.length - 2)
markToDelete.push(elements.length - 1)
}
for (var i = markToDelete.length - 1; i >= 0; i--) {
elements.splice(markToDelete[i], 1)
}
d.initializeReferenceElementsIfRequired()
// Normalize separators
for(var i = 0; i < elements.length; i++) {
if(i < d.referenceElements.length && d.referenceElements[i].isSeparator()) {
currentContent = d.referenceElements[i].content
} // else: use the last separator
if(elements[i].isSeparator() && currentContent.length > 0 && elements[i].content !== currentContent) {
elements[i].content = currentContent
}
}
updateFollowingIndices(elements, 0)
}
function updateFollowingIndices(elements, firstElementIndex) {
for(var i = firstElementIndex; i < elements.length; i++) {
if(i == 0) {
elements[i].startIndex = 0
} else {
elements[i].startIndex = elements[i - 1].endIndex
}
elements[i].endIndex = elements[i].startIndex + elements[i].content.length
}
}
function findFirstEmptyContent(elements) {
for(var i = 0; i < elements.length; i++) {
if(elements[i].content.length === 0) {
return i
}
}
return -1
}
function deleteFromStartToEnd(elements, startPos, endPos) {
var startIndex = -1
var endIndex = -1
for(var i = 0; i < elements.length; i++) {
if(startIndex == -1 && startPos >= elements[i].startIndex) {
startIndex = i
} else if(endIndex == -1 && endPos <= elements[i].startIndex) {
endIndex = i
}
}
elements.splice(startIndex, endIndex - startIndex)
updateFollowingIndices(elements, 0)
}
function validateAllElements(elements) {
var numberLevel = 0
for(var i = 0; i < elements.length; i++) {
const error = validateElement(elements[i], numberLevel)
if(error.length > 0) {
return error
}
if(elements[i].isNumber()) {
numberLevel++
}
}
return ""
}
function validateElement(element, numberLevel) {
if(!element.validateNumber() && !element.isEmptyNumber()) {
return root.inputError
} else if(numberLevel >= d.addressIndexStart && element.number() >= 100) {
return root.tooBigError
}
return ""
}
Component {
id: elementComponent
Element {
content: ""
startIndex: 0
endIndex: 0
contentType: 0
}
}
}

View File

@ -0,0 +1,62 @@
import QtQuick 2.15
QtObject {
required property string content
required property int startIndex
// Non-inclusive
required property int endIndex
required property int contentType
property bool isFrozen: false
enum ContentType {
Number,
Separator,
Base
}
function length() {
return endIndex - startIndex
}
function isHardened() {
return contentType === Element.ContentType.Separator && (content[0] === "'" || content[0] === "")
}
/// Returns NaN if not a number
function number() {
return (contentType === Element.ContentType.Number && /^\d+$/.test(content)) ? parseInt(content, 10) : NaN
}
function isNumber() {
return contentType === Element.ContentType.Number
}
function validateNumber() {
return contentType !== Element.ContentType.Number || !isNaN(number())
}
function isSeparator() {
return contentType === Element.ContentType.Separator
}
function isEmptyNumber() {
return contentType === Element.ContentType.Number && content.length === 0
}
function isBase() {
return contentType === Element.ContentType.Base
}
/// Compares for incomplete typed separators
function isSimilar(other) {
return contentType === other.contentType
&& (contentType === Element.ContentType.Number
? number() === other.number()
: (isHardened() === other.isHardened()))
}
/// Compares
function isSame(other) {
return contentType === other.contentType && content === other.content && startIndex == other.startIndex && endIndex == other.endIndex
}
}

View File

@ -0,0 +1,2 @@
Controller 1.0 Controller.qml
Element 1.0 Element.qml

View File

@ -0,0 +1 @@
DerivationPathInput 1.0 DerivationPathInput.qml

View File

@ -38,6 +38,8 @@ QtObject {
(root.addAccountModule.derivationPath.match(/\//g) || []).length !== 5
: false
property bool derivationPathEditingNotValid: false
readonly property var derivationPathRegEx: /^(m\/44'\/)([0-9|'|\/](?!\/'))*$/
property string selectedRootPath: Constants.addAccountPopup.predefinedPaths.ethereum
readonly property var roots: [Constants.addAccountPopup.predefinedPaths.custom,
@ -182,6 +184,9 @@ QtObject {
root.addAccountModule.selectedEmoji !== ""
if (root.currentState.stateType === Constants.addAccountPopup.state.main) {
if(derivationPathEditingNotValid)
return false
if (root.selectedOrigin.pairType === Constants.addAccountPopup.keyPairType.profile ||
root.selectedOrigin.pairType === Constants.addAccountPopup.keyPairType.seedImport) {
return valid &&