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 # 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. """The number of images to save in a stack.
Defaults to 1 unless you need to see either side of focus 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. """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 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. 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. """Distance in steps between images in a z-stack.
Suggested values: Suggested values:
@ -538,7 +538,9 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
def settings_ui(self) -> list[PropertyControl]: def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab.""" """A list of PropertyControl objects to create the settings in the scan tab."""
return [ 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( property_control_for(
self, "stack_images_to_save", label="Images in Stack to Save" self, "stack_images_to_save", label="Images in Stack to Save"
), ),
@ -547,9 +549,13 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
"stack_min_images_to_test", "stack_min_images_to_test",
label="Minimum number of images to test for focus", label="Minimum number of images to test for focus",
), ),
property_control_for(self, "stack_dz", label="Stack dz (steps)"), property_control_for(self, "stack_dz", label="Stack dz (steps)", step=5),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"), property_control_for(
property_control_for(self, "max_range", label="Maximum Distance (steps)"), self, "autofocus_dz", label="Autofocus Range (steps)", step=200
),
property_control_for(
self, "max_range", label="Maximum Distance (steps)", step=1000
),
property_control_for( property_control_for(
self, "skip_background", label="Detect and Skip Empty Fields " self, "skip_background", label="Detect and Skip Empty Fields "
), ),
@ -662,7 +668,9 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
def settings_ui(self) -> list[PropertyControl]: def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab.""" """A list of PropertyControl objects to create the settings in the scan tab."""
return [ 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, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"), property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"), property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),

View file

@ -97,6 +97,16 @@ class PropertyControl(BaseModel):
when selected. 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( def property_control_for(
thing: lt.Thing, property_name: str, **kwargs: Any thing: lt.Thing, property_name: str, **kwargs: Any

View file

@ -5,7 +5,7 @@
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<select <select
v-model="internalValue" v-model="internalValue"
class="uk-form-small numeric-setting-line-input dropdown" class="uk-form-small property-input dropdown"
@change="sendValue" @change="sendValue"
> >
<option v-for="(value, display) in options" :key="value" :value="value"> <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 v-if="!useDropdown && dataType == 'number'" class="uk-form-label"
>{{ label }} >{{ label }}
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <div class="uk-inline number-wrapper">
ref="numericalInput" <input
v-model="internalValue" ref="numericalInput"
class="uk-form-small numeric-setting-line-input" v-model="internalValue"
:class="{ edited: isEdited, flash: animateUpdate }" class="uk-form-small property-input numeric-property"
:disabled="isDisabled" :class="{ edited: isEdited, flash: animateUpdate }"
type="number" :disabled="isDisabled"
@input="grabFocus" type="number"
@focusout="focusOut" :min="minimum"
@keydown="keyDown" :max="maximum"
@animationend="animationEnd" @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" /> <sync-property-button @click="requestUpdate" />
</div> </div>
</label> </label>
<div v-if="!useDropdown && dataType == 'boolean'" class="input-and-buttons-container"> <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 <input
ref="checkbox" ref="checkbox"
v-model="internalValue" v-model="internalValue"
@ -54,7 +62,7 @@
v-for="i in valueLength" v-for="i in valueLength"
:key="i" :key="i"
v-model="internalValue[i - 1]" v-model="internalValue[i - 1]"
class="uk-form-small numeric-setting-line-input" class="uk-form-small property-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled" :disabled="isDisabled"
type="number" type="number"
@ -73,7 +81,7 @@
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <input
v-model="internalValue[key]" v-model="internalValue[key]"
class="uk-form-small numeric-setting-line-input" class="uk-form-small property-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled" :disabled="isDisabled"
type="number" type="number"
@ -91,7 +99,7 @@
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <input
v-model="internalValue" v-model="internalValue"
class="uk-form-small numeric-setting-line-input" class="uk-form-small property-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
:disabled="isDisabled" :disabled="isDisabled"
type="text" type="text"
@ -107,7 +115,7 @@
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <input
v-model="internalValue" v-model="internalValue"
class="uk-form-small numeric-setting-line-input" class="uk-form-small property-input"
type="text" type="text"
disabled="true" disabled="true"
/> />
@ -149,6 +157,11 @@ export default {
default: null, default: null,
required: false, required: false,
}, },
step: {
type: Number,
default: null,
required: false,
},
}, },
emits: ["requestUpdate", "sendValue", "animationShown"], emits: ["requestUpdate", "sendValue", "animationShown"],
@ -242,6 +255,27 @@ export default {
} }
return "other"; 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: { watch: {
@ -292,6 +326,37 @@ export default {
this.sendValue(); 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. /** Grab the browser focus.
* *
* This is needed for numerical elements to be in focus when a spinner * This is needed for numerical elements to be in focus when a spinner
@ -312,6 +377,17 @@ export default {
if (event.keyCode == 13) { if (event.keyCode == 13) {
this.sendValue(); 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 () { updateIsEdited: function () {
this.isEdited = this.isEdited =
@ -349,12 +425,50 @@ export default {
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
.numeric-setting-line-input { .property-input {
flex-grow: 1; flex-grow: 1;
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
width: 6em; 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 { .dropdown {
background-color: #fff; background-color: #fff;
border: 1px solid #ccc; border: 1px solid #ccc;

View file

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

View file

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