Merge branch 'numerical-input-limits' into 'v3'

Use lt.property/setting validatiors to set numerical input range and step

Closes #457

See merge request openflexure/openflexure-microscope-server!494
This commit is contained in:
Julian Stirling 2026-02-19 16:53:36 +00:00
commit 0709a7b118
5 changed files with 166 additions and 27 deletions

View file

@ -295,13 +295,13 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
# Stacking settings
stack_images_to_save: int = lt.setting(default=1)
stack_images_to_save: int = lt.setting(default=1, ge=1, le=9)
"""The number of images to save in a stack.
Defaults to 1 unless you need to see either side of focus
"""
stack_min_images_to_test: int = lt.setting(default=9)
stack_min_images_to_test: int = lt.setting(default=9, ge=5, le=13)
"""The minimum number of images to capture in a stack.
This many images are captures and tested for focus, if the focus is not central
@ -311,7 +311,7 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
Defaults to 9 which balances reliability and speed.
"""
stack_dz: int = lt.setting(default=50)
stack_dz: int = lt.setting(default=50, ge=10, le=400)
"""Distance in steps between images in a z-stack.
Suggested values:
@ -538,7 +538,9 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
),
property_control_for(
self, "stack_images_to_save", label="Images in Stack to Save"
),
@ -547,9 +549,13 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
"stack_min_images_to_test",
label="Minimum number of images to test for focus",
),
property_control_for(self, "stack_dz", label="Stack dz (steps)"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
property_control_for(self, "max_range", label="Maximum Distance (steps)"),
property_control_for(self, "stack_dz", label="Stack dz (steps)", step=5),
property_control_for(
self, "autofocus_dz", label="Autofocus Range (steps)", step=200
),
property_control_for(
self, "max_range", label="Maximum Distance (steps)", step=1000
),
property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields "
),
@ -662,7 +668,9 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),

View file

@ -97,6 +97,16 @@ class PropertyControl(BaseModel):
when selected.
"""
step: Optional[int | float] = None
"""The step size for a numerical input.
If the property is not numeric this will be ignored.
If this is left as None, then the UI will try to use the ``multipleOf`` field in
the property dataSchema. If ``multipleOf`` is not set then the browser default is
used.
"""
def property_control_for(
thing: lt.Thing, property_name: str, **kwargs: Any

View file

@ -5,7 +5,7 @@
<div class="input-and-buttons-container">
<select
v-model="internalValue"
class="uk-form-small numeric-setting-line-input dropdown"
class="uk-form-small property-input dropdown"
@change="sendValue"
>
<option v-for="(value, display) in options" :key="value" :value="value">
@ -18,23 +18,31 @@
<label v-if="!useDropdown && dataType == 'number'" class="uk-form-label"
>{{ label }}
<div class="input-and-buttons-container">
<input
ref="numericalInput"
v-model="internalValue"
class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled"
type="number"
@input="grabFocus"
@focusout="focusOut"
@keydown="keyDown"
@animationend="animationEnd"
/>
<div class="uk-inline number-wrapper">
<input
ref="numericalInput"
v-model="internalValue"
class="uk-form-small property-input numeric-property"
:class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled"
type="number"
:min="minimum"
:max="maximum"
@input="grabFocus"
@focusout="focusOut"
@keydown="keyDown"
@animationend="animationEnd"
/>
<div class="spinner-buttons">
<button type="button" @click="increment(1)" @mousedown="spinnerClick"></button>
<button type="button" @click="increment(-1)" @mousedown="spinnerClick"></button>
</div>
</div>
<sync-property-button @click="requestUpdate" />
</div>
</label>
<div v-if="!useDropdown && dataType == 'boolean'" class="input-and-buttons-container">
<label class="uk-form-label numeric-setting-line-input">
<label class="uk-form-label property-input">
<input
ref="checkbox"
v-model="internalValue"
@ -54,7 +62,7 @@
v-for="i in valueLength"
:key="i"
v-model="internalValue[i - 1]"
class="uk-form-small numeric-setting-line-input"
class="uk-form-small property-input"
:class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled"
type="number"
@ -73,7 +81,7 @@
<div class="input-and-buttons-container">
<input
v-model="internalValue[key]"
class="uk-form-small numeric-setting-line-input"
class="uk-form-small property-input"
:class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled"
type="number"
@ -91,7 +99,7 @@
<div class="input-and-buttons-container">
<input
v-model="internalValue"
class="uk-form-small numeric-setting-line-input"
class="uk-form-small property-input"
:class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled"
type="text"
@ -107,7 +115,7 @@
<div class="input-and-buttons-container">
<input
v-model="internalValue"
class="uk-form-small numeric-setting-line-input"
class="uk-form-small property-input"
type="text"
disabled="true"
/>
@ -149,6 +157,11 @@ export default {
default: null,
required: false,
},
step: {
type: Number,
default: null,
required: false,
},
},
emits: ["requestUpdate", "sendValue", "animationShown"],
@ -242,6 +255,27 @@ export default {
}
return "other";
},
maximum() {
if (this.dataType !== "number") return undefined;
return this.dataSchema.maximum;
},
minimum() {
if (this.dataType !== "number") return undefined;
return this.dataSchema.minimum;
},
/**
* The step size for numerical spinners.
*
* If not a number this is `undefined`. If `step` is set as a prop then it is used
* otherwise `multipleOf` from the property `dataSchema` is used. If `multipleOf`
* is not set in the `dataSchema` JS will return `undefined` and the browser will
* use its default value.
*/
spinner_step() {
if (this.dataType !== "number") return undefined;
if (this.step !== null) return this.step;
return this.dataSchema.multipleOf;
},
},
watch: {
@ -292,6 +326,37 @@ export default {
this.sendValue();
}
},
increment(sign) {
const step = this.spinner_step || 1;
const multipleOf = this.dataSchema.multipleOf;
let newValue = Number(this.internalValue) || 0;
newValue += sign * step;
// Apply minimum if defined
if (this.minimum !== undefined && this.minimum !== null) {
newValue = Math.max(newValue, this.minimum);
}
// Apply maximum if defined
if (this.maximum !== undefined && this.maximum !== null) {
newValue = Math.min(newValue, this.maximum);
}
// If mulipleOf is validated on server then enforce it.
if (multipleOf) {
newValue = Math.round(newValue / multipleOf) * multipleOf;
}
// Rounding to about 5 sig figs to stop weird javascript rounding.
const magnitude = Math.floor(Math.log10(Math.abs(newValue)));
const factor = 10 ** -(Math.floor(magnitude) - 4);
this.internalValue = Math.round(newValue * factor) / factor;
},
/**
* Grab focus for the numerical input when a spinner is clicked.
*/
spinnerClick(event) {
event.preventDefault();
this.$refs.numericalInput.focus();
},
/** Grab the browser focus.
*
* This is needed for numerical elements to be in focus when a spinner
@ -312,6 +377,17 @@ export default {
if (event.keyCode == 13) {
this.sendValue();
}
// If numeric then capture Up and Down keys for incrementing.
if (this.dataType === "number") {
if (event.key === "ArrowUp") {
event.preventDefault();
this.increment(1);
}
if (event.key === "ArrowDown") {
event.preventDefault();
this.increment(-1);
}
}
},
updateIsEdited: function () {
this.isEdited =
@ -349,12 +425,50 @@ export default {
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
.property-input {
flex-grow: 1;
margin-left: 5px;
margin-right: 5px;
width: 6em;
}
/*Hide standard spinners as it isn't possible to customise the browser ones.*/
.numeric-property {
-moz-appearance: textfield;
}
.numeric-property::-webkit-outer-spin-button,
.numeric-property::-webkit-inner-spin-button {
-webkit-appearance: none;
}
.number-wrapper {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
flex-grow: 1;
}
.spinner-buttons {
position: absolute;
right: 4px;
top: 1px;
bottom: 5px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.spinner-buttons button {
height: 45%;
padding: 0 6px;
line-height: 1;
font-size: 10px;
border: none;
background: transparent;
cursor: pointer;
color: #888;
}
.dropdown {
background-color: #fff;
border: 1px solid #ccc;

View file

@ -5,6 +5,7 @@
:label="label"
:animate="animate"
:options="options"
:step="step"
@request-update="readProperty"
@send-value="writeProperty"
@animation-shown="resetAnimate"
@ -50,6 +51,11 @@ export default {
default: null,
required: false,
},
step: {
type: Number,
default: null,
required: false,
},
},
data() {

View file

@ -7,6 +7,7 @@
:read-back="propertyData.read_back"
:read-back-delay="propertyData.read_back_delay"
:options="propertyData.options"
:step="propertyData.step"
/>
</template>