From 924e7d6b45dd59afb9492e05bc6446e1fe1ca548 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 7 Apr 2020 11:30:36 +0200 Subject: [PATCH] add relative timelock spinner --- .../control/RelativeTimelockSpinner.java | 254 ++++++++++++++++++ .../sparrow/transaction/InputController.java | 12 +- .../sparrow/transaction/input.fxml | 5 +- 3 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/RelativeTimelockSpinner.java diff --git a/src/main/java/com/sparrowwallet/sparrow/control/RelativeTimelockSpinner.java b/src/main/java/com/sparrowwallet/sparrow/control/RelativeTimelockSpinner.java new file mode 100644 index 00000000..c8ef2ed5 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/RelativeTimelockSpinner.java @@ -0,0 +1,254 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.protocol.TransactionInput; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TextFormatter; +import javafx.scene.input.InputEvent; +import javafx.util.StringConverter; + +import java.time.Duration; + +public class RelativeTimelockSpinner extends Spinner { + // Mode represents the unit that is currently being edited. + // For convenience expose methods for incrementing and decrementing that + // unit, and for selecting the appropriate portion in a spinner's editor + enum Mode { + DAYS { + @Override + Duration increment(Duration time, int steps) { + return checkBounds(time, time.plusDays(steps)); + } + + @Override + void select(RelativeTimelockSpinner spinner) { + int index = spinner.getEditor().getText().indexOf('d'); + spinner.getEditor().selectRange(0, index); + } + }, + HOURS { + @Override + Duration increment(Duration time, int steps) { + return checkBounds(time, time.plusHours(steps)); + } + + @Override + void select(RelativeTimelockSpinner spinner) { + int dayIndex = spinner.getEditor().getText().indexOf('d'); + int start = dayIndex > -1 ? dayIndex + 2 : 0; + int hrIndex = spinner.getEditor().getText().indexOf('h'); + spinner.getEditor().selectRange(start, hrIndex); + } + }, + MINUTES { + @Override + Duration increment(Duration time, int steps) { + return checkBounds(time, time.plusMinutes(steps*TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT/60)); + } + + @Override + void select(RelativeTimelockSpinner spinner) { + int hrIndex = spinner.getEditor().getText().indexOf('h'); + int start = hrIndex > -1 ? hrIndex + 2 : 0; + int minIndex = spinner.getEditor().getText().indexOf('m'); + spinner.getEditor().selectRange(start, minIndex); + } + }, + SECONDS { + @Override + Duration increment(Duration time, int steps) { + return time.plusSeconds(steps*TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT); + } + + @Override + void select(RelativeTimelockSpinner spinner) { + int index = spinner.getEditor().getText().indexOf('m'); + spinner.getEditor().selectRange(index + 2, spinner.getEditor().getText().length() - 1); + } + }; + + abstract Duration increment(Duration time, int steps); + + abstract void select(RelativeTimelockSpinner spinner); + + Duration decrement(Duration time, int steps) { + return increment(time, -steps); + } + + Duration checkBounds(Duration oldTime, Duration time) { + long totalSeconds = time.getSeconds(); + + if(totalSeconds < 0) { + return Duration.ZERO; + } + + long maxSeconds = TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK * TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT; + if(totalSeconds > maxSeconds) { + return Duration.ofSeconds(maxSeconds); + } + + return time; + } + } + + // Property containing the current editing mode: + + private final ObjectProperty mode = new SimpleObjectProperty<>(Mode.MINUTES); + + public ObjectProperty modeProperty() { + return mode; + } + + public final Mode getMode() { + return modeProperty().get(); + } + + public final void setMode(Mode mode) { + modeProperty().set(mode); + } + + public RelativeTimelockSpinner(Duration time) { + setEditable(true); + + StringConverter localTimeConverter = new StringConverter<>() { + @Override + public String toString(Duration time) { + if(time.getSeconds()/86400 > 0) { + return time.getSeconds()/86400 + "d " + (time.getSeconds()/3600)%24 + "h"; + } else if(time.getSeconds()/3600 > 0) { + return (time.getSeconds()/3600)%24 + "h " + (time.getSeconds()/60)%60 + "m"; + } else { + return (time.getSeconds()/60)%60 + "m " + time.getSeconds()%60 + "s"; + } + } + + @Override + public Duration fromString(String string) { + long totalSeconds = getTotalSecondsFromString(string); + double rounded = Math.round((double)totalSeconds/TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT); + long roundedSeconds = (long)rounded*TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT; + + Duration duration = Duration.ofSeconds(roundedSeconds); + if(totalSeconds > 86400) { + if(!string.equals(this.toString(duration))) { + if (roundedSeconds < totalSeconds) { + duration = duration.plusSeconds(TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT); + } else { + duration = duration.minusSeconds(TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT); + } + } + } + + long maxSeconds = TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK * TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT; + if(roundedSeconds > maxSeconds) { + return Duration.ofSeconds(maxSeconds); + } + + return duration; + } + + private long getTotalSecondsFromString(String string) { + String[] tokens = string.split(" "); + int days = 0, hours = 0, minutes = 0, seconds = 0; + for(int i = 0; i < tokens.length; i++) { + int value = Integer.parseInt(tokens[i].substring(0, tokens[i].length()-1)); + if(tokens[i].endsWith("d")) { + days = value; + } else if(tokens[i].endsWith("h")) { + hours = value; + } else if(tokens[i].endsWith("m")) { + minutes = value; + } else if(tokens[i].endsWith("s")) { + seconds = value; + } + } + + return ((days * 24 + hours) * 60 + minutes) * 60 + seconds; + } + }; + + // The textFormatter both manages the text <-> LocalTime conversion, + // and vetoes any edits that are not valid. We just make sure we have + // two colons and only digits in between: + + TextFormatter textFormatter = new TextFormatter(localTimeConverter, Duration.ZERO, c -> { + String newText = c.getControlNewText(); + if(newText.matches("[0-9]{0,4}d [0-9]{0,2}h") || + newText.matches("[0-9]{0,2}h [0-9]{0,2}m") || + newText.matches("[0-9]{0,2}m [0-9]{0,2}s")) { + return c; + } + + return null; + }); + + // The spinner value factory defines increment and decrement by + // delegating to the current editing mode: + SpinnerValueFactory valueFactory = new SpinnerValueFactory<>() { + { + setConverter(localTimeConverter); + setValue(time); + } + + @Override + public void decrement(int steps) { + checkMode(); + setValue(mode.get().decrement(getValue(), steps)); + mode.get().select(RelativeTimelockSpinner.this); + } + + @Override + public void increment(int steps) { + checkMode(); + setValue(mode.get().increment(getValue(), steps)); + mode.get().select(RelativeTimelockSpinner.this); + } + + private void checkMode() { + String text = RelativeTimelockSpinner.this.getEditor().getText(); + if(mode.get() == Mode.DAYS && text.indexOf('d') < 0) { + RelativeTimelockSpinner.this.mode.set(Mode.HOURS); + } else if(mode.get() == Mode.HOURS && text.indexOf('h') < 0) { + RelativeTimelockSpinner.this.mode.set(Mode.MINUTES); + } else if(mode.get() == Mode.MINUTES && text.indexOf('m') < 0) { + RelativeTimelockSpinner.this.mode.set(Mode.HOURS); + } else if(mode.get() == Mode.SECONDS && text.indexOf('s') < 0) { + RelativeTimelockSpinner.this.mode.set(Mode.MINUTES); + } + } + }; + + this.setValueFactory(valueFactory); + this.getEditor().setTextFormatter(textFormatter); + + // Update the mode when the user interacts with the editor. + // This is a bit of a hack, e.g. calling spinner.getEditor().positionCaret() + // could result in incorrect state. Directly observing the caretPostion + // didn't work well though; getting that to work properly might be + // a better approach in the long run. + this.getEditor().addEventHandler(InputEvent.ANY, e -> { + int caretPos = this.getEditor().getCaretPosition(); + int dayIndex = this.getEditor().getText().indexOf('d'); + int hrIndex = this.getEditor().getText().indexOf('h'); + int minIndex = this.getEditor().getText().indexOf('m'); + if(caretPos <= dayIndex) { + mode.set(Mode.DAYS); + } else if (caretPos <= hrIndex) { + mode.set(Mode.HOURS); + } else if (caretPos <= minIndex) { + mode.set(Mode.MINUTES); + } else { + mode.set(Mode.SECONDS); + } + }); + + // When the mode changes, select the new portion: + mode.addListener((obs, oldMode, newMode) -> newMode.select(this)); + } + + public RelativeTimelockSpinner() { + this(Duration.ZERO); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java index 1d15e9ab..8df91117 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java @@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.transaction; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBTInput; import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.control.RelativeTimelockSpinner; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.control.*; @@ -13,6 +14,7 @@ import tornadofx.control.Fieldset; import org.fxmisc.flowless.VirtualizedScrollPane; import java.net.URL; +import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -87,7 +89,7 @@ public class InputController extends TransactionFormController implements Initia private Spinner locktimeRelativeBlocks; @FXML - private Spinner locktimeRelativeSeconds; + private RelativeTimelockSpinner locktimeRelativeSeconds; @FXML private ComboBox locktimeRelativeCombo; @@ -217,10 +219,6 @@ public class InputController extends TransactionFormController implements Initia }); locktimeRelativeBlocks.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, (int)TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK, 0)); - locktimeRelativeSeconds.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, - (int)TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK*TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT, 0, - TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT)); - locktimeRelativeBlocks.managedProperty().bind(locktimeRelativeBlocks.visibleProperty()); locktimeRelativeSeconds.managedProperty().bind(locktimeRelativeSeconds.visibleProperty()); locktimeRelativeCombo.getSelectionModel().selectedItemProperty().addListener((ov, old_toggle, new_toggle) -> { @@ -240,7 +238,7 @@ public class InputController extends TransactionFormController implements Initia locktimeRelativeBlocks.valueFactoryProperty().get().setValue((int)txInput.getRelativeLocktime()); locktimeRelativeCombo.getSelectionModel().select(0); } else { - locktimeRelativeSeconds.valueFactoryProperty().get().setValue((int)txInput.getRelativeLocktime() * TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT); + locktimeRelativeSeconds.valueFactoryProperty().get().setValue(Duration.ofSeconds(txInput.getRelativeLocktime() * TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT)); locktimeRelativeCombo.getSelectionModel().select(1); } locktimeToggleGroup.selectToggle(locktimeRelativeType); @@ -272,7 +270,7 @@ public class InputController extends TransactionFormController implements Initia Integer value = locktimeRelativeBlocks.getValue(); txInput.setSequenceNumber(value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK); } else { - Integer value = locktimeRelativeSeconds.getValue() / TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT; + long value = locktimeRelativeSeconds.getValue().toSeconds() / TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT; txInput.setSequenceNumber((value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK) | TransactionInput.RELATIVE_TIMELOCK_TYPE_FLAG); } EventManager.get().notify(transaction); diff --git a/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml b/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml index f47f869a..083f21ca 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/transaction/input.fxml @@ -11,6 +11,7 @@ + @@ -126,12 +127,12 @@ - + - +