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:
parent
3566d64f39
commit
81c3463816
|
@ -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(
|
||||
|
|
|
@ -173,6 +173,10 @@ ListModel {
|
|||
title: "StatusEmojiAndColorComboBox"
|
||||
section: "Components"
|
||||
}
|
||||
ListElement {
|
||||
title: "DerivationPathInput"
|
||||
section: "Components"
|
||||
}
|
||||
ListElement {
|
||||
title: "BrowserSettings"
|
||||
section: "Settings"
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
Controller 1.0 Controller.qml
|
||||
Element 1.0 Element.qml
|
|
@ -0,0 +1 @@
|
|||
DerivationPathInput 1.0 DerivationPathInput.qml
|
|
@ -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 &&
|
||||
|
|
Loading…
Reference in New Issue