status-desktop/ui/app/AppLayouts/Wallet/addaccount/panels/DerivationPathInput/Controller.qml

408 lines
17 KiB
QML
Raw Normal View History

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
2023-03-15 22:34:48 +00:00
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
}
}
}