From 6c2855896cce44e013ced8eca8fa0685081bc694 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:40:06 -0600 Subject: [PATCH 1/7] refactor(display): extract RTScaledWidgetRepresentation and add show_scale_labels to Tank Move everything in TankRepresentation that only depends on the ScaledPVWidget contract into an abstract RTScaledWidgetRepresentation: creating the RTTank, forwarding value and range updates, evaluating the alarm limits, the orientation transform and the update scheduling. TankRepresentation keeps the Tank specific listeners and colors. The behaviour of the Tank widget does not change. Add a 'show_scale_labels' property (default true) to the Tank so that stacked widgets can share one labelled scale: with the property off, YAxisImpl draws the tick marks and the axis line only. Tick positions are the same as with labels. TankWidgetUnitTest covers the new property. --- .../display/builder/model/Messages.java | 1 + .../builder/model/widgets/ScaledPVWidget.java | 31 +- .../builder/model/widgets/TankWidget.java | 29 +- .../display/builder/model/messages.properties | 1 + .../builder/model/messages_fr.properties | 1 + .../model/widgets/TankWidgetUnitTest.java | 4 + .../widgets/RTScaledWidgetRepresentation.java | 305 ++++++++++++++++++ .../javafx/widgets/TankRepresentation.java | 242 ++------------ .../org/csstudio/javafx/rtplot/RTTank.java | 11 + .../javafx/rtplot/internal/YAxisImpl.java | 39 ++- 10 files changed, 427 insertions(+), 237 deletions(-) create mode 100644 app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index 1f56ddea77..27bdbcc778 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -326,6 +326,7 @@ public class Messages WidgetProperties_ShowLoLo, WidgetProperties_ShowMinorTicks, WidgetProperties_PerpendicularTickLabels, + WidgetProperties_ShowScaleLabels, WidgetProperties_ShowOK, WidgetProperties_ShowScale, WidgetProperties_ShowUnits, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java index 6f8e5ecd11..bdfd7af1b1 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java @@ -22,10 +22,10 @@ import org.csstudio.display.builder.model.WidgetProperty; import org.csstudio.display.builder.model.WidgetPropertyCategory; import org.csstudio.display.builder.model.WidgetPropertyDescriptor; -import org.phoebus.ui.color.NamedWidgetColors; -import org.phoebus.ui.color.WidgetColorService; import org.csstudio.display.builder.model.properties.EnumWidgetProperty; +import org.phoebus.ui.color.NamedWidgetColors; import org.phoebus.ui.color.WidgetColor; +import org.phoebus.ui.color.WidgetColorService; import org.phoebus.ui.vtype.ScaleFormat; /** Base class for PV widgets that display a numeric value on a scale @@ -46,7 +46,7 @@ * overrides the manual LOLO/LO/HI/HIHI levels. New property; * old Phoebus silently ignores the XML element. *
  • Manual {@code minimum} / {@code maximum} range.
  • - *
  • A {@code show_limits} toggle for alarm-limit visual markers.
  • + *
  • A {@code show_alarm_limits} toggle for alarm-limit visual markers.
  • *
  • Manual LOLO / LO / HI / HIHI thresholds (NaN = inactive).
  • *
  • Configurable minor/major alarm colours defaulting to the named * {@code ALARM_MINOR} / {@code ALARM_MAJOR} palette entries.
  • @@ -125,6 +125,31 @@ public EnumWidgetProperty createProperty(final Widget widget, newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "major_alarm_color", Messages.WidgetProperties_MajorAlarmColor); + /** 'scale_visible': show the numeric scale (tick marks and labels) */ + public static final WidgetPropertyDescriptor propScaleVisible = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "scale_visible", + Messages.WidgetProperties_ScaleVisible); + + /** 'show_minor_ticks': show minor tick marks on the scale */ + public static final WidgetPropertyDescriptor propShowMinorTicks = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_minor_ticks", + Messages.WidgetProperties_ShowMinorTicks); + + /** 'opposite_scale_visible': show a second scale on the opposite side */ + public static final WidgetPropertyDescriptor propOppositeScaleVisible = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "opposite_scale_visible", + Messages.WidgetProperties_OppositeScaleVisible); + + /** 'perpendicular_tick_labels': draw scale labels perpendicular to the axis */ + public static final WidgetPropertyDescriptor propPerpendicularTickLabels = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "perpendicular_tick_labels", + Messages.WidgetProperties_PerpendicularTickLabels); + + /** 'show_scale_labels': show tick label text on the scale (ticks are always drawn) */ + public static final WidgetPropertyDescriptor propShowScaleLabels = + newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_scale_labels", + Messages.WidgetProperties_ShowScaleLabels); + // ---- Instance fields ------------------------------------------------ private volatile WidgetProperty format; diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java index 48666c55da..8756a5f411 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java @@ -7,7 +7,6 @@ *******************************************************************************/ package org.csstudio.display.builder.model.widgets; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newBooleanPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newColorPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; @@ -84,26 +83,6 @@ public Widget createWidget() /** 'empty_color' */ public static final WidgetPropertyDescriptor propEmptyColor = newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "empty_color", Messages.WidgetProperties_EmptyColor); - /** 'scale_visible' */ - public static final WidgetPropertyDescriptor propScaleVisible = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "scale_visible", Messages.WidgetProperties_ScaleVisible); - - /** 'show_minor_ticks' */ - public static final WidgetPropertyDescriptor propShowMinorTicks = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_minor_ticks", Messages.WidgetProperties_ShowMinorTicks); - - /** 'perpendicular_tick_labels' — draw scale labels perpendicular - * to the axis direction (horizontal text beside vertical scale) - */ - public static final WidgetPropertyDescriptor propPerpendicularTickLabels = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "perpendicular_tick_labels", Messages.WidgetProperties_PerpendicularTickLabels); - - /** 'opposite_scale_visible' — show a second scale on the opposite - * side of the tank (right for vertical, bottom for horizontal). - * Inspired by CS-Studio BOY which could show markers on both sides. - */ - public static final WidgetPropertyDescriptor propOppositeScaleVisible = - newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "opposite_scale_visible", Messages.WidgetProperties_OppositeScaleVisible); /** Widget configurator to read legacy *.opi files*/ private static class CustomConfigurator extends WidgetConfigurator @@ -168,6 +147,7 @@ public WidgetConfigurator getConfigurator(final Version persisted_version) private volatile WidgetProperty empty_color; private volatile WidgetProperty scale_visible; private volatile WidgetProperty show_minor_ticks; + private volatile WidgetProperty show_scale_labels; private volatile WidgetProperty perpendicular_tick_labels; private volatile WidgetProperty opposite_scale_visible; private volatile WidgetProperty log_scale; @@ -193,6 +173,7 @@ protected void defineProperties(final List> properties) properties.add(scale_visible = propScaleVisible.createProperty(this, true)); properties.add(opposite_scale_visible = propOppositeScaleVisible.createProperty(this, false)); properties.add(show_minor_ticks = propShowMinorTicks.createProperty(this, true)); + properties.add(show_scale_labels = propShowScaleLabels.createProperty(this, true)); properties.add(perpendicular_tick_labels = propPerpendicularTickLabels.createProperty(this, false)); properties.add(log_scale = propLogscale.createProperty(this, false)); properties.add(horizontal = propHorizontal.createProperty(this, false)); @@ -250,6 +231,12 @@ public WidgetProperty propShowMinorTicks() return show_minor_ticks; } + /** @return 'show_scale_labels' property */ + public WidgetProperty propShowScaleLabels() + { + return show_scale_labels; + } + /** @return 'perpendicular_tick_labels' property */ public WidgetProperty propPerpendicularTickLabels() { diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index 278fcc4753..586c0cf6c7 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -310,6 +310,7 @@ WidgetProperties_ShowLimits=Show Limits WidgetProperties_ShowLow=Show Low WidgetProperties_ShowLoLo=Show LoLo WidgetProperties_ShowMinorTicks=Show minor ticks +WidgetProperties_ShowScaleLabels=Show scale labels WidgetProperties_PerpendicularTickLabels=Labels perpendicular to axis WidgetProperties_ShowOK=Show OK WidgetProperties_ShowScale=Show Scale diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties index a5764e7ef7..3bc18b6855 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties @@ -310,6 +310,7 @@ WidgetProperties_ShowLimits=Afficher les limites WidgetProperties_ShowLow=Afficher Low WidgetProperties_ShowLoLo=Afficher LoLo WidgetProperties_ShowMinorTicks=Afficher les petites graduations +WidgetProperties_ShowScaleLabels=Afficher les labels de l'échelle WidgetProperties_PerpendicularTickLabels=Labels perpendiculaires à l'axe WidgetProperties_ShowOK=Afficher OK WidgetProperties_ShowScale=Afficher l'échelle diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java index f544fa699e..57fe2b840b 100644 --- a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java @@ -77,6 +77,7 @@ public void testTankWidgetDefaults() assertThat(tank.propScaleVisible().getValue(), equalTo(true)); assertThat(tank.propOppositeScaleVisible().getValue(), equalTo(false)); assertThat(tank.propShowMinorTicks().getValue(), equalTo(true)); + assertThat(tank.propShowScaleLabels().getValue(), equalTo(true)); assertThat(tank.propPerpendicularTickLabels().getValue(), equalTo(false)); assertThat(tank.propFormat().getValue(), equalTo(ScaleFormat.DEFAULT)); assertThat(tank.propPrecision().getValue(), equalTo(2)); @@ -138,6 +139,7 @@ public void testXmlRoundTrip() throws Exception original.propOppositeScaleVisible().setValue(true); original.propBorderWidth().setValue(3); original.propPerpendicularTickLabels().setValue(true); + original.propShowScaleLabels().setValue(false); original.propFormat().setValue(ScaleFormat.DECIMAL); original.propPrecision().setValue(3); @@ -184,6 +186,7 @@ public void testXmlRoundTrip() throws Exception assertThat(tank.propOppositeScaleVisible().getValue(), equalTo(true)); assertThat(tank.propBorderWidth().getValue(), equalTo(3)); assertThat(tank.propPerpendicularTickLabels().getValue(), equalTo(true)); + assertThat(tank.propShowScaleLabels().getValue(), equalTo(false)); assertThat(tank.propFormat().getValue(), equalTo(ScaleFormat.DECIMAL)); assertThat(tank.propPrecision().getValue(), equalTo(3)); } @@ -213,5 +216,6 @@ public void testNewPropertiesAreOptional() throws Exception assertThat(xml, not(containsString(""))); assertThat(xml, not(containsString(""))); assertThat(xml, not(containsString(""))); + assertThat(xml, not(containsString(""))); } } diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java new file mode 100644 index 0000000000..bff17b53a9 --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java @@ -0,0 +1,305 @@ +/******************************************************************************* + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.representation.javafx.widgets; + +import java.util.concurrent.TimeUnit; + +import org.csstudio.display.builder.model.DirtyFlag; +import org.csstudio.display.builder.model.UntypedWidgetPropertyListener; +import org.csstudio.display.builder.model.WidgetProperty; +import org.csstudio.display.builder.model.WidgetPropertyListener; +import org.csstudio.display.builder.model.util.VTypeUtil; +import org.csstudio.display.builder.model.widgets.ScaledPVWidget; +import org.csstudio.display.builder.representation.Preferences; +import org.csstudio.display.builder.representation.javafx.JFXUtil; +import org.csstudio.javafx.rtplot.RTTank; +import org.epics.util.stats.Range; +import org.epics.vtype.Display; +import org.epics.vtype.VType; + +import javafx.scene.layout.Pane; +import javafx.scene.transform.Rotate; +import javafx.scene.transform.Translate; + +/** Abstract base for widget representations whose JFX node is an {@link RTTank}. + * + *

    Handles all logic that depends only on the {@link ScaledPVWidget} contract: + *

      + *
    • Creating and throttle-configuring the {@link RTTank}
    • + *
    • Forwarding PV value and display-range changes to the tank
    • + *
    • Evaluating alarm limit lines from PV metadata or widget properties
    • + *
    • Orientation transform (rotation for horizontal layout)
    • + *
    • Scheduling representation updates on property changes
    • + *
    + * + *

    Subclasses provide: + *

      + *
    • {@link #isHorizontal()}: the widget's own {@code horizontal} property
    • + *
    • {@link #registerLookListeners()} / {@link #unregisterLookListeners()}: + * listeners on the widget-specific appearance properties + * (colors, scale visibility, font, ...)
    • + *
    • {@link #applyLookToTank()}: push the current appearance properties + * to the tank after size and orientation have been set
    • + *
    + * + * @param concrete {@link ScaledPVWidget} subtype + * @author Heredie Delvalle + */ +@SuppressWarnings("nls") +public abstract class RTScaledWidgetRepresentation + extends RegionBaseRepresentation +{ + /** The rendering canvas shared by all RTTank-based widgets. */ + protected volatile RTTank tank; + + /** Dirty flag for appearance (color, scale, size). Value updates do not + * set this, they bypass the JFX representation update cycle entirely by + * calling {@link RTTank#setValue} directly. */ + protected final DirtyFlag dirty_look = new DirtyFlag(); + + /** Marks appearance dirty and schedules an update. Shared by subclass + * listeners on color / scale / font properties. */ + protected final UntypedWidgetPropertyListener lookListener = + (p, o, n) -> { dirty_look.mark(); toolkit.scheduleUpdate(this); }; + + /** Forwards PV value or display-range changes to the tank immediately. */ + private final UntypedWidgetPropertyListener valueListener = this::valueChanged; + + /** Re-evaluates and pushes alarm limit lines whenever a limit property changes. */ + private final UntypedWidgetPropertyListener limitsListener = this::limitsChanged; + + /** Swaps width and height in the editor and triggers a look update. */ + protected final WidgetPropertyListener orientationChangedListener = + this::orientationChanged; + + /** Whether orientation transforms are currently applied to the tank node. */ + private boolean was_transformed = false; + + @Override + public Pane createJFXNode() throws Exception + { + tank = new RTTank(); + tank.setUpdateThrottle(Preferences.image_update_delay, TimeUnit.MILLISECONDS); + return new Pane(tank); + } + + /** Register listeners on the {@link ScaledPVWidget} value and limit + * properties, then call {@link #registerLookListeners()} for the + * subclass to add its widget-specific appearance listeners. + *

    The current range, value and limits are applied once at the end. */ + @Override + protected void registerListeners() + { + super.registerListeners(); + + // Value / range + model_widget.propLimitsFromPV().addUntypedPropertyListener(valueListener); + model_widget.propMinimum().addUntypedPropertyListener(valueListener); + model_widget.propMaximum().addUntypedPropertyListener(valueListener); + model_widget.runtimePropValue().addUntypedPropertyListener(valueListener); + + // Alarm limit lines + model_widget.propShowAlarmLimits().addUntypedPropertyListener(limitsListener); + model_widget.propAlarmLimitsFromPV().addUntypedPropertyListener(limitsListener); + model_widget.propLevelLoLo().addUntypedPropertyListener(limitsListener); + model_widget.propLevelLow().addUntypedPropertyListener(limitsListener); + model_widget.propLevelHigh().addUntypedPropertyListener(limitsListener); + model_widget.propLevelHiHi().addUntypedPropertyListener(limitsListener); + + // Alarm color changes only affect appearance, not limits + model_widget.propMinorAlarmColor().addUntypedPropertyListener(lookListener); + model_widget.propMajorAlarmColor().addUntypedPropertyListener(lookListener); + + // Widget-specific look properties (colors, scale, font, ...) + registerLookListeners(); + + // Apply the current state, range first, then limits + valueChanged(null, null, null); + limitsChanged(null, null, null); + } + + /** Register listeners on widget-specific appearance properties. + * The implementation should add listeners using {@link #lookListener} + * (or a dedicated listener) and call nothing on the tank directly, + * that happens in {@link #applyLookToTank()}. */ + protected abstract void registerLookListeners(); + + @Override + protected void unregisterListeners() + { + model_widget.propLimitsFromPV().removePropertyListener(valueListener); + model_widget.propMinimum().removePropertyListener(valueListener); + model_widget.propMaximum().removePropertyListener(valueListener); + model_widget.runtimePropValue().removePropertyListener(valueListener); + + model_widget.propShowAlarmLimits().removePropertyListener(limitsListener); + model_widget.propAlarmLimitsFromPV().removePropertyListener(limitsListener); + model_widget.propLevelLoLo().removePropertyListener(limitsListener); + model_widget.propLevelLow().removePropertyListener(limitsListener); + model_widget.propLevelHigh().removePropertyListener(limitsListener); + model_widget.propLevelHiHi().removePropertyListener(limitsListener); + + model_widget.propMinorAlarmColor().removePropertyListener(lookListener); + model_widget.propMajorAlarmColor().removePropertyListener(lookListener); + + unregisterLookListeners(); + super.unregisterListeners(); + } + + /** Unregister the listeners added by {@link #registerLookListeners()}. */ + protected abstract void unregisterLookListeners(); + + /** Called on every PV value update and on range-related property changes. + * Updates the range, the alarm limits from the PV metadata and the + * fill level of the tank. */ + private void valueChanged(final WidgetProperty prop, + final Object old_value, final Object new_value) + { + final VType vtype = model_widget.runtimePropValue().getValue(); + final boolean limits_from_pv = model_widget.propLimitsFromPV().getValue(); + updateRange(vtype, limits_from_pv); + + if (model_widget.propAlarmLimitsFromPV().getValue()) + applyAlarmLimits(vtype); + + // In edit mode there is no PV, so the widget range is the effective range + final double min = model_widget.propMinimum().getValue(); + final double max = model_widget.propMaximum().getValue(); + final double value = toolkit.isEditMode() + ? (min + max) / 2.0 + : VTypeUtil.getValueNumber(vtype).doubleValue(); + tank.setValue(value); + } + + /** Push the display range to the tank. + * When {@code limits_from_pv} is {@code true}, reads the range from PV + * display metadata and falls back to widget properties when metadata is + * unavailable. When {@code false}, uses the widget properties directly. + * + * @param vtype current PV value (may be {@code null} before connect) + * @param limits_from_pv whether the range should come from the PV */ + private void updateRange(final VType vtype, final boolean limits_from_pv) + { + double min = model_widget.propMinimum().getValue(); + double max = model_widget.propMaximum().getValue(); + if (limits_from_pv) + { + final Display display_info = Display.displayOf(vtype); + if (display_info != null && display_info.getDisplayRange().isFinite()) + { + min = display_info.getDisplayRange().getMinimum(); + max = display_info.getDisplayRange().getMaximum(); + } + } + tank.setRange(min, max); + } + + /** Triggered when any alarm limit property changes; delegates to + * {@link #applyAlarmLimits(VType)} with the current PV value. */ + private void limitsChanged(final WidgetProperty property, + final Object old_value, final Object new_value) + { + applyAlarmLimits(model_widget.runtimePropValue().getValue()); + } + + /** Resolves alarm limits from PV metadata or widget properties (depending on + * {@code alarm_limits_from_pv}) and pushes them to the tank. + * Clears all limit lines when {@code show_alarm_limits} is {@code false}. */ + private void applyAlarmLimits(final VType vtype) + { + if (!model_widget.propShowAlarmLimits().getValue()) + { + tank.setLimits(Double.NaN, Double.NaN, Double.NaN, Double.NaN); + return; + } + final double lolo, lo, hi, hihi; + if (model_widget.propAlarmLimitsFromPV().getValue()) + { + final Display display_info = Display.displayOf(vtype); + if (display_info != null) + { + final Range minor = display_info.getWarningRange(); + final Range major = display_info.getAlarmRange(); + lo = minor.getMinimum(); + hi = minor.getMaximum(); + lolo = major.getMinimum(); + hihi = major.getMaximum(); + } + else + lolo = lo = hi = hihi = Double.NaN; + } + else + { + lolo = model_widget.propLevelLoLo().getValue(); + lo = model_widget.propLevelLow().getValue(); + hi = model_widget.propLevelHigh().getValue(); + hihi = model_widget.propLevelHiHi().getValue(); + } + tank.setLimits(lolo, lo, hi, hihi); + tank.setLimitsFromPV(model_widget.propAlarmLimitsFromPV().getValue()); + } + + /** @return whether this widget is currently in horizontal orientation */ + protected abstract boolean isHorizontal(); + + /** Swaps width and height in the editor (so the widget visually rotates + * rather than stretching) and triggers a look update. */ + protected void orientationChanged(final WidgetProperty prop, + final Boolean old, final Boolean horizontal) + { + if (toolkit.isEditMode()) + { + final int w = model_widget.propWidth().getValue(); + final int h = model_widget.propHeight().getValue(); + model_widget.propWidth().setValue(h); + model_widget.propHeight().setValue(w); + } + dirty_look.mark(); + toolkit.scheduleUpdate(this); + } + + /** Push the current widget-specific appearance properties to the tank. + * Called from {@link #updateChanges()} after size and orientation + * have been applied. */ + protected abstract void applyLookToTank(); + + @Override + public void updateChanges() + { + super.updateChanges(); + if (dirty_look.checkAndClear()) + { + final double width = model_widget.propWidth().getValue(); + final double height = model_widget.propHeight().getValue(); + + // RTTank renders vertically; rotate 90 degrees clockwise for horizontal bars. + if (isHorizontal()) + { + tank.getTransforms().setAll(new Translate(width, 0), + new Rotate(90, 0, 0)); + was_transformed = true; + tank.setWidth(height); + tank.setHeight(width); + } + else + { + if (was_transformed) + tank.getTransforms().clear(); + was_transformed = false; + tank.setWidth(width); + tank.setHeight(height); + } + jfx_node.setPrefSize(width, height); + + applyLookToTank(); + tank.setAlarmColors( + JFXUtil.convert(model_widget.propMinorAlarmColor().getValue()), + JFXUtil.convert(model_widget.propMajorAlarmColor().getValue())); + } + } +} diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java index 8e3d373914..2d514e86b0 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2023 Oak Ridge National Laboratory. + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -7,52 +7,31 @@ *******************************************************************************/ package org.csstudio.display.builder.representation.javafx.widgets; -import java.util.concurrent.TimeUnit; - -import org.csstudio.display.builder.model.DirtyFlag; -import org.csstudio.display.builder.model.UntypedWidgetPropertyListener; -import org.csstudio.display.builder.model.WidgetProperty; -import org.csstudio.display.builder.model.WidgetPropertyListener; -import org.csstudio.display.builder.model.util.VTypeUtil; import org.csstudio.display.builder.model.widgets.TankWidget; -import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.javafx.JFXUtil; -import org.csstudio.javafx.rtplot.RTTank; -import org.epics.util.stats.Range; -import org.epics.vtype.Display; -import org.epics.vtype.VType; - -import javafx.scene.layout.Pane; -import javafx.scene.transform.Rotate; -import javafx.scene.transform.Translate; -/** Creates JavaFX item for model widget +/** Creates JavaFX item for the Tank widget. + * + *

    All shared RTTank wiring (value updates, alarm limits, + * orientation handling) lives in {@link RTScaledWidgetRepresentation}. + * This class contributes only the Tank-specific appearance properties: + * background, foreground, fill and empty colors. + * * @author Kay Kasemir - * @author Heredie Delvalle — CLS, alarm limits, dual scale, - * format/precision wiring + * @author Heredie Delvalle */ -public class TankRepresentation extends RegionBaseRepresentation +@SuppressWarnings("nls") +public class TankRepresentation extends RTScaledWidgetRepresentation { - private final DirtyFlag dirty_look = new DirtyFlag(); - private final UntypedWidgetPropertyListener lookListener = this::lookChanged; - private final UntypedWidgetPropertyListener valueListener = this::valueChanged; - private final UntypedWidgetPropertyListener limitsListener = this::limitsChanged; - private final WidgetPropertyListener orientationChangedListener = this::orientationChanged; - - private volatile RTTank tank; - @Override - public Pane createJFXNode() throws Exception + protected boolean isHorizontal() { - tank = new RTTank(); - tank.setUpdateThrottle(Preferences.image_update_delay, TimeUnit.MILLISECONDS); - return new Pane(tank); + return model_widget.propHorizontal().getValue(); } @Override - protected void registerListeners() + protected void registerLookListeners() { - super.registerListeners(); model_widget.propWidth().addUntypedPropertyListener(lookListener); model_widget.propHeight().addUntypedPropertyListener(lookListener); model_widget.propFont().addUntypedPropertyListener(lookListener); @@ -62,36 +41,18 @@ protected void registerListeners() model_widget.propEmptyColor().addUntypedPropertyListener(lookListener); model_widget.propScaleVisible().addUntypedPropertyListener(lookListener); model_widget.propShowMinorTicks().addUntypedPropertyListener(lookListener); + model_widget.propShowScaleLabels().addUntypedPropertyListener(lookListener); model_widget.propPerpendicularTickLabels().addUntypedPropertyListener(lookListener); model_widget.propFormat().addUntypedPropertyListener(lookListener); model_widget.propPrecision().addUntypedPropertyListener(lookListener); - model_widget.propMinorAlarmColor().addUntypedPropertyListener(lookListener); - model_widget.propMajorAlarmColor().addUntypedPropertyListener(lookListener); model_widget.propOppositeScaleVisible().addUntypedPropertyListener(lookListener); model_widget.propBorderWidth().addUntypedPropertyListener(lookListener); model_widget.propLogScale().addUntypedPropertyListener(lookListener); - - // Range and fill-level; need re-evaluation on every PV sample - model_widget.propLimitsFromPV().addUntypedPropertyListener(valueListener); - model_widget.propMinimum().addUntypedPropertyListener(valueListener); - model_widget.propMaximum().addUntypedPropertyListener(valueListener); - model_widget.runtimePropValue().addUntypedPropertyListener(valueListener); - // Alarm limits; only need re-evaluation when limit properties change. - // When alarm_limits_from_pv=true, valueChanged() calls applyAlarmLimits() too. - model_widget.propShowAlarmLimits().addUntypedPropertyListener(limitsListener); - model_widget.propAlarmLimitsFromPV().addUntypedPropertyListener(limitsListener); - model_widget.propLevelLoLo().addUntypedPropertyListener(limitsListener); - model_widget.propLevelLow().addUntypedPropertyListener(limitsListener); - model_widget.propLevelHigh().addUntypedPropertyListener(limitsListener); - model_widget.propLevelHiHi().addUntypedPropertyListener(limitsListener); model_widget.propHorizontal().addPropertyListener(orientationChangedListener); - // Initial apply — order matters: range first, then limits, then value - valueChanged(null, null, null); - limitsChanged(null, null, null); } @Override - protected void unregisterListeners() + protected void unregisterLookListeners() { model_widget.propWidth().removePropertyListener(lookListener); model_widget.propHeight().removePropertyListener(lookListener); @@ -102,171 +63,32 @@ protected void unregisterListeners() model_widget.propEmptyColor().removePropertyListener(lookListener); model_widget.propScaleVisible().removePropertyListener(lookListener); model_widget.propShowMinorTicks().removePropertyListener(lookListener); + model_widget.propShowScaleLabels().removePropertyListener(lookListener); model_widget.propPerpendicularTickLabels().removePropertyListener(lookListener); model_widget.propFormat().removePropertyListener(lookListener); model_widget.propPrecision().removePropertyListener(lookListener); - model_widget.propMinorAlarmColor().removePropertyListener(lookListener); - model_widget.propMajorAlarmColor().removePropertyListener(lookListener); model_widget.propOppositeScaleVisible().removePropertyListener(lookListener); model_widget.propBorderWidth().removePropertyListener(lookListener); model_widget.propLogScale().removePropertyListener(lookListener); - - model_widget.propLimitsFromPV().removePropertyListener(valueListener); - model_widget.propMinimum().removePropertyListener(valueListener); - model_widget.propMaximum().removePropertyListener(valueListener); - model_widget.runtimePropValue().removePropertyListener(valueListener); - model_widget.propShowAlarmLimits().removePropertyListener(limitsListener); - model_widget.propAlarmLimitsFromPV().removePropertyListener(limitsListener); - model_widget.propLevelLoLo().removePropertyListener(limitsListener); - model_widget.propLevelLow().removePropertyListener(limitsListener); - model_widget.propLevelHigh().removePropertyListener(limitsListener); - model_widget.propLevelHiHi().removePropertyListener(limitsListener); model_widget.propHorizontal().removePropertyListener(orientationChangedListener); - super.unregisterListeners(); } - private void lookChanged(final WidgetProperty property, final Object old_value, final Object new_value) - { - dirty_look.mark(); - toolkit.scheduleUpdate(this); - } - - /** Update the display range and fill level. Called on every PV value change. - * Alarm limits from PV metadata are also refreshed here (the metadata is - * carried inside the VType on every update). Manually-configured limits - * are managed exclusively by {@link #limitsChanged}. - */ - private void valueChanged(final WidgetProperty property, final Object old_value, final Object new_value) - { - final VType vtype = model_widget.runtimePropValue().getValue(); - - double min_val = model_widget.propMinimum().getValue(); - double max_val = model_widget.propMaximum().getValue(); - if (model_widget.propLimitsFromPV().getValue()) - { - final Display display_info = Display.displayOf(vtype); - if (display_info != null && display_info.getDisplayRange().isFinite()) - { - min_val = display_info.getDisplayRange().getMinimum(); - max_val = display_info.getDisplayRange().getMaximum(); - } - } - tank.setRange(min_val, max_val); - - // Alarm metadata is embedded in the VType, so re-check it on every update. - // When using widget-configured limits, limitsChanged() handles updates instead. - if (model_widget.propAlarmLimitsFromPV().getValue()) - applyAlarmLimits(vtype); - - final double value = toolkit.isEditMode() - ? (min_val + max_val) / 2 - : VTypeUtil.getValueNumber(vtype).doubleValue(); - tank.setValue(value); - } - - /** Re-apply alarm limit lines. Called when any limit property changes. - * Also invoked from {@link #valueChanged} when limits come from the PV. - */ - private void limitsChanged(final WidgetProperty property, final Object old_value, final Object new_value) - { - applyAlarmLimits(model_widget.runtimePropValue().getValue()); - } - - /** Push the current alarm limits to the tank, reading from PV metadata or - * widget properties depending on {@code alarm_limits_from_pv}. - * Clears all limit lines when {@code show_alarm_limits} is {@code false}. - */ - private void applyAlarmLimits(final VType vtype) - { - if (!model_widget.propShowAlarmLimits().getValue()) - { - tank.setLimits(Double.NaN, Double.NaN, Double.NaN, Double.NaN); - return; - } - final double lolo, lo, hi, hihi; - if (model_widget.propAlarmLimitsFromPV().getValue()) - { - final Display display_info = Display.displayOf(vtype); - if (display_info != null) - { - final Range minor = display_info.getWarningRange(); - final Range major = display_info.getAlarmRange(); - lo = minor.getMinimum(); - hi = minor.getMaximum(); - lolo = major.getMinimum(); - hihi = major.getMaximum(); - } - else - { // PV connected but no metadata yet — show nothing - lolo = lo = hi = hihi = Double.NaN; - } - } - else - { - lolo = model_widget.propLevelLoLo().getValue(); - lo = model_widget.propLevelLow().getValue(); - hi = model_widget.propLevelHigh().getValue(); - hihi = model_widget.propLevelHiHi().getValue(); - } - tank.setLimits(lolo, lo, hi, hihi); - tank.setLimitsFromPV(model_widget.propAlarmLimitsFromPV().getValue()); - } - - private void orientationChanged(final WidgetProperty prop, final Boolean old, final Boolean horizontal) - { - if (toolkit.isEditMode()) - { // Swap width <-> height so widget basically rotates - final int w = model_widget.propWidth().getValue(); - final int h = model_widget.propHeight().getValue(); - model_widget.propWidth().setValue(h); - model_widget.propHeight().setValue(w); - } - lookChanged(prop, old, horizontal); - } - - /** Track if we ever set transformations because just 'clearing' would otherwise allocate them */ - private boolean was_transformed = false; - @Override - public void updateChanges() + protected void applyLookToTank() { - super.updateChanges(); - if (dirty_look.checkAndClear()) - { - double width = model_widget.propWidth().getValue(); - double height = model_widget.propHeight().getValue(); - if (model_widget.propHorizontal().getValue()) - { - tank.getTransforms().setAll(new Translate(width, 0), - new Rotate(90, 0, 0)); - was_transformed = true; - tank.setWidth(height); - tank.setHeight(width); - } - else - { - if (was_transformed) - tank.getTransforms().clear(); - tank.setWidth(width); - tank.setHeight(height); - } - jfx_node.setPrefSize(width, height); - tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); - tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); - tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); - tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); - tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); - tank.setScaleVisible(model_widget.propScaleVisible().getValue()); - tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); - tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); - tank.setLogScale(model_widget.propLogScale().getValue()); - tank.setLabelFormat(model_widget.propFormat().getValue(), - model_widget.propPrecision().getValue()); - tank.setAlarmColors( - JFXUtil.convert(model_widget.propMinorAlarmColor().getValue()), - JFXUtil.convert(model_widget.propMajorAlarmColor().getValue())); - tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); - tank.setBorderWidth(model_widget.propBorderWidth().getValue()); - } + tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); + tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); + tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); + tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); + tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); + tank.setScaleVisible(model_widget.propScaleVisible().getValue()); + tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); + tank.setScaleLabelsVisible(model_widget.propShowScaleLabels().getValue()); + tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); + tank.setLogScale(model_widget.propLogScale().getValue()); + tank.setLabelFormat(model_widget.propFormat().getValue(), + model_widget.propPrecision().getValue()); + tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); + tank.setBorderWidth(model_widget.propBorderWidth().getValue()); } } diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 8c294c7b05..7ed55b8b34 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -284,6 +284,17 @@ public void setShowMinorTicks(final boolean show) requestUpdate(); } + /** Show or hide the tick labels while keeping the tick marks. + * Stacked widgets can then share one labelled scale: only the first + * shows labels, the others show aligned tick marks. + * @param visible {@code true} (default) for labels, {@code false} for ticks only */ + public void setScaleLabelsVisible(final boolean visible) + { + // The axes request layout and refresh themselves when this changes + scale.setScaleLabelsVisible(visible); + right_scale.setScaleLabelsVisible(visible); + } + /** Configure the number format used for scale tick labels. * @param format Display format; {@code null} or {@link ScaleFormat#DEFAULT} restores automatic formatting. * @param precision Number of decimal places; clamped to [0, 15]. diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java index 109ad205c5..0df928fe37 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/YAxisImpl.java @@ -60,6 +60,11 @@ public class YAxisImpl> extends NumericAxis impl /** Show on right side? */ private volatile boolean is_right = false; + /** When {@code false}, only the tick marks are drawn: no tick labels + * and no axis label. Stacked widgets can then share one labelled scale. + * Read on the render thread, written from the JavaFX thread. */ + private volatile boolean show_labels = true; + /** When {@code true}, rotated tick labels always use the 'up' direction * (bottom-to-top) regardless of {@link #is_right}. This keeps the text * orientation of a right-side scale identical to a left-side scale. @@ -161,6 +166,20 @@ public void setForceTextUp(final boolean force) force_text_up = force; } + /** Show or hide the tick labels and the axis label while keeping the + * tick marks. Tick positions do not change, so stacked widgets can + * share one labelled scale. + * @param show {@code true} (default) for labels, {@code false} for ticks only + */ + public void setScaleLabelsVisible(final boolean show) + { + if (show_labels == show) + return; + show_labels = show; + requestLayout(); + requestRefresh(); + } + /** Add trace to axis * @param trace {@link Trace} * @throws IllegalArgumentException if trace already on axis @@ -210,6 +229,11 @@ public int getDesiredPixelSize(final Rectangle region, final Graphics2D gc) return 0; this.region = region; + + // Ticks only: the tick marks plus the axis line + if (!show_labels) + return TICK_LENGTH + 1; + gc.setFont(label_font); FontMetrics metrics = gc.getFontMetrics(); @@ -334,6 +358,10 @@ public int[] getPixelGaps(final Graphics2D gc) if (! isVisible()) return super.getPixelGaps(gc); + // Ticks-only mode: no labels extend past the tick positions. + if (!show_labels) + return new int[] { 0, 0 }; + gc.setFont(scale_font); final FontMetrics metrics = gc.getFontMetrics(); @@ -392,7 +420,9 @@ public void paint(final Graphics2D gc, final Rectangle plot_bounds) // Skip the visibility pass when LogTicks already thinned the labeled set: // a second greedy pass would destroy the intentional symmetric spacing. final boolean skipVisibility = (ticks instanceof LogTicks) && ((LogTicks) ticks).isThinned(); - final boolean[] showLabel = skipVisibility + final boolean[] showLabel = !show_labels + ? new boolean[majorTicks.size()] + : skipVisibility ? allLabeled(majorTicks) : computeTickLabelVisibility(majorTicks, gc.getFontMetrics()); @@ -428,8 +458,11 @@ public void paint(final Graphics2D gc, final Rectangle plot_bounds) gc.setColor(old_fg); gc.setBackground(old_bg); - gc.setFont(label_font); - paintLabels(gc); + if (show_labels) + { + gc.setFont(label_font); + paintLabels(gc); + } } protected void paintLabels(final Graphics2D gc) From 18dad8756b5ffe2504119c07494c9d5f39076435 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:46:02 -0600 Subject: [PATCH 2/7] feat(display): make ProgressBarWidget a ScaledPVWidget Move the progress bar onto the ScaledPVWidget base class that the Tank already uses, so it carries the same range, scale and alarm limit properties, plus an inner padding. The scale look properties (font, foreground and fill color, log scale, scale visibility, tick options, border width) now live in ScaledPVWidget with a defineScaleLookProperties() helper, and the Tank uses the same fields in its own order. The border keeps the Tank's 'tank_border_width' name for all scaled widgets: legacy BOY files carry a 'border_width' element for every widget, which must not become a bar border. The stock renderer ignores the additions, and existing .bob files keep loading unchanged since the pre-existing properties keep their names. The BOY importer picks up 'show_scale', 'scale_font' and the 'level_hi' and 'level_lo' names that differ from ours. ProgressBarWidgetUnitTest covers defaults, legacy .bob and BOY files, the XML round trip and the list of renderer-only properties. --- .../display/builder/model/Messages.java | 1 + .../model/widgets/ProgressBarWidget.java | 114 +++++---- .../builder/model/widgets/ScaledPVWidget.java | 93 +++++++ .../builder/model/widgets/TankWidget.java | 80 +----- .../display/builder/model/messages.properties | 1 + .../builder/model/messages_fr.properties | 1 + .../widgets/ProgressBarWidgetUnitTest.java | 232 ++++++++++++++++++ .../model/widgets/TankWidgetUnitTest.java | 12 + 8 files changed, 401 insertions(+), 133 deletions(-) create mode 100644 app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index 27bdbcc778..9a62e6a502 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -241,6 +241,7 @@ public class Messages WidgetProperties_HourTickMarkColor, WidgetProperties_HourTickMarkVisible, WidgetProperties_Increment, + WidgetProperties_InnerPadding, WidgetProperties_InitialIndex, WidgetProperties_Insets, WidgetProperties_Interactive, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java index 193cb01ecf..a8de7841f5 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ProgressBarWidget.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2022 Oak Ridge National Laboratory. + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -8,15 +8,11 @@ package org.csstudio.display.builder.model.widgets; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propHorizontal; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propLimitsFromPV; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMaximum; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMinimum; -import static org.csstudio.display.builder.model.widgets.plots.PlotWidgetProperties.propLogscale; import java.util.Arrays; import java.util.List; +import java.util.Set; import org.csstudio.display.builder.model.Version; import org.csstudio.display.builder.model.Widget; @@ -34,12 +30,41 @@ import org.w3c.dom.Element; /** Widget that displays a progress bar + * + *

    Extends {@link ScaledPVWidget} so the bar offers the same range, + * scale and alarm limit properties as the {@link TankWidget}. + * The scale related properties only take effect with the RTTank based + * renderer, see {@link #SCALE_MODE_PROPS}. + * + *

    Existing {@code .bob} files load unchanged: {@code fill_color}, + * {@code background_color}, {@code horizontal}, {@code limits_from_pv}, + * {@code minimum}, {@code maximum} and {@code log_scale} keep their + * XML names. Older Phoebus versions ignore the new properties. + * * @author Kay Kasemir * @author Amanda Carpenter + * @author Heredie Delvalle - CLS, scale support */ @SuppressWarnings("nls") -public class ProgressBarWidget extends PVWidget +public class ProgressBarWidget extends ScaledPVWidget { + /** Properties that only have an effect with the RTTank based renderer + * ({@code progressbar_scale_mode=true}). + * + *

    The property editor hides these while the stock JavaFX renderer + * is in use. Properties that both renderers honour, like the range, + * {@code horizontal}, {@code log_scale}, fill and background color, + * are not listed here. + */ + public static final Set SCALE_MODE_PROPS = Set.of( + "format", "precision", "font", "foreground_color", + "scale_visible", "show_minor_ticks", "show_scale_labels", + "opposite_scale_visible", "perpendicular_tick_labels", + "inner_padding", "tank_border_width", + "alarm_limits_from_pv", "show_alarm_limits", + "level_lolo", "level_low", "level_high", "level_hihi", + "minor_alarm_color", "major_alarm_color"); + /** Widget descriptor */ public static final WidgetDescriptor WIDGET_DESCRIPTOR = new WidgetDescriptor("progressbar", WidgetCategory.MONITOR, @@ -55,7 +80,7 @@ public Widget createWidget() } }; - /** Widget configurator to read legacy *.opi files*/ + /** Widget configurator to read legacy *.opi files */ private static class ProgressBarConfigurator extends WidgetConfigurator { public ProgressBarConfigurator(final Version xml_version) @@ -82,14 +107,18 @@ public boolean configureFromXML(final ModelReader model_reader, final Widget wid bar.propY().setValue(bar.propY().getValue() + reduce); bar.propHeight().setValue(bar.propHeight().getValue() - reduce); } - // Do use space below where BOY placed markers for the bar itself. - // In the future, there could be a scale. final Element el = XMLUtil.getChildElement(xml, "color_fillbackground"); if (el != null) bar.propBackgroundColor().readFromXML(model_reader, el); - // Create text update for the value indicator + // BOY names that differ from ours. level_hihi and level_lolo match. + readLegacyElement(model_reader, xml, "show_scale", bar.propScaleVisible()); + readLegacyElement(model_reader, xml, "scale_font", bar.propFont()); + readLegacyElement(model_reader, xml, "level_hi", bar.propLevelHigh()); + readLegacyElement(model_reader, xml, "level_lo", bar.propLevelLow()); + + // Create a companion TextUpdate widget for the BOY value label. if (XMLUtil.getChildBoolean(xml, "show_label").orElse(true)) { final Document doc = xml.getOwnerDocument(); @@ -116,6 +145,14 @@ public boolean configureFromXML(final ModelReader model_reader, final Widget wid return true; } + + private static void readLegacyElement(final ModelReader model_reader, final Element xml, + final String name, final WidgetProperty property) throws Exception + { + final Element element = XMLUtil.getChildElement(xml, name); + if (element != null) + property.readFromXML(model_reader, element); + } } @Override @@ -125,37 +162,24 @@ public WidgetConfigurator getConfigurator(final Version persisted_version) return new ProgressBarConfigurator(persisted_version); } - private volatile WidgetProperty limits_from_pv; - private volatile WidgetProperty minimum; - private volatile WidgetProperty maximum; - private volatile WidgetProperty log_scale; - private volatile WidgetProperty fill_color; private volatile WidgetProperty background_color; - private volatile WidgetProperty horizontal; + private volatile WidgetProperty horizontal; + private volatile WidgetProperty inner_padding; /** Constructor */ public ProgressBarWidget() { - super(WIDGET_DESCRIPTOR.getType()); + super(WIDGET_DESCRIPTOR.getType(), 100, 20); } @Override protected void defineProperties(final List> properties) { super.defineProperties(properties); - properties.add(fill_color = propFillColor.createProperty(this, new WidgetColor(60, 255, 60))); + defineScaleLookProperties(properties, false, false); properties.add(background_color = propBackgroundColor.createProperty(this, new WidgetColor(250, 250, 250))); - properties.add(limits_from_pv = propLimitsFromPV.createProperty(this, true)); - properties.add(minimum = propMinimum.createProperty(this, 0.0)); - properties.add(maximum = propMaximum.createProperty(this, 100.0)); - properties.add(log_scale = propLogscale.createProperty(this, false)); - properties.add(horizontal = propHorizontal.createProperty(this, true)); - } - - /** @return 'fill_color' property */ - public WidgetProperty propFillColor() - { - return fill_color; + properties.add(horizontal = propHorizontal.createProperty(this, true)); + properties.add(inner_padding = propInnerPadding.createProperty(this, 3)); } /** @return 'background_color' property */ @@ -164,33 +188,15 @@ public WidgetProperty propBackgroundColor() return background_color; } - /** @return 'limits_from_pv' property */ - public WidgetProperty propLimitsFromPV() - { - return limits_from_pv; - } - - /** @return 'minimum' property */ - public WidgetProperty propMinimum() - { - return minimum; - } - - /** @return 'maximum' property */ - public WidgetProperty propMaximum() - { - return maximum; - } - - /** @return 'log_scale' property */ - public WidgetProperty propLogScale() - { - return log_scale; - } - /** @return 'horizontal' property */ public WidgetProperty propHorizontal() { return horizontal; } + + /** @return 'inner_padding' property */ + public WidgetProperty propInnerPadding() + { + return inner_padding; + } } diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java index bdfd7af1b1..b2478b5001 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ScaledPVWidget.java @@ -11,9 +11,13 @@ import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newColorPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newDoublePropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propForegroundColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propLimitsFromPV; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMaximum; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMinimum; +import static org.csstudio.display.builder.model.widgets.plots.PlotWidgetProperties.propLogscale; import java.util.List; @@ -22,7 +26,10 @@ import org.csstudio.display.builder.model.WidgetProperty; import org.csstudio.display.builder.model.WidgetPropertyCategory; import org.csstudio.display.builder.model.WidgetPropertyDescriptor; +import org.csstudio.display.builder.model.persist.NamedWidgetFonts; +import org.csstudio.display.builder.model.persist.WidgetFontService; import org.csstudio.display.builder.model.properties.EnumWidgetProperty; +import org.csstudio.display.builder.model.properties.WidgetFont; import org.phoebus.ui.color.NamedWidgetColors; import org.phoebus.ui.color.WidgetColor; import org.phoebus.ui.color.WidgetColorService; @@ -150,6 +157,18 @@ public EnumWidgetProperty createProperty(final Widget widget, newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "show_scale_labels", Messages.WidgetProperties_ShowScaleLabels); + /** 'tank_border_width': width in pixels of the border drawn around the + * bar (0..5). The name avoids the 'border_width' element that legacy + * BOY files carry for every widget. */ + public static final WidgetPropertyDescriptor propTankBorderWidth = + newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "tank_border_width", + Messages.WidgetProperties_BorderWidth, 0, 5); + + /** 'inner_padding': gap between the widget edge and the bar, in pixels (0..20) */ + public static final WidgetPropertyDescriptor propInnerPadding = + newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "inner_padding", + Messages.WidgetProperties_InnerPadding, 0, 20); + // ---- Instance fields ------------------------------------------------ private volatile WidgetProperty format; @@ -166,6 +185,19 @@ public EnumWidgetProperty createProperty(final Widget widget, private volatile WidgetProperty minor_alarm_color; private volatile WidgetProperty major_alarm_color; + // Scale look. Subclasses create these in defineScaleLookProperties(), + // or one by one when they need a different order or defaults. + protected volatile WidgetProperty font; + protected volatile WidgetProperty foreground; + protected volatile WidgetProperty fill_color; + protected volatile WidgetProperty log_scale; + protected volatile WidgetProperty scale_visible; + protected volatile WidgetProperty show_minor_ticks; + protected volatile WidgetProperty show_scale_labels; + protected volatile WidgetProperty opposite_scale_visible; + protected volatile WidgetProperty perpendicular_tick_labels; + protected volatile WidgetProperty border_width; + protected ScaledPVWidget(final String type, final int default_width, final int default_height) { super(type, default_width, default_height); @@ -203,6 +235,67 @@ protected void defineProperties(final List> properties) WidgetColorService.getColor(NamedWidgetColors.ALARM_MAJOR))); } + /** Define the scale look properties: font, foreground and fill color, + * log scale, scale visibility and tick options, border width. + * @param properties Property list of the widget + * @param show_scale Default for 'scale_visible' + * @param perpendicular_labels Default for 'perpendicular_tick_labels' + */ + protected void defineScaleLookProperties(final List> properties, + final boolean show_scale, + final boolean perpendicular_labels) + { + properties.add(font = propFont.createProperty(this, WidgetFontService.get(NamedWidgetFonts.DEFAULT))); + properties.add(foreground = propForegroundColor.createProperty(this, WidgetColorService.getColor(NamedWidgetColors.TEXT))); + properties.add(fill_color = propFillColor.createProperty(this, new WidgetColor(60, 255, 60))); + properties.add(log_scale = propLogscale.createProperty(this, false)); + properties.add(scale_visible = propScaleVisible.createProperty(this, show_scale)); + properties.add(show_minor_ticks = propShowMinorTicks.createProperty(this, true)); + properties.add(show_scale_labels = propShowScaleLabels.createProperty(this, true)); + properties.add(opposite_scale_visible = propOppositeScaleVisible.createProperty(this, false)); + properties.add(perpendicular_tick_labels = propPerpendicularTickLabels.createProperty(this, perpendicular_labels)); + properties.add(border_width = propTankBorderWidth.createProperty(this, 0)); + } + + /** @return The scale look properties, for representations that listen to all of them */ + public List> getScaleLookProperties() + { + return List.of(font, foreground, fill_color, log_scale, + scale_visible, show_minor_ticks, show_scale_labels, + opposite_scale_visible, perpendicular_tick_labels, + border_width); + } + + /** @return 'font' property */ + public WidgetProperty propFont() { return font; } + + /** @return 'foreground_color' property */ + public WidgetProperty propForeground() { return foreground; } + + /** @return 'fill_color' property */ + public WidgetProperty propFillColor() { return fill_color; } + + /** @return 'log_scale' property */ + public WidgetProperty propLogScale() { return log_scale; } + + /** @return 'scale_visible' property */ + public WidgetProperty propScaleVisible() { return scale_visible; } + + /** @return 'show_minor_ticks' property */ + public WidgetProperty propShowMinorTicks() { return show_minor_ticks; } + + /** @return 'show_scale_labels' property */ + public WidgetProperty propShowScaleLabels() { return show_scale_labels; } + + /** @return 'opposite_scale_visible' property */ + public WidgetProperty propOppositeScaleVisible() { return opposite_scale_visible; } + + /** @return 'perpendicular_tick_labels' property */ + public WidgetProperty propPerpendicularTickLabels() { return perpendicular_tick_labels; } + + /** @return 'tank_border_width' property (0 = no border) */ + public WidgetProperty propBorderWidth() { return border_width; } + /** @return 'format' property (scale label format) */ public WidgetProperty propFormat() { return format; } diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java index 8756a5f411..cac4d4f91f 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/TankWidget.java @@ -8,7 +8,6 @@ package org.csstudio.display.builder.model.widgets; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newColorPropertyDescriptor; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; @@ -31,7 +30,6 @@ import org.csstudio.display.builder.model.persist.ModelReader; import org.csstudio.display.builder.model.persist.NamedWidgetFonts; import org.csstudio.display.builder.model.persist.WidgetFontService; -import org.csstudio.display.builder.model.properties.WidgetFont; import org.phoebus.ui.color.NamedWidgetColors; import org.phoebus.ui.color.WidgetColor; import org.phoebus.ui.color.WidgetColorService; @@ -73,13 +71,6 @@ public Widget createWidget() } }; - /** 'tank_border_width' — width in pixels of the border drawn around the - * tank body; 0 (default) means no border, preserving the original look. - */ - public static final WidgetPropertyDescriptor propTankBorderWidth = - newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "tank_border_width", - Messages.WidgetProperties_BorderWidth, 0, 5); - /** 'empty_color' */ public static final WidgetPropertyDescriptor propEmptyColor = newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "empty_color", Messages.WidgetProperties_EmptyColor); @@ -140,19 +131,9 @@ public WidgetConfigurator getConfigurator(final Version persisted_version) return new CustomConfigurator(persisted_version); } - private volatile WidgetProperty font; - private volatile WidgetProperty foreground; private volatile WidgetProperty background; - private volatile WidgetProperty fill_color; private volatile WidgetProperty empty_color; - private volatile WidgetProperty scale_visible; - private volatile WidgetProperty show_minor_ticks; - private volatile WidgetProperty show_scale_labels; - private volatile WidgetProperty perpendicular_tick_labels; - private volatile WidgetProperty opposite_scale_visible; - private volatile WidgetProperty log_scale; private volatile WidgetProperty horizontal; - private volatile WidgetProperty border_width_prop; /** Constructor */ @@ -177,7 +158,7 @@ protected void defineProperties(final List> properties) properties.add(perpendicular_tick_labels = propPerpendicularTickLabels.createProperty(this, false)); properties.add(log_scale = propLogscale.createProperty(this, false)); properties.add(horizontal = propHorizontal.createProperty(this, false)); - properties.add(border_width_prop = propTankBorderWidth.createProperty(this, 0)); + properties.add(border_width = propTankBorderWidth.createProperty(this, 0)); } @Override @@ -189,81 +170,22 @@ public WidgetProperty getProperty(String name) throws IllegalArgumentExceptio return super.getProperty(name); } - /** @return 'font' property */ - public WidgetProperty propFont() - { - return font; - } - - /** @return 'foreground_color' property */ - public WidgetProperty propForeground() - { - return foreground; - } - /** @return 'background_color' property */ public WidgetProperty propBackground() { return background; } - /** @return 'fill_color' property */ - public WidgetProperty propFillColor() - { - return fill_color; - } - /** @return 'empty_color' property */ public WidgetProperty propEmptyColor() { return empty_color; } - /** @return 'scale_visible' property */ - public WidgetProperty propScaleVisible() - { - return scale_visible; - } - - /** @return 'show_minor_ticks' property */ - public WidgetProperty propShowMinorTicks() - { - return show_minor_ticks; - } - - /** @return 'show_scale_labels' property */ - public WidgetProperty propShowScaleLabels() - { - return show_scale_labels; - } - - /** @return 'perpendicular_tick_labels' property */ - public WidgetProperty propPerpendicularTickLabels() - { - return perpendicular_tick_labels; - } - - /** @return 'opposite_scale_visible' property */ - public WidgetProperty propOppositeScaleVisible() - { - return opposite_scale_visible; - } - - /** @return 'log_scale' property */ - public WidgetProperty propLogScale() - { - return log_scale; - } - /** @return 'horizontal' property */ public WidgetProperty propHorizontal() { return horizontal; } - /** @return 'border_width' property (0 = no border) */ - public WidgetProperty propBorderWidth() - { - return border_width_prop; - } } diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index 586c0cf6c7..850272fa44 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -227,6 +227,7 @@ WidgetProperties_HourColor=Hour Color WidgetProperties_HourTickMarkColor=Hour Tick Mark Color WidgetProperties_HourTickMarkVisible=Hour Tick Mark Visible WidgetProperties_Increment=Increment +WidgetProperties_InnerPadding=Inner Padding WidgetProperties_InitialIndex=Initial Index WidgetProperties_Insets=Insets WidgetProperties_Interactive=Interactive diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties index 3bc18b6855..1b8ddb720b 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties @@ -227,6 +227,7 @@ WidgetProperties_HourColor=Couleur de l'heure WidgetProperties_HourTickMarkColor=Couleur des graduations horaires WidgetProperties_HourTickMarkVisible=Graduations horaires visibles WidgetProperties_Increment=Incrément +WidgetProperties_InnerPadding=Marge intérieure WidgetProperties_InitialIndex=Index initial WidgetProperties_Insets=Marges WidgetProperties_Interactive=Interactif diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java new file mode 100644 index 0000000000..bdc953f9a3 --- /dev/null +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ProgressBarWidgetUnitTest.java @@ -0,0 +1,232 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.model.widgets; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.csstudio.display.builder.model.DisplayModel; +import org.csstudio.display.builder.model.Widget; +import org.csstudio.display.builder.model.persist.ModelReader; +import org.csstudio.display.builder.model.persist.ModelWriter; +import org.junit.jupiter.api.Test; +import org.phoebus.ui.color.WidgetColor; +import org.phoebus.ui.vtype.ScaleFormat; + +/** JUnit tests for {@link ProgressBarWidget} as a {@link ScaledPVWidget} + * + *

    The progress bar must keep loading the files written before it had + * a scale, and files written now must not confuse an older Phoebus. + * + * @author Heredie Delvalle + */ +@SuppressWarnings("nls") +public class ProgressBarWidgetUnitTest +{ + /** Defaults, in particular those that the stock renderer relies on */ + @Test + public void testDefaults() + { + final ProgressBarWidget bar = new ProgressBarWidget(); + + assertThat(bar.propWidth().getValue(), equalTo(100)); + assertThat(bar.propHeight().getValue(), equalTo(20)); + assertThat(bar.propHorizontal().getValue(), equalTo(true)); + assertThat(bar.propLimitsFromPV().getValue(), equalTo(true)); + assertThat(bar.propMinimum().getValue(), equalTo(0.0)); + assertThat(bar.propMaximum().getValue(), equalTo(100.0)); + assertThat(bar.propLogScale().getValue(), equalTo(false)); + assertThat(bar.propFillColor().getValue(), equalTo(new WidgetColor(60, 255, 60))); + assertThat(bar.propBackgroundColor().getValue(), equalTo(new WidgetColor(250, 250, 250))); + + // A bar looks like a bar until a scale is asked for + assertThat(bar.propScaleVisible().getValue(), equalTo(false)); + assertThat(bar.propShowMinorTicks().getValue(), equalTo(true)); + assertThat(bar.propShowScaleLabels().getValue(), equalTo(true)); + assertThat(bar.propOppositeScaleVisible().getValue(), equalTo(false)); + assertThat(bar.propPerpendicularTickLabels().getValue(), equalTo(false)); + assertThat(bar.propBorderWidth().getValue(), equalTo(0)); + assertThat(bar.propInnerPadding().getValue(), equalTo(3)); + assertThat(bar.propFormat().getValue(), equalTo(ScaleFormat.DEFAULT)); + assertThat(bar.propShowAlarmLimits().getValue(), equalTo(false)); + } + + /** Every name that the property panel hides for the stock renderer must be a real property */ + @Test + public void testScaleModePropertiesExist() + { + final ProgressBarWidget bar = new ProgressBarWidget(); + for (String name : ProgressBarWidget.SCALE_MODE_PROPS) + assertTrue(bar.checkProperty(name).isPresent(), "Unknown property " + name); + } + + /** A file written before the progress bar had a scale must load as before */ + @Test + public void testLegacyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Bar\n" + + " loc://x\n" + + " \n" + + " \n" + + " false\n" + + " 5.0\n" + + " 50.0\n" + + " true\n" + + " false\n" + + " \n" + + ""; + final ProgressBarWidget bar = (ProgressBarWidget) read(xml); + + assertThat(bar.propFillColor().getValue(), equalTo(new WidgetColor(10, 20, 30))); + assertThat(bar.propBackgroundColor().getValue(), equalTo(new WidgetColor(1, 2, 3))); + assertThat(bar.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(bar.propMinimum().getValue(), equalTo(5.0)); + assertThat(bar.propMaximum().getValue(), equalTo(50.0)); + assertThat(bar.propLogScale().getValue(), equalTo(true)); + assertThat(bar.propHorizontal().getValue(), equalTo(false)); + // Scale properties keep their defaults + assertThat(bar.propScaleVisible().getValue(), equalTo(false)); + } + + /** Non-default values of the new properties survive save and load */ + @Test + public void testXmlRoundTrip() throws Exception + { + final ProgressBarWidget original = new ProgressBarWidget(); + original.propScaleVisible().setValue(true); + original.propShowScaleLabels().setValue(false); + original.propOppositeScaleVisible().setValue(true); + original.propPerpendicularTickLabels().setValue(true); + original.propBorderWidth().setValue(2); + original.propInnerPadding().setValue(7); + original.propFormat().setValue(ScaleFormat.EXPONENTIAL); + original.propPrecision().setValue(1); + original.propShowAlarmLimits().setValue(true); + original.propAlarmLimitsFromPV().setValue(false); + original.propLevelHigh().setValue(80.0); + original.propMinimum().setValue(100.0); + original.propMaximum().setValue(0.0); + + final String xml = write(original, false); + assertThat(xml, containsString("")); + assertThat(xml, containsString("")); + assertThat(xml, containsString("")); + + final ProgressBarWidget bar = (ProgressBarWidget) read(xml); + assertThat(bar.propScaleVisible().getValue(), equalTo(true)); + assertThat(bar.propShowScaleLabels().getValue(), equalTo(false)); + assertThat(bar.propOppositeScaleVisible().getValue(), equalTo(true)); + assertThat(bar.propPerpendicularTickLabels().getValue(), equalTo(true)); + assertThat(bar.propBorderWidth().getValue(), equalTo(2)); + assertThat(bar.propInnerPadding().getValue(), equalTo(7)); + assertThat(bar.propFormat().getValue(), equalTo(ScaleFormat.EXPONENTIAL)); + assertThat(bar.propPrecision().getValue(), equalTo(1)); + assertThat(bar.propShowAlarmLimits().getValue(), equalTo(true)); + assertThat(bar.propAlarmLimitsFromPV().getValue(), equalTo(false)); + assertThat(bar.propLevelHigh().getValue(), equalTo(80.0)); + assertThat(bar.propMinimum().getValue(), equalTo(100.0)); + assertThat(bar.propMaximum().getValue(), equalTo(0.0)); + } + + /** Only changed properties are written, so a file that uses the + * pre-existing properties looks the same as before */ + @Test + public void testLegacyPropertiesWriteNoNewElements() throws Exception + { + final ProgressBarWidget bar = new ProgressBarWidget(); + bar.propFillColor().setValue(new WidgetColor(1, 2, 3)); + bar.propMinimum().setValue(5.0); + bar.propMaximum().setValue(50.0); + bar.propLogScale().setValue(true); + bar.propHorizontal().setValue(false); + final String xml = write(bar, true); + for (String name : List.of("fill_color", "minimum", "maximum", "log_scale", "horizontal")) + assertThat(xml, containsString("<" + name + ">")); + for (String name : ProgressBarWidget.SCALE_MODE_PROPS) + assertThat(xml, not(containsString("<" + name + ">"))); + } + + /** A BOY progress bar is imported with its scale and levels, and the + * generic BOY 'border_width' element does not become a bar border */ + @Test + public void testBoyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Bar\n" + + " loc://x\n" + + " 10\n" + + " 10\n" + + " 200\n" + + " 60\n" + + " 0\n" + + " 1\n" + + " false\n" + + " true\n" + + " false\n" + + " 80.0\n" + + " 90.0\n" + + " 20.0\n" + + " 10.0\n" + + " false\n" + + " 0.0\n" + + " 50.0\n" + + " \n" + + ""; + final ProgressBarWidget bar = (ProgressBarWidget) read(xml); + + assertThat(bar.propBorderWidth().getValue(), equalTo(0)); + assertThat(bar.propScaleVisible().getValue(), equalTo(false)); + assertThat(bar.propLevelHigh().getValue(), equalTo(80.0)); + assertThat(bar.propLevelHiHi().getValue(), equalTo(90.0)); + assertThat(bar.propLevelLow().getValue(), equalTo(20.0)); + assertThat(bar.propLevelLoLo().getValue(), equalTo(10.0)); + assertThat(bar.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(bar.propMaximum().getValue(), equalTo(50.0)); + // BOY reserved 25 px above the bar for the markers + assertThat(bar.propY().getValue(), equalTo(35)); + assertThat(bar.propHeight().getValue(), equalTo(35)); + } + + private static String write(final Widget widget, final boolean skip_defaults) throws Exception + { + final DisplayModel model = new DisplayModel(); + model.runtimeChildren().addChild(widget); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final boolean saved = ModelWriter.skip_defaults; + ModelWriter.skip_defaults = skip_defaults; + try (ModelWriter writer = new ModelWriter(out)) + { + writer.writeModel(model); + } + finally + { + ModelWriter.skip_defaults = saved; + } + return out.toString(); + } + + private static Widget read(final String xml) throws Exception + { + final ModelReader reader = new ModelReader(new ByteArrayInputStream(xml.getBytes())); + return reader.readModel().getChildren().get(0); + } +} diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java index 57fe2b840b..82df2a5bd1 100644 --- a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/TankWidgetUnitTest.java @@ -86,6 +86,18 @@ public void testTankWidgetDefaults() assertThat(tank.propBorderWidth().getValue(), equalTo(0)); } + /** The Tank defines the shared scale look properties in its own order; + * the list for representations must contain all of them */ + @Test + public void testScaleLookProperties() + { + final TankWidget tank = new TankWidget(); + final List> look = tank.getScaleLookProperties(); + assertThat(look.size(), equalTo(10)); + assertTrue(look.contains(tank.propBorderWidth())); + assertTrue(look.contains(tank.propForeground())); + } + /** Verify that alarm properties appear together and in the expected * order when listed in the property panel. * From 7e3565c52b9daed823095561c88655ba9aa8ec3f Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:46:02 -0600 Subject: [PATCH 3/7] feat(display): RTTank based Progress Bar renderer, opt-in via preference Add RTProgressBarRepresentation, which draws the progress bar with the RTTank engine shared with the Tank widget. This gives the bar a numeric scale with format and precision, an optional opposite scale, minor ticks, tick-only mode for stacked bars, and alarm limit lines. RTTank gains an inner padding and a flat track for the progress bar look. RTScaledWidgetRepresentation gains registerScaleLookListeners() and applyScaleLook() for the scale look properties that ScaledPVWidget defines; TankRepresentation and the new representation use them and only handle their own extras. The renderer is selected with the new preference org.csstudio.display.builder.representation/progressbar_scale_mode which defaults to false. With the default, the stock JavaFX ProgressBarRepresentation is used and nothing changes for existing displays. The property panel hides the renderer-only properties while the stock renderer is in use. --- .../properties/PropertyPanelSection.java | 21 ++++++ .../widgets/BaseWidgetRepresentations.java | 5 +- .../widgets/RTProgressBarRepresentation.java | 74 +++++++++++++++++++ .../widgets/RTScaledWidgetRepresentation.java | 54 ++++++++++++++ .../javafx/widgets/TankRepresentation.java | 43 +---------- .../builder/representation/Preferences.java | 7 ++ ...play_representation_preferences.properties | 7 ++ .../org/csstudio/javafx/rtplot/RTTank.java | 41 ++++++++-- 8 files changed, 205 insertions(+), 47 deletions(-) create mode 100644 app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java diff --git a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java index cd971b0583..fe4502af1d 100644 --- a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java +++ b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java @@ -59,6 +59,8 @@ import org.csstudio.display.builder.model.properties.RulesWidgetProperty; import org.csstudio.display.builder.model.properties.ScriptsWidgetProperty; import org.csstudio.display.builder.model.properties.WidgetClassProperty; +import org.csstudio.display.builder.model.widgets.ProgressBarWidget; +import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.javafx.FilenameSupport; import org.phoebus.ui.color.NamedWidgetColor; import org.phoebus.ui.color.WidgetColor; @@ -148,6 +150,22 @@ public boolean hasFocus() { return has_focus; } + /** Some widgets can be drawn by the stock JavaFX renderer or by an + * RTTank based one that adds a scale, selected via preference. + * Properties that only the RTTank renderer honours are hidden while + * the stock renderer is in use, so the panel only lists what has + * an effect. + * + * @param property Property about to be listed + * @return {@code true} if the property has no effect with the current renderer + */ + private static boolean unusedByCurrentRenderer(final WidgetProperty property) { + if (property.getWidget() instanceof ProgressBarWidget) + return !Preferences.progressbar_scale_mode + && ProgressBarWidget.SCALE_MODE_PROPS.contains(property.getName()); + return false; + } + void fill(final UndoableActionManager undo, final Collection> properties, final List other) { @@ -163,6 +181,9 @@ void fill(final UndoableActionManager undo, if (property instanceof WidgetClassProperty && class_mode) continue; + if (unusedByCurrentRenderer(property)) + continue; + // Start of new category that needs to be shown? if (property.getCategory() != category) { category = property.getCategory(); diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java index b6db502bd7..8c9927f195 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java @@ -55,6 +55,7 @@ import org.csstudio.display.builder.model.widgets.plots.ImageWidget; import org.csstudio.display.builder.model.widgets.plots.StripchartWidget; import org.csstudio.display.builder.model.widgets.plots.XYPlotWidget; +import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.WidgetRepresentation; import org.csstudio.display.builder.representation.WidgetRepresentationFactory; import org.csstudio.display.builder.representation.javafx.widgets.plots.DataBrowserRepresentation; @@ -105,7 +106,9 @@ public Widget createWidget() entry(PictureWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new PictureRepresentation()), entry(PolygonWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new PolygonRepresentation()), entry(PolylineWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new PolylineRepresentation()), - entry(ProgressBarWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new ProgressBarRepresentation()), + entry(ProgressBarWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) (Preferences.progressbar_scale_mode + ? new RTProgressBarRepresentation() + : new ProgressBarRepresentation())), entry(RadioWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new RadioRepresentation()), entry(RectangleWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new RectangleRepresentation()), entry(ScaledSliderWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new ScaledSliderRepresentation()), diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java new file mode 100644 index 0000000000..edd6e7746f --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTProgressBarRepresentation.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.representation.javafx.widgets; + +import org.csstudio.display.builder.model.widgets.ProgressBarWidget; +import org.csstudio.display.builder.representation.javafx.JFXUtil; + +import javafx.scene.paint.Color; + +/** Progress Bar representation based on {@link org.csstudio.javafx.rtplot.RTTank} + * + *

    Adds a numeric scale with format/precision, an optional second scale + * and alarm limit lines to the progress bar. Used instead of the stock + * {@link ProgressBarRepresentation} when the {@code progressbar_scale_mode} + * preference is set. + * + *

    Value, range, alarm limit and orientation handling are shared with + * the Tank in {@link RTScaledWidgetRepresentation}. This class only maps + * the progress bar's appearance properties onto the tank. + * + * @author Heredie Delvalle + */ +@SuppressWarnings("nls") +public class RTProgressBarRepresentation extends RTScaledWidgetRepresentation +{ + @Override + protected void configureTank() + { + // The stock ProgressBar has a plain track, without the tank's + // shaded gradient for the empty region + tank.setFlatTrack(true); + } + + @Override + protected boolean isHorizontal() + { + return model_widget.propHorizontal().getValue(); + } + + @Override + protected void registerLookListeners() + { + registerScaleLookListeners(); + model_widget.propBackgroundColor().addUntypedPropertyListener(lookListener); + model_widget.propInnerPadding().addUntypedPropertyListener(lookListener); + model_widget.propHorizontal().addPropertyListener(orientationChangedListener); + } + + @Override + protected void unregisterLookListeners() + { + unregisterScaleLookListeners(); + model_widget.propBackgroundColor().removePropertyListener(lookListener); + model_widget.propInnerPadding().removePropertyListener(lookListener); + model_widget.propHorizontal().removePropertyListener(orientationChangedListener); + } + + @Override + protected void applyLookToTank() + { + applyScaleLook(); + // Background and empty color are the same so the unfilled part of + // the bar blends with the margin, like the stock progress bar track + final Color background = JFXUtil.convert(model_widget.propBackgroundColor().getValue()); + tank.setBackground(background); + tank.setEmptyColor(background); + tank.setInnerPadding(model_widget.propInnerPadding().getValue()); + } +} diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java index bff17b53a9..63c80f08bb 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java @@ -45,8 +45,12 @@ * (colors, scale visibility, font, ...) *

  • {@link #applyLookToTank()}: push the current appearance properties * to the tank after size and orientation have been set
  • + *
  • {@link #configureTank()}: optional one-time tank setup
  • * * + *

    {@link #registerScaleLookListeners()} and {@link #applyScaleLook()} + * handle the scale look properties that all {@link ScaledPVWidget}s share. + * * @param concrete {@link ScaledPVWidget} subtype * @author Heredie Delvalle */ @@ -85,9 +89,15 @@ public Pane createJFXNode() throws Exception { tank = new RTTank(); tank.setUpdateThrottle(Preferences.image_update_delay, TimeUnit.MILLISECONDS); + configureTank(); return new Pane(tank); } + /** Called once after the tank is created, for one-time settings like a rendering style */ + protected void configureTank() + { + } + /** Register listeners on the {@link ScaledPVWidget} value and limit * properties, then call {@link #registerLookListeners()} for the * subclass to add its widget-specific appearance listeners. @@ -123,6 +133,30 @@ protected void registerListeners() limitsChanged(null, null, null); } + /** Listen to the widget size, the label format and the scale look + * properties shared by all scaled widgets. + * Subclasses call this from {@link #registerLookListeners()}. */ + protected void registerScaleLookListeners() + { + model_widget.propWidth().addUntypedPropertyListener(lookListener); + model_widget.propHeight().addUntypedPropertyListener(lookListener); + model_widget.propFormat().addUntypedPropertyListener(lookListener); + model_widget.propPrecision().addUntypedPropertyListener(lookListener); + for (WidgetProperty property : model_widget.getScaleLookProperties()) + property.addUntypedPropertyListener(lookListener); + } + + /** Undo {@link #registerScaleLookListeners()} */ + protected void unregisterScaleLookListeners() + { + model_widget.propWidth().removePropertyListener(lookListener); + model_widget.propHeight().removePropertyListener(lookListener); + model_widget.propFormat().removePropertyListener(lookListener); + model_widget.propPrecision().removePropertyListener(lookListener); + for (WidgetProperty property : model_widget.getScaleLookProperties()) + property.removePropertyListener(lookListener); + } + /** Register listeners on widget-specific appearance properties. * The implementation should add listeners using {@link #lookListener} * (or a dedicated listener) and call nothing on the tank directly, @@ -263,6 +297,26 @@ protected void orientationChanged(final WidgetProperty prop, toolkit.scheduleUpdate(this); } + /** Push the scale look properties shared by all scaled widgets to the + * tank: font, colors, log scale, label format, scale visibility and + * tick options, border width. + * Subclasses call this from {@link #applyLookToTank()}. */ + protected void applyScaleLook() + { + tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); + tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); + tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); + tank.setLogScale(model_widget.propLogScale().getValue()); + tank.setLabelFormat(model_widget.propFormat().getValue(), + model_widget.propPrecision().getValue()); + tank.setScaleVisible(model_widget.propScaleVisible().getValue()); + tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); + tank.setScaleLabelsVisible(model_widget.propShowScaleLabels().getValue()); + tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); + tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); + tank.setBorderWidth(model_widget.propBorderWidth().getValue()); + } + /** Push the current widget-specific appearance properties to the tank. * Called from {@link #updateChanges()} after size and orientation * have been applied. */ diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java index 2d514e86b0..bf86521954 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/TankRepresentation.java @@ -32,63 +32,26 @@ protected boolean isHorizontal() @Override protected void registerLookListeners() { - model_widget.propWidth().addUntypedPropertyListener(lookListener); - model_widget.propHeight().addUntypedPropertyListener(lookListener); - model_widget.propFont().addUntypedPropertyListener(lookListener); - model_widget.propForeground().addUntypedPropertyListener(lookListener); + registerScaleLookListeners(); model_widget.propBackground().addUntypedPropertyListener(lookListener); - model_widget.propFillColor().addUntypedPropertyListener(lookListener); model_widget.propEmptyColor().addUntypedPropertyListener(lookListener); - model_widget.propScaleVisible().addUntypedPropertyListener(lookListener); - model_widget.propShowMinorTicks().addUntypedPropertyListener(lookListener); - model_widget.propShowScaleLabels().addUntypedPropertyListener(lookListener); - model_widget.propPerpendicularTickLabels().addUntypedPropertyListener(lookListener); - model_widget.propFormat().addUntypedPropertyListener(lookListener); - model_widget.propPrecision().addUntypedPropertyListener(lookListener); - model_widget.propOppositeScaleVisible().addUntypedPropertyListener(lookListener); - model_widget.propBorderWidth().addUntypedPropertyListener(lookListener); - model_widget.propLogScale().addUntypedPropertyListener(lookListener); model_widget.propHorizontal().addPropertyListener(orientationChangedListener); } @Override protected void unregisterLookListeners() { - model_widget.propWidth().removePropertyListener(lookListener); - model_widget.propHeight().removePropertyListener(lookListener); - model_widget.propFont().removePropertyListener(lookListener); - model_widget.propForeground().removePropertyListener(lookListener); + unregisterScaleLookListeners(); model_widget.propBackground().removePropertyListener(lookListener); - model_widget.propFillColor().removePropertyListener(lookListener); model_widget.propEmptyColor().removePropertyListener(lookListener); - model_widget.propScaleVisible().removePropertyListener(lookListener); - model_widget.propShowMinorTicks().removePropertyListener(lookListener); - model_widget.propShowScaleLabels().removePropertyListener(lookListener); - model_widget.propPerpendicularTickLabels().removePropertyListener(lookListener); - model_widget.propFormat().removePropertyListener(lookListener); - model_widget.propPrecision().removePropertyListener(lookListener); - model_widget.propOppositeScaleVisible().removePropertyListener(lookListener); - model_widget.propBorderWidth().removePropertyListener(lookListener); - model_widget.propLogScale().removePropertyListener(lookListener); model_widget.propHorizontal().removePropertyListener(orientationChangedListener); } @Override protected void applyLookToTank() { - tank.setFont(JFXUtil.convert(model_widget.propFont().getValue())); + applyScaleLook(); tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); - tank.setForeground(JFXUtil.convert(model_widget.propForeground().getValue())); - tank.setFillColor(JFXUtil.convert(model_widget.propFillColor().getValue())); tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().getValue())); - tank.setScaleVisible(model_widget.propScaleVisible().getValue()); - tank.setShowMinorTicks(model_widget.propShowMinorTicks().getValue()); - tank.setScaleLabelsVisible(model_widget.propShowScaleLabels().getValue()); - tank.setPerpendicularTickLabels(model_widget.propPerpendicularTickLabels().getValue()); - tank.setLogScale(model_widget.propLogScale().getValue()); - tank.setLabelFormat(model_widget.propFormat().getValue(), - model_widget.propPrecision().getValue()); - tank.setRightScaleVisible(model_widget.propOppositeScaleVisible().getValue()); - tank.setBorderWidth(model_widget.propBorderWidth().getValue()); } } diff --git a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java index d25853b120..dad8d6b34d 100644 --- a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java +++ b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java @@ -22,6 +22,13 @@ public class Preferences update_accumulation_time, update_delay, plot_update_delay, image_update_delay, tooltip_length, embedded_timeout; + /** When {@code true}, the Progress Bar widget uses {@link org.csstudio.javafx.rtplot.RTTank} + * as its rendering engine, which adds a numeric scale, tick format/precision, + * an optional second scale, and alarm-limit lines. + * When {@code false} (default), the stock JFX {@code ProgressBar} look is preserved. + * Requires restart to take effect. */ + @Preference public static boolean progressbar_scale_mode; + static { AnnotatedPreferences.initialize(Preferences.class, "/display_representation_preferences.properties"); diff --git a/app/display/representation/src/main/resources/display_representation_preferences.properties b/app/display/representation/src/main/resources/display_representation_preferences.properties index 32724cbba7..e3f27e73ba 100644 --- a/app/display/representation/src/main/resources/display_representation_preferences.properties +++ b/app/display/representation/src/main/resources/display_representation_preferences.properties @@ -44,3 +44,10 @@ tooltip_length=200 # Timeout for load / unload of Embedded Widget content, in milliseconds. embedded_timeout=5000 + +# When true, the Progress Bar widget uses the RTTank rendering engine, +# which adds a numeric scale, tick marks, format/precision control, +# an optional second scale, and alarm-limit lines. +# When false (default), the standard JFX ProgressBar look is preserved. +# Requires restart to take effect. +progressbar_scale_mode = false diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 7ed55b8b34..302c24548c 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -94,6 +94,15 @@ public class RTTank extends Canvas /** Border width in pixels around the tank body; 0 = no border (default) */ private volatile int border_width = 0; + /** Extra inset from the canvas edge to the plot body on all four sides. + * 0 for the tank look; a progress bar uses it for the gap between the + * widget edge and the bar. */ + private volatile int inner_padding = 0; + + /** Paint the empty part of the tank in a solid color instead of the + * shaded gradient, like the track of a progress bar. */ + private volatile boolean flat_track = false; + /** Current value, i.e. fill level */ private volatile double value = 5.0; @@ -219,6 +228,22 @@ public void setBorderWidth(final int width) requestUpdate(); } + /** @param pixels Extra inset from all four canvas edges to the plot body, 0..20 */ + public void setInnerPadding(final int pixels) + { + inner_padding = Math.max(0, Math.min(20, pixels)); + need_layout.set(true); + requestUpdate(); + } + + /** @param flat Paint the empty part of the tank solid ({@code true}) + * or with the shaded gradient ({@code false}, default) */ + public void setFlatTrack(final boolean flat) + { + flat_track = flat; + requestUpdate(); + } + /** @param color Background color */ public void setBackground(final javafx.scene.paint.Color color) { @@ -560,11 +585,13 @@ private void computeLayout(final Graphics2D gc, final Rectangle bounds) // Inset = ceil(border_width/2) keeps the outer stroke edge inside the canvas. // On sides with a scale the label area provides ample margin so inset=0. // When there is no border, inset=1 is the original clip guard. + // inner_padding is added on all four sides regardless of scale presence. final int half_bw_ceil = (border_width + 1) / 2; - final int inset_left = (left_width == 0) ? Math.max(1, half_bw_ceil) : 0; - final int inset_right = (right_width == 0) ? Math.max(1, half_bw_ceil) : 0; - final int inset_top = (ends[1] == 0) ? Math.max(1, half_bw_ceil) : 0; - final int inset_bottom = (ends[0] == 0) ? Math.max(1, half_bw_ceil) : 0; + final int ip = inner_padding; + final int inset_left = (left_width == 0) ? Math.max(1, half_bw_ceil) + ip : ip; + final int inset_right = (right_width == 0) ? Math.max(1, half_bw_ceil) + ip : ip; + final int inset_top = (ends[1] == 0) ? Math.max(1, half_bw_ceil) + ip : ip; + final int inset_bottom = (ends[0] == 0) ? Math.max(1, half_bw_ceil) + ip : ip; final int top = bounds.y + ends[1] + inset_top; final int height = bounds.height - ends[0] - ends[1] - inset_top - inset_bottom; @@ -620,8 +647,10 @@ protected Image updateImageBuffer() final int level = computeFillLevel(plot_bounds.height, min, max, current, scale.isLogarithmic()); final int arc = Math.min(plot_bounds.width, plot_bounds.height) / 10; - gc.setPaint(new GradientPaint(plot_bounds.x, 0, empty, plot_bounds.x+plot_bounds.width/2, 0, empty_shadow, true)); - + if (flat_track) + gc.setColor(empty); + else + gc.setPaint(new GradientPaint(plot_bounds.x, 0, empty, plot_bounds.x+plot_bounds.width/2, 0, empty_shadow, true)); gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, plot_bounds.height, arc, arc); gc.setPaint(new GradientPaint(plot_bounds.x, 0, fill, plot_bounds.x+plot_bounds.width/2, 0, fill_highlight, true)); From 1774da5d90270dd2bf0b5d30029d2456887f4751 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:46:02 -0600 Subject: [PATCH 4/7] feat(convert-edm): map EDM bar scale, range, precision and border The EDM 'activeBar' carries a scale flag, a fixed range, a precision and a border flag that the progress bar can now represent. --- .../edm/widgets/Convert_activeBarClass.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java b/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java index a7acd31982..79c1814872 100644 --- a/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java +++ b/app/display/convert-edm/src/main/java/org/csstudio/display/converter/edm/widgets/Convert_activeBarClass.java @@ -28,7 +28,22 @@ public Convert_activeBarClass(final EdmConverter converter, final Widget parent, convertColor(r.getBgColor(), widget.propBackgroundColor()); widget.propHorizontal().setValue(!"vertical".equals(r.getOrientation())); widget.propPVName().setValue(convertPVName(r.getIndicatorPv())); + + widget.propScaleVisible().setValue(r.isShowScale()); widget.propLimitsFromPV().setValue(r.isLimitsFromDb()); + if (!r.isLimitsFromDb() && r.getMax() > r.getMin()) + { + widget.propMinimum().setValue(r.getMin()); + widget.propMaximum().setValue(r.getMax()); + } + + // EDM precision 0 usually means "not set" + if (r.getPrecision() > 0) + widget.propPrecision().setValue(r.getPrecision()); + + // EDM only knows border on/off + if (r.isBorder()) + widget.propBorderWidth().setValue(1); } @Override From e0cdef8c129838c5748951d25f9dda5a423139d8 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:50:13 -0600 Subject: [PATCH 5/7] feat(display): make ThermometerWidget a ScaledPVWidget Move the thermometer onto the ScaledPVWidget base class, like the Tank and the Progress Bar, so it carries the same range, scale and alarm limit properties through defineScaleLookProperties(), plus background color, inner padding and a bulb size. The scale is off by default, so a thermometer keeps its stock proportions until a scale is asked for. The stock renderer ignores the additions. Existing .bob and BOY files keep loading unchanged since the pre-existing properties keep their names and the border uses the 'tank_border_width' name. ThermometerWidgetUnitTest covers defaults, legacy .bob and BOY files, the XML round trip and the list of renderer-only properties. --- .../display/builder/model/Messages.java | 1 + .../model/widgets/ThermometerWidget.java | 99 +++++---- .../display/builder/model/messages.properties | 1 + .../builder/model/messages_fr.properties | 1 + .../widgets/ThermometerWidgetUnitTest.java | 203 ++++++++++++++++++ 5 files changed, 263 insertions(+), 42 deletions(-) create mode 100644 app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index 9a62e6a502..a758896ebd 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -185,6 +185,7 @@ public class Messages WidgetProperties_BorderAlarmSensitive, WidgetProperties_BorderColor, WidgetProperties_BorderWidth, + WidgetProperties_BulbSize, WidgetProperties_CellColors, WidgetProperties_Class, WidgetProperties_ColorHiHi, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java index 7fab8e066f..03b0aa2dbc 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/ThermometerWidget.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2016 Oak Ridge National Laboratory. + * Copyright (c) 2015-2026 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -7,29 +7,63 @@ *******************************************************************************/ package org.csstudio.display.builder.model.widgets; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propLimitsFromPV; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMaximum; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propMinimum; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newIntegerPropertyDescriptor; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propBackgroundColor; import java.util.Arrays; import java.util.List; +import java.util.Set; +import org.csstudio.display.builder.model.Messages; import org.csstudio.display.builder.model.Widget; import org.csstudio.display.builder.model.WidgetCategory; import org.csstudio.display.builder.model.WidgetDescriptor; import org.csstudio.display.builder.model.WidgetProperty; +import org.csstudio.display.builder.model.WidgetPropertyCategory; +import org.csstudio.display.builder.model.WidgetPropertyDescriptor; import org.phoebus.ui.color.WidgetColor; -/** - * Widget of a thermometer +/** Widget that displays a thermometer * - * @author Amanda Carpenter + *

    Extends {@link ScaledPVWidget} so the thermometer offers the same + * range, scale and alarm limit properties as the {@link TankWidget}. + * The scale related properties only take effect with the RTTank based + * renderer, see {@link #SCALE_MODE_PROPS}. + * + *

    Existing {@code .bob} files load unchanged: {@code fill_color}, + * {@code limits_from_pv}, {@code minimum} and {@code maximum} keep their + * XML names. Older Phoebus versions ignore the new properties. + * + * @author Amanda Carpenter + * @author Heredie Delvalle - CLS, scale support */ -public class ThermometerWidget extends PVWidget +@SuppressWarnings("nls") +public class ThermometerWidget extends ScaledPVWidget { + /** Properties that only have an effect with the RTTank based renderer + * ({@code thermometer_scale_mode=true}). + * The property editor hides these while the stock renderer is in use. */ + public static final Set SCALE_MODE_PROPS = Set.of( + "format", "precision", + "background_color", "foreground_color", "font", + "log_scale", + "scale_visible", "show_minor_ticks", "show_scale_labels", + "opposite_scale_visible", "perpendicular_tick_labels", + "inner_padding", "tank_border_width", + "bulb_size", + "alarm_limits_from_pv", "show_alarm_limits", + "level_lolo", "level_low", "level_high", "level_hihi", + "minor_alarm_color", "major_alarm_color"); + + /** 'bulb_size': how much wider than the tube the bulb is, in pixels (0..50). + * The bulb is always drawn; 0 gives the narrowest bulb, not none. + * With the default of 20 and no scale, a thermometer of the default + * width has about the proportions of the stock thermometer. */ + public static final WidgetPropertyDescriptor propBulbSize = + newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "bulb_size", + Messages.WidgetProperties_BulbSize, 0, 50); + /** Widget descriptor */ - @SuppressWarnings("nls") public static final WidgetDescriptor WIDGET_DESCRIPTOR = new WidgetDescriptor("thermometer", WidgetCategory.MONITOR, "Thermometer", @@ -44,7 +78,9 @@ public Widget createWidget() } }; - //TODO: configurator that ignores if show_bulb property is false (vertical progress bar instead) + private volatile WidgetProperty background_color; + private volatile WidgetProperty inner_padding; + private volatile WidgetProperty bulb_size; /** Constructor */ public ThermometerWidget() @@ -52,43 +88,22 @@ public ThermometerWidget() super(WIDGET_DESCRIPTOR.getType(), 40, 160); } - private volatile WidgetProperty limits_from_pv; - private volatile WidgetProperty minimum; - private volatile WidgetProperty maximum; - private volatile WidgetProperty fill_color; - @Override protected void defineProperties(final List> properties) { super.defineProperties(properties); - properties.add(fill_color = propFillColor.createProperty(this, new WidgetColor(60, 255, 60))); - properties.add(limits_from_pv = propLimitsFromPV.createProperty(this, true)); - properties.add(minimum = propMinimum.createProperty(this, 0.0)); - properties.add(maximum = propMaximum.createProperty(this, 100.0)); + defineScaleLookProperties(properties, false, true); + properties.add(background_color = propBackgroundColor.createProperty(this, new WidgetColor(250, 250, 250))); + properties.add(inner_padding = propInnerPadding.createProperty(this, 3)); + properties.add(bulb_size = propBulbSize.createProperty(this, 20)); } - /** @return 'fill_color' property */ - public WidgetProperty propFillColor() - { - return fill_color; - } + /** @return 'background_color' property */ + public WidgetProperty propBackgroundColor() { return background_color; } - /** @return 'limits_from_pv' property */ - public WidgetProperty propLimitsFromPV() - { - return limits_from_pv; - } - - /** @return 'minimum' property */ - public WidgetProperty propMinimum() - { - return minimum; - } - - /** @return 'maximum' property */ - public WidgetProperty propMaximum() - { - return maximum; - } + /** @return 'inner_padding' property */ + public WidgetProperty propInnerPadding() { return inner_padding; } + /** @return 'bulb_size' property */ + public WidgetProperty propBulbSize() { return bulb_size; } } diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index 850272fa44..632fca5e04 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -171,6 +171,7 @@ WidgetProperties_Bit=Bit WidgetProperties_BorderAlarmSensitive=Alarm Border WidgetProperties_BorderColor=Border Color WidgetProperties_BorderWidth=Border Width +WidgetProperties_BulbSize=Bulb Size WidgetProperties_CellColors=Cell Colors WidgetProperties_Class=Class WidgetProperties_ColorHiHi=Color HiHi diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties index 1b8ddb720b..3425d6bb58 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages_fr.properties @@ -171,6 +171,7 @@ WidgetProperties_Bit=Bit WidgetProperties_BorderAlarmSensitive=Alarme de bordure WidgetProperties_BorderColor=Couleur de la bordure WidgetProperties_BorderWidth=Largeur de la bordure +WidgetProperties_BulbSize=Taille du bulbe WidgetProperties_CellColors=Couleurs des cellules WidgetProperties_Class=Classe WidgetProperties_ColorHiHi=Couleur HiHi diff --git a/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java new file mode 100644 index 0000000000..6a61fc58fd --- /dev/null +++ b/app/display/model/src/test/java/org/csstudio/display/builder/model/widgets/ThermometerWidgetUnitTest.java @@ -0,0 +1,203 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.model.widgets; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.csstudio.display.builder.model.DisplayModel; +import org.csstudio.display.builder.model.Widget; +import org.csstudio.display.builder.model.persist.ModelReader; +import org.csstudio.display.builder.model.persist.ModelWriter; +import org.junit.jupiter.api.Test; +import org.phoebus.ui.color.WidgetColor; +import org.phoebus.ui.vtype.ScaleFormat; + +/** JUnit tests for {@link ThermometerWidget} as a {@link ScaledPVWidget} + * + *

    The thermometer must keep loading the files written before it had + * a scale, and files written now must not confuse an older Phoebus. + * + * @author Heredie Delvalle + */ +@SuppressWarnings("nls") +public class ThermometerWidgetUnitTest +{ + /** Defaults, in particular those that the stock renderer relies on */ + @Test + public void testDefaults() + { + final ThermometerWidget thermo = new ThermometerWidget(); + + assertThat(thermo.propWidth().getValue(), equalTo(40)); + assertThat(thermo.propHeight().getValue(), equalTo(160)); + assertThat(thermo.propLimitsFromPV().getValue(), equalTo(true)); + assertThat(thermo.propMinimum().getValue(), equalTo(0.0)); + assertThat(thermo.propMaximum().getValue(), equalTo(100.0)); + assertThat(thermo.propFillColor().getValue(), equalTo(new WidgetColor(60, 255, 60))); + + // Looks like the stock thermometer until a scale is asked for; + // horizontal labels read naturally next to a vertical tube + assertThat(thermo.propScaleVisible().getValue(), equalTo(false)); + assertThat(thermo.propPerpendicularTickLabels().getValue(), equalTo(true)); + assertThat(thermo.propShowMinorTicks().getValue(), equalTo(true)); + assertThat(thermo.propShowScaleLabels().getValue(), equalTo(true)); + assertThat(thermo.propOppositeScaleVisible().getValue(), equalTo(false)); + assertThat(thermo.propLogScale().getValue(), equalTo(false)); + assertThat(thermo.propBorderWidth().getValue(), equalTo(0)); + assertThat(thermo.propInnerPadding().getValue(), equalTo(3)); + assertThat(thermo.propBulbSize().getValue(), equalTo(20)); + assertThat(thermo.propFormat().getValue(), equalTo(ScaleFormat.DEFAULT)); + assertThat(thermo.propShowAlarmLimits().getValue(), equalTo(false)); + } + + /** Every name that the property panel hides for the stock renderer must be a real property */ + @Test + public void testScaleModePropertiesExist() + { + final ThermometerWidget thermo = new ThermometerWidget(); + for (String name : ThermometerWidget.SCALE_MODE_PROPS) + assertTrue(thermo.checkProperty(name).isPresent(), "Unknown property " + name); + } + + /** A file written before the thermometer had a scale must load as before */ + @Test + public void testLegacyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Thermo\n" + + " loc://x\n" + + " \n" + + " false\n" + + " -10.0\n" + + " 40.0\n" + + " \n" + + ""; + final ThermometerWidget thermo = (ThermometerWidget) read(xml); + + assertThat(thermo.propFillColor().getValue(), equalTo(new WidgetColor(10, 20, 30))); + assertThat(thermo.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(thermo.propMinimum().getValue(), equalTo(-10.0)); + assertThat(thermo.propMaximum().getValue(), equalTo(40.0)); + assertThat(thermo.propBulbSize().getValue(), equalTo(20)); + } + + /** Non-default values of the new properties survive save and load */ + @Test + public void testXmlRoundTrip() throws Exception + { + final ThermometerWidget original = new ThermometerWidget(); + original.propScaleVisible().setValue(true); + original.propOppositeScaleVisible().setValue(true); + original.propLogScale().setValue(true); + original.propBorderWidth().setValue(1); + original.propInnerPadding().setValue(0); + original.propBulbSize().setValue(35); + original.propBackgroundColor().setValue(new WidgetColor(1, 2, 3)); + original.propFormat().setValue(ScaleFormat.DECIMAL); + original.propPrecision().setValue(0); + original.propShowAlarmLimits().setValue(true); + original.propLevelLoLo().setValue(5.0); + + final String xml = write(original, false); + assertThat(xml, containsString("")); + assertThat(xml, containsString("")); + + final ThermometerWidget thermo = (ThermometerWidget) read(xml); + assertThat(thermo.propScaleVisible().getValue(), equalTo(true)); + assertThat(thermo.propOppositeScaleVisible().getValue(), equalTo(true)); + assertThat(thermo.propLogScale().getValue(), equalTo(true)); + assertThat(thermo.propBorderWidth().getValue(), equalTo(1)); + assertThat(thermo.propInnerPadding().getValue(), equalTo(0)); + assertThat(thermo.propBulbSize().getValue(), equalTo(35)); + assertThat(thermo.propBackgroundColor().getValue(), equalTo(new WidgetColor(1, 2, 3))); + assertThat(thermo.propFormat().getValue(), equalTo(ScaleFormat.DECIMAL)); + assertThat(thermo.propPrecision().getValue(), equalTo(0)); + assertThat(thermo.propShowAlarmLimits().getValue(), equalTo(true)); + assertThat(thermo.propLevelLoLo().getValue(), equalTo(5.0)); + } + + /** Only changed properties are written, so a file that uses the + * pre-existing properties looks the same as before */ + @Test + public void testLegacyPropertiesWriteNoNewElements() throws Exception + { + final ThermometerWidget thermo = new ThermometerWidget(); + thermo.propFillColor().setValue(new WidgetColor(1, 2, 3)); + thermo.propLimitsFromPV().setValue(false); + thermo.propMinimum().setValue(5.0); + thermo.propMaximum().setValue(50.0); + final String xml = write(thermo, true); + for (String name : List.of("fill_color", "limits_from_pv", "minimum", "maximum")) + assertThat(xml, containsString("<" + name + ">")); + for (String name : ThermometerWidget.SCALE_MODE_PROPS) + assertThat(xml, not(containsString("<" + name + ">"))); + } + + /** A BOY thermometer is imported as before; its generic BOY + * 'border_width' element does not become a glass outline */ + @Test + public void testBoyFileLoads() throws Exception + { + final String xml = + "\n" + + "\n" + + " \n" + + " Thermo\n" + + " loc://x\n" + + " 0\n" + + " 1\n" + + " \n" + + " false\n" + + " -10.0\n" + + " 40.0\n" + + " \n" + + ""; + final ThermometerWidget thermo = (ThermometerWidget) read(xml); + + assertThat(thermo.propBorderWidth().getValue(), equalTo(0)); + assertThat(thermo.propFillColor().getValue(), equalTo(new WidgetColor(10, 20, 30))); + assertThat(thermo.propLimitsFromPV().getValue(), equalTo(false)); + assertThat(thermo.propMinimum().getValue(), equalTo(-10.0)); + assertThat(thermo.propMaximum().getValue(), equalTo(40.0)); + } + + private static String write(final Widget widget, final boolean skip_defaults) throws Exception + { + final DisplayModel model = new DisplayModel(); + model.runtimeChildren().addChild(widget); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final boolean saved = ModelWriter.skip_defaults; + ModelWriter.skip_defaults = skip_defaults; + try (ModelWriter writer = new ModelWriter(out)) + { + writer.writeModel(model); + } + finally + { + ModelWriter.skip_defaults = saved; + } + return out.toString(); + } + + private static Widget read(final String xml) throws Exception + { + final ModelReader reader = new ModelReader(new ByteArrayInputStream(xml.getBytes())); + return reader.readModel().getChildren().get(0); + } +} From 5f1b88084a25640764dcaea3f76698bbe64c7fe3 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:50:13 -0600 Subject: [PATCH 6/7] feat(rtplot): thermometer look for RTTank Add a thermometer style to RTTank: a narrow tube, centered in the plot area, with a bulb at the bottom. The scales sit right next to the tube and only span the tube, so the liquid level and the alarm limit lines are placed through the scale transform and always line up with the tick marks. Tube and bulb widen with the widget up to a cap, like the stock thermometer, and the bulb size can be adjusted. A widget that is too narrow for scale and bulb keeps tube and bulb in view. The tank look is not affected. The tank body drawing moves into its own method so that updateImageBuffer() only dispatches on the style. RTTankTest checks the thermometer geometry for degenerate sizes. --- .../org/csstudio/javafx/rtplot/RTTank.java | 370 ++++++++++++++++-- .../csstudio/javafx/rtplot/RTTankTest.java | 33 ++ 2 files changed, 376 insertions(+), 27 deletions(-) diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java index 302c24548c..8248ef7695 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/RTTank.java @@ -13,6 +13,9 @@ import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.Stroke; +import java.awt.geom.Arc2D; +import java.awt.geom.Path2D; import java.awt.image.BufferedImage; import java.text.NumberFormat; import java.util.Objects; @@ -41,6 +44,8 @@ * *

    Renders a vertical tank with fill level, optional left and right * scales, a foreground outline, and optional alarm/warning limit lines. + * With {@link #setThermometerStyle} the tank is drawn as a thermometer, + * a narrow tube with a bulb, using the same scales and limit lines. * The dual-scale layout is modelled after CS-Studio BOY's tank widget * which supported markers on both sides of the bar. * @@ -103,6 +108,34 @@ public class RTTank extends Canvas * shaded gradient, like the track of a progress bar. */ private volatile boolean flat_track = false; + /** Draw as a thermometer instead of a tank: a narrow tube, centered + * in the plot area, with a bulb at the bottom. Scale, value mapping + * and alarm limits are shared with the tank look. */ + private volatile boolean thermometer_style = false; + + /** Extra bulb diameter beyond the tube width, in pixels. + * Thermometer look only; the bulb is clamped to the space left by the scales. */ + private volatile int bulb_size = 20; + + /** Thermometer geometry, computed by {@link #thermoGeometry} and + * shared by tube, bulb, liquid and scales so they stay aligned. + * All values are canvas pixels. */ + record ThermoGeom(double centerX, double tubeWidth, + double tubeTop, double tubeBottom, + double bulbCenterY, double bulbRadius) + { + double tubeLeft() { return centerX - tubeWidth / 2; } + double tubeRight() { return centerX + tubeWidth / 2; } + double bulbDiameter() { return 2 * bulbRadius; } + } + + /** Pixels reserved around the thermometer for the scales: width of the + * left and right scale, label overhang at the top and bottom */ + record ScaleSpace(int left, int right, int top, int bottom) {} + + /** Geometry from the most recent thermometer layout, {@code null} until then */ + private volatile ThermoGeom thermo_geom = null; + /** Current value, i.e. fill level */ private volatile double value = 5.0; @@ -244,6 +277,26 @@ public void setFlatTrack(final boolean flat) requestUpdate(); } + /** Select the thermometer look: a narrow tube with a bulb at the bottom + * instead of the full width tank body. Scale, value mapping and alarm + * limits behave the same in both looks. + * @param thermometer {@code true} for thermometer, {@code false} (default) for tank */ + public void setThermometerStyle(final boolean thermometer) + { + thermometer_style = thermometer; + need_layout.set(true); + requestUpdate(); + } + + /** @param pixels Extra bulb diameter beyond the tube width (thermometer look), + * clamped at layout time so the bulb fits the plot area */ + public void setBulbSize(final int pixels) + { + bulb_size = Math.max(0, pixels); + need_layout.set(true); + requestUpdate(); + } + /** @param color Background color */ public void setBackground(final javafx.scene.paint.Color color) { @@ -558,6 +611,38 @@ private static int computeFillLevel(final int plotHeight, final double min, fina return (int) (plotHeight * (current - min) / (max - min) + 0.5); } + /** @return Is at least one alarm limit line configured? */ + private boolean hasLimitLines() + { + return !Double.isNaN(limit_lolo) || !Double.isNaN(limit_lo) || + !Double.isNaN(limit_hi) || !Double.isNaN(limit_hihi); + } + + /** @return Stroke for limit lines: solid for limits from the PV, + * dashed for manually configured ones */ + private Stroke limitLineStroke() + { + if (limits_from_pv) + return new BasicStroke(2f); + return new BasicStroke(2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, + 10f, new float[] { 6f, 4f }, 0f); + } + + // Thermometer look. Sizes in pixels. + + /** The tube widens with the widget up to a cap, as in the stock thermometer */ + private static final int TUBE_MIN_WIDTH = 6; + private static final int TUBE_MAX_WIDTH = 20; + + /** Tube height kept above the bulb, so the bulb never swallows the tube */ + private static final int TUBE_MIN_HEIGHT = 8; + + /** The bulb is always at least this much wider than the tube */ + private static final int BULB_MIN_OVERHANG = 6; + + /** Gap between a scale and the tube wall */ + private static final int SCALE_GAP = 3; + /** Compute layout of plot components. * Supports independent left and right scales; the plot area sits * between them. A 1-pixel inset is added on any edge that has no @@ -565,6 +650,12 @@ private static int computeFillLevel(final int plotHeight, final double min, fina */ private void computeLayout(final Graphics2D gc, final Rectangle bounds) { + if (thermometer_style) + { + computeThermoLayout(gc, bounds); + return; + } + int left_width = 0; int right_width = 0; int[] ends = { 0, 0 }; // [bottom gap, top gap] @@ -606,6 +697,229 @@ private void computeLayout(final Graphics2D gc, final Rectangle bounds) bounds.width - left_width - right_width - inset_left - inset_right, height); } + /** Thermometer layout + * + *

    Unlike the tank, the scales sit right next to the narrow tube and + * only span the tube, not the bulb. Scales, tube and bulb are centered + * as a whole in the available width. + * The geometry is kept in {@link #thermo_geom} so that painting and + * the scale transform use the same numbers. + */ + private void computeThermoLayout(final Graphics2D gc, final Rectangle bounds) + { + final ScaleSpace space = measureThermoScales(gc, bounds); + final ThermoGeom geom = thermoGeometry(bounds, space); + placeThermoScales(geom, space); + + // Plot area is the bounding box of tube and bulb, the reference for scale.paint() + final double bulb_bottom = geom.bulbCenterY() + geom.bulbRadius(); + plot_area.setBounds((int) Math.round(geom.centerX() - geom.bulbRadius()), + (int) Math.round(geom.tubeTop()), + (int) Math.round(geom.bulbDiameter()), + (int) Math.round(bulb_bottom - geom.tubeTop())); + thermo_geom = geom; + } + + /** @return Space taken by the visible scales */ + private ScaleSpace measureThermoScales(final Graphics2D gc, final Rectangle bounds) + { + int left = 0; + int right = 0; + int top = 0; + int bottom = 0; + if (scale_visible) + { + left = scale.getDesiredPixelSize(bounds, gc); + final int[] gaps = scale.getPixelGaps(gc); // [bottom, top] + bottom = gaps[0]; + top = gaps[1]; + } + if (right_scale_visible) + { + right = right_scale.getDesiredPixelSize(bounds, gc); + final int[] gaps = right_scale.getPixelGaps(gc); + bottom = Math.max(bottom, gaps[0]); + top = Math.max(top, gaps[1]); + } + return new ScaleSpace(left, right, top, bottom); + } + + /** Size and place tube and bulb in the space left by the scales + * @param bounds Canvas area + * @param space Space reserved for the scales + * @return Thermometer geometry + */ + ThermoGeom thermoGeometry(final Rectangle bounds, final ScaleSpace space) + { + // Vertical extent, leaving room for the label overhang and the outline stroke + final int half_outline = (border_width + 1) / 2; + final double inset = inner_padding + half_outline + 1.0; + final double top = bounds.y + space.top() + inset; + final double bottom = Math.max(top + 1.0, bounds.y + bounds.height - space.bottom() - inset); + final double height = bottom - top; + + // Width left for tube and bulb + final int left_space = space.left() > 0 ? space.left() + SCALE_GAP : 0; + final int right_space = space.right() > 0 ? space.right() + SCALE_GAP : 0; + final double width = Math.max(TUBE_MIN_WIDTH, + bounds.width - 2.0 * inner_padding - left_space - right_space); + + // Tube takes half the width, capped, as in the stock thermometer + final double tube_width = Math.clamp(width / 2, TUBE_MIN_WIDTH, TUBE_MAX_WIDTH); + + // Bulb is wider than the tube, but must fit the remaining width and height + double bulb_diameter = tube_width + bulb_size; + bulb_diameter = Math.min(bulb_diameter, Math.min(width, height - TUBE_MIN_HEIGHT)); + bulb_diameter = Math.max(bulb_diameter, tube_width + BULB_MIN_OVERHANG); + final double bulb_radius = bulb_diameter / 2; + + // Center the assembly. On each side, the scale or the bulb reaches + // out from the tube center, whichever is wider. A widget that is + // narrower than that keeps tube and bulb in view and clips the scale. + final double left_extent = Math.max(tube_width / 2 + left_space, bulb_radius); + final double right_extent = Math.max(tube_width / 2 + right_space, bulb_radius); + final double centered = bounds.x + (bounds.width - left_extent - right_extent) / 2 + left_extent; + final double center_x = Math.max(bounds.x + inner_padding + bulb_radius, + Math.min(centered, bounds.x + bounds.width - inner_padding - bulb_radius)); + + // The tube ends where its walls meet the bulb circle, never above its own top + final double bulb_center_y = bottom - bulb_radius; + final double half_chord = Math.min(tube_width / 2, bulb_radius - 0.001); + final double tube_bottom = Math.max(top, + bulb_center_y - Math.sqrt(bulb_radius * bulb_radius - half_chord * half_chord)); + + return new ThermoGeom(center_x, tube_width, top, tube_bottom, bulb_center_y, bulb_radius); + } + + /** Place the scales flush against the tube walls, spanning only the tube. + * The left scale is always positioned, even when hidden, because its + * value transform maps the liquid level and the limit lines onto the tube. */ + private void placeThermoScales(final ThermoGeom geom, final ScaleSpace space) + { + final int y = (int) Math.round(geom.tubeTop()); + final int height = Math.max(1, (int) Math.round(geom.tubeBottom() - geom.tubeTop())); + scale.setBounds(new Rectangle((int) Math.round(geom.tubeLeft() - SCALE_GAP - space.left()), + y, space.left(), height)); + if (right_scale_visible) + right_scale.setBounds(new Rectangle((int) Math.round(geom.tubeRight() + SCALE_GAP), + y, space.right(), height)); + } + + /** Draw the thermometer from the geometry of the last layout: + * empty tube, liquid, limit lines and glass outline */ + private void drawThermometer(final Graphics2D gc, final double min, final double max, final double current) + { + final ThermoGeom geom = thermo_geom; + if (geom == null) + return; + final int arc = (int) Math.max(2, geom.tubeWidth() * 0.6); + + paintEmptyTube(gc, geom, arc); + paintLiquid(gc, geom, arc, liquidLevel(geom, current)); + paintThermoLimits(gc, geom, min, max); + paintGlassOutline(gc, geom, arc); + } + + /** @return Y coordinate of the liquid surface, taken from the scale so it + * lines up with the tick marks, and clamped to the tube */ + private double liquidLevel(final ThermoGeom geom, final double current) + { + return Math.clamp(scale.getScreenCoord(current), geom.tubeTop(), geom.tubeBottom()); + } + + private void paintEmptyTube(final Graphics2D gc, final ThermoGeom geom, final int arc) + { + if (flat_track) + gc.setColor(empty); + else + gc.setPaint(new GradientPaint((float) geom.tubeLeft(), 0, empty, + (float) geom.centerX(), 0, empty_shadow, true)); + gc.fillRoundRect((int) Math.round(geom.tubeLeft()), (int) Math.round(geom.tubeTop()), + (int) Math.round(geom.tubeWidth()), + (int) Math.round(geom.tubeBottom() - geom.tubeTop()), + arc, arc); + } + + /** Paint the bulb, which is always full, and the liquid column up to {@code level} */ + private void paintLiquid(final Graphics2D gc, final ThermoGeom geom, final int arc, final double level) + { + gc.setPaint(new GradientPaint((float) geom.tubeLeft(), 0, fill, + (float) geom.centerX(), 0, fill_highlight, true)); + final int bulb_diameter = (int) Math.round(geom.bulbDiameter()); + gc.fillOval((int) Math.round(geom.centerX() - geom.bulbRadius()), + (int) Math.round(geom.bulbCenterY() - geom.bulbRadius()), + bulb_diameter, bulb_diameter); + if (level < geom.tubeBottom()) + gc.fillRoundRect((int) Math.round(geom.tubeLeft()), (int) Math.round(level), + (int) Math.round(geom.tubeWidth()), + (int) Math.round(geom.tubeBottom() - level) + arc, + arc, arc); + } + + /** Paint the alarm limit lines across the tube */ + private void paintThermoLimits(final Graphics2D gc, final ThermoGeom geom, + final double min, final double max) + { + if (!hasLimitLines()) + return; + gc.setStroke(limitLineStroke()); + drawThermoLimit(gc, geom, min, max, limit_lolo, limit_major_color); + drawThermoLimit(gc, geom, min, max, limit_lo, limit_minor_color); + drawThermoLimit(gc, geom, min, max, limit_hi, limit_minor_color); + drawThermoLimit(gc, geom, min, max, limit_hihi, limit_major_color); + gc.setStroke(new BasicStroke(1f)); + } + + /** Draw one limit line across the tube, placed via the scale so that it + * matches the tick marks. Limits outside the range are skipped. */ + private void drawThermoLimit(final Graphics2D gc, final ThermoGeom geom, + final double min, final double max, + final double limit, final Color color) + { + if (!Double.isFinite(limit) || limit <= min || limit >= max) + return; + final int y = scale.getScreenCoord(limit); + if (y < geom.tubeTop() || y > geom.tubeBottom()) + return; + gc.setColor(color); + gc.drawLine((int) Math.round(geom.tubeLeft()), y, (int) Math.round(geom.tubeRight()), y); + } + + /** Paint the glass outline: tube walls, rounded top and the bulb arc. + * Nothing is drawn for border width 0. */ + private void paintGlassOutline(final Graphics2D gc, final ThermoGeom geom, final int arc) + { + if (border_width <= 0) + return; + final double left = geom.tubeLeft(); + final double right = geom.tubeRight(); + final double top = geom.tubeTop(); + final double bottom = geom.tubeBottom(); + + final Path2D.Double outline = new Path2D.Double(); + outline.moveTo(left, bottom); + outline.lineTo(left, top + arc / 2.0); + outline.quadTo(left, top, left + arc / 2.0, top); + outline.lineTo(right - arc / 2.0, top); + outline.quadTo(right, top, right, top + arc / 2.0); + outline.lineTo(right, bottom); + // Around the bulb, from the right wall back to the left wall + final double dy = bottom - geom.bulbCenterY(); + final double angle_right = Math.toDegrees(Math.atan2(-dy, right - geom.centerX())); + final double angle_left = Math.toDegrees(Math.atan2(-dy, left - geom.centerX())); + outline.append(new Arc2D.Double(geom.centerX() - geom.bulbRadius(), + geom.bulbCenterY() - geom.bulbRadius(), + geom.bulbDiameter(), geom.bulbDiameter(), + angle_right, angle_left - angle_right - 360, Arc2D.OPEN), + true); + outline.closePath(); + + gc.setColor(foreground); + gc.setStroke(new BasicStroke(border_width)); + gc.draw(outline); + gc.setStroke(new BasicStroke(1f)); + } + /** Draw all components into image buffer */ protected Image updateImageBuffer() { @@ -640,10 +954,27 @@ protected Image updateImageBuffer() plot_area.paint(gc); final AxisRange range = scale.getValueRange(); - final boolean normal = range.getLow() <= range.getHigh(); final double min = Math.min(range.getLow(), range.getHigh()); final double max = Math.max(range.getLow(), range.getHigh()); final double current = value; + if (thermometer_style) + drawThermometer(gc, min, max, current); + else + drawTank(gc, plot_bounds, min, max, current, range.getLow() <= range.getHigh()); + + gc.dispose(); + + // Convert to JFX + return SwingFXUtils.toFXImage(image, null); + } + + /** Draw the tank body: track, fill level, optional border and limit lines + * @param normal Range runs bottom-up? Otherwise the tank fills from the top + */ + private void drawTank(final Graphics2D gc, final Rectangle plot_bounds, + final double min, final double max, final double current, + final boolean normal) + { final int level = computeFillLevel(plot_bounds.height, min, max, current, scale.isLogarithmic()); final int arc = Math.min(plot_bounds.width, plot_bounds.height) / 10; @@ -659,11 +990,10 @@ protected Image updateImageBuffer() else gc.fillRoundRect(plot_bounds.x, plot_bounds.y, plot_bounds.width, level, arc, arc); - // Optional border: stroked CENTRED on plot_bounds — no integer half-pixel - // shifting. The inner half of the stroke covers the fill edge (no gap); - // the outer half extends beyond plot_bounds into the inset margin. - // Ticks land at plot_bounds edges = centre of the border stroke, matching - // the CS-Studio BOY convention. + // Optional border: stroked CENTRED on plot_bounds. The inner half of the + // stroke covers the fill edge (no gap); the outer half extends beyond + // plot_bounds into the inset margin. Ticks land at plot_bounds edges, + // the centre of the border stroke, matching the CS-Studio BOY convention. if (border_width > 0) { // Java2D: fillRoundRect covers x..x+w-1, drawRoundRect strokes x..x+w. @@ -677,30 +1007,16 @@ protected Image updateImageBuffer() gc.setStroke(new BasicStroke(1f)); } - // Draw alarm / warning limit lines over the tank body - final double lim_lolo = limit_lolo; - final double lim_lo = limit_lo; - final double lim_hi = limit_hi; - final double lim_hihi = limit_hihi; - if (normal && (!Double.isNaN(lim_lolo) || !Double.isNaN(lim_lo) || - !Double.isNaN(lim_hi) || !Double.isNaN(lim_hihi))) + // Limit lines only make sense on a bottom-up range + if (normal && hasLimitLines()) { - if (limits_from_pv) - gc.setStroke(new BasicStroke(2f)); - else - gc.setStroke(new BasicStroke(2f, BasicStroke.CAP_BUTT, - BasicStroke.JOIN_MITER, 10f, new float[]{6f, 4f}, 0f)); - drawLimitLineAt(gc, plot_bounds, min, max, lim_lolo, limit_major_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_lo, limit_minor_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_hi, limit_minor_color); - drawLimitLineAt(gc, plot_bounds, min, max, lim_hihi, limit_major_color); + gc.setStroke(limitLineStroke()); + drawLimitLineAt(gc, plot_bounds, min, max, limit_lolo, limit_major_color); + drawLimitLineAt(gc, plot_bounds, min, max, limit_lo, limit_minor_color); + drawLimitLineAt(gc, plot_bounds, min, max, limit_hi, limit_minor_color); + drawLimitLineAt(gc, plot_bounds, min, max, limit_hihi, limit_major_color); gc.setStroke(new BasicStroke(1f)); } - - gc.dispose(); - - // Convert to JFX - return SwingFXUtils.toFXImage(image, null); } /** Request a complete redraw of the plot */ diff --git a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java index c9e5177051..982378f528 100644 --- a/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java +++ b/app/rtplot/src/test/java/org/csstudio/javafx/rtplot/RTTankTest.java @@ -7,6 +7,8 @@ *******************************************************************************/ package org.csstudio.javafx.rtplot; +import java.awt.Rectangle; + import org.junit.jupiter.api.Test; import org.phoebus.ui.vtype.ScaleFormat; @@ -15,6 +17,7 @@ import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; /** JUnit tests for {@link RTTank}. * @@ -52,6 +55,36 @@ public void testSetRangeRejectsInvalid() tank.setRange(Double.POSITIVE_INFINITY, 100); } + /** The thermometer layout must produce a usable geometry for any size: + * tube and bulb inside the canvas, tube above the bulb, no exception */ + @Test + public void testThermometerGeometry() + { + final RTTank tank = new RTTank(); + tank.setThermometerStyle(true); + for (int[] size : new int[][] { { 1, 1 }, { 12, 40 }, { 24, 60 }, { 30, 30 }, { 40, 160 }, { 400, 600 } }) + for (int bulb : new int[] { 0, 20, 50, 500 }) + for (int padding : new int[] { 0, 20 }) + { + tank.setBulbSize(bulb); + tank.setInnerPadding(padding); + final Rectangle bounds = new Rectangle(0, 0, size[0], size[1]); + final RTTank.ThermoGeom geom = tank.thermoGeometry(bounds, new RTTank.ScaleSpace(30, 0, 5, 5)); + final String what = size[0] + "x" + size[1] + " bulb " + bulb + " padding " + padding; + assertTrue(geom.tubeBottom() >= geom.tubeTop(), what + ": tube ends above its top"); + assertTrue(geom.tubeWidth() >= 1, what + ": no tube"); + assertTrue(geom.bulbRadius() > geom.tubeWidth() / 2, what + ": bulb narrower than tube"); + // A canvas smaller than the minimum tube and bulb overflows, larger ones must not + if (size[0] >= 40 + 2 * padding && size[1] >= 60 + 2 * padding) + { + assertTrue(geom.bulbCenterY() + geom.bulbRadius() <= bounds.height, what + ": bulb below the canvas"); + assertTrue(geom.centerX() - geom.bulbRadius() >= 0, what + ": bulb left of the canvas"); + assertTrue(geom.centerX() + geom.bulbRadius() <= bounds.width, what + ": bulb right of the canvas"); + assertTrue(geom.tubeTop() >= 0, what + ": tube above the canvas"); + } + } + } + /** setValue should handle NaN and Infinity */ @Test public void testSetValueEdgeCases() From 0ea1effee4a04682d3c75c167ee82e4ffd576811 Mon Sep 17 00:00:00 2001 From: Emilio Heredia Date: Fri, 18 Sep 2026 13:50:13 -0600 Subject: [PATCH 7/7] feat(display): RTTank based Thermometer renderer, opt-in via preference Add RTThermometerRepresentation, which draws the thermometer with the RTTank engine in its thermometer style. This gives the thermometer a numeric scale with format and precision, log scale, minor ticks, an optional opposite scale, a glass outline and alarm limit lines, with the foreground color for scale and outline. The renderer is selected with the new preference org.csstudio.display.builder.representation/thermometer_scale_mode which defaults to false. With the default, the stock hand drawn ThermometerRepresentation is used and nothing changes for existing displays. The property panel hides the renderer-only properties while the stock renderer is in use. --- .../properties/PropertyPanelSection.java | 4 + .../widgets/BaseWidgetRepresentations.java | 4 +- .../widgets/RTThermometerRepresentation.java | 74 +++++++++++++++++++ .../builder/representation/Preferences.java | 7 ++ ...play_representation_preferences.properties | 6 ++ 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java diff --git a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java index fe4502af1d..4d736d8ad9 100644 --- a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java +++ b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/properties/PropertyPanelSection.java @@ -60,6 +60,7 @@ import org.csstudio.display.builder.model.properties.ScriptsWidgetProperty; import org.csstudio.display.builder.model.properties.WidgetClassProperty; import org.csstudio.display.builder.model.widgets.ProgressBarWidget; +import org.csstudio.display.builder.model.widgets.ThermometerWidget; import org.csstudio.display.builder.representation.Preferences; import org.csstudio.display.builder.representation.javafx.FilenameSupport; import org.phoebus.ui.color.NamedWidgetColor; @@ -163,6 +164,9 @@ private static boolean unusedByCurrentRenderer(final WidgetProperty property) if (property.getWidget() instanceof ProgressBarWidget) return !Preferences.progressbar_scale_mode && ProgressBarWidget.SCALE_MODE_PROPS.contains(property.getName()); + if (property.getWidget() instanceof ThermometerWidget) + return !Preferences.thermometer_scale_mode + && ThermometerWidget.SCALE_MODE_PROPS.contains(property.getName()); return false; } diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java index 8c9927f195..fb01cc0e38 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/BaseWidgetRepresentations.java @@ -124,7 +124,9 @@ public Widget createWidget() entry(TextEntryWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new TextEntryRepresentation()), entry(TextSymbolWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new TextSymbolRepresentation()), entry(TextUpdateWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new TextUpdateRepresentation()), - entry(ThermometerWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new ThermometerRepresentation()), + entry(ThermometerWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) (Preferences.thermometer_scale_mode + ? new RTThermometerRepresentation() + : new ThermometerRepresentation())), entry(Viewer3dWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new Viewer3dRepresentation()), entry(WebBrowserWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new WebBrowserRepresentation()), entry(XYPlotWidget.WIDGET_DESCRIPTOR, () -> (WidgetRepresentation) new XYPlotRepresentation()), diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java new file mode 100644 index 0000000000..86f9218f3e --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTThermometerRepresentation.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * Copyright (c) 2026 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.csstudio.display.builder.representation.javafx.widgets; + +import org.csstudio.display.builder.model.widgets.ThermometerWidget; +import org.csstudio.display.builder.representation.javafx.JFXUtil; + +import javafx.scene.paint.Color; + +/** Thermometer representation based on {@link org.csstudio.javafx.rtplot.RTTank} + * + *

    The tank draws tube, bulb, liquid, scale and alarm limit lines in one + * pass, so the liquid level always lines up with the tick marks. Compared + * with the stock {@link ThermometerRepresentation} this adds a configurable + * scale (log, format, precision, minor ticks) and alarm limit lines. + * Used when the {@code thermometer_scale_mode} preference is set. + * + *

    Value, range and alarm limit handling are shared with the Tank in + * {@link RTScaledWidgetRepresentation}. This class only maps the + * thermometer's appearance properties onto the tank. + * + * @author Heredie Delvalle + */ +@SuppressWarnings("nls") +public class RTThermometerRepresentation extends RTScaledWidgetRepresentation +{ + @Override + protected boolean isHorizontal() + { + return false; + } + + @Override + protected void configureTank() + { + tank.setThermometerStyle(true); + } + + @Override + protected void registerLookListeners() + { + registerScaleLookListeners(); + model_widget.propBackgroundColor().addUntypedPropertyListener(lookListener); + model_widget.propInnerPadding().addUntypedPropertyListener(lookListener); + model_widget.propBulbSize().addUntypedPropertyListener(lookListener); + } + + @Override + protected void unregisterLookListeners() + { + unregisterScaleLookListeners(); + model_widget.propBackgroundColor().removePropertyListener(lookListener); + model_widget.propInnerPadding().removePropertyListener(lookListener); + model_widget.propBulbSize().removePropertyListener(lookListener); + } + + @Override + protected void applyLookToTank() + { + applyScaleLook(); + // The empty part of the tube is painted in the background color, with the + // tank's shading, so only the liquid stands out + final Color background = JFXUtil.convert(model_widget.propBackgroundColor().getValue()); + tank.setBackground(background); + tank.setEmptyColor(background); + tank.setInnerPadding(model_widget.propInnerPadding().getValue()); + tank.setBulbSize(model_widget.propBulbSize().getValue()); + } +} diff --git a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java index dad8d6b34d..f9c6acb814 100644 --- a/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java +++ b/app/display/representation/src/main/java/org/csstudio/display/builder/representation/Preferences.java @@ -29,6 +29,13 @@ public class Preferences * Requires restart to take effect. */ @Preference public static boolean progressbar_scale_mode; + /** When {@code true}, the Thermometer widget is rendered by {@link org.csstudio.javafx.rtplot.RTTank}, + * which adds a numeric scale with format and precision, an optional second + * scale, a glass outline and alarm limit lines. + * When {@code false} (default), the stock hand-drawn thermometer is used. + * Requires restart to take effect. */ + @Preference public static boolean thermometer_scale_mode; + static { AnnotatedPreferences.initialize(Preferences.class, "/display_representation_preferences.properties"); diff --git a/app/display/representation/src/main/resources/display_representation_preferences.properties b/app/display/representation/src/main/resources/display_representation_preferences.properties index e3f27e73ba..970f7ef0cd 100644 --- a/app/display/representation/src/main/resources/display_representation_preferences.properties +++ b/app/display/representation/src/main/resources/display_representation_preferences.properties @@ -51,3 +51,9 @@ embedded_timeout=5000 # When false (default), the standard JFX ProgressBar look is preserved. # Requires restart to take effect. progressbar_scale_mode = false + +# When true, the Thermometer widget uses RTTank as its rendering engine, +# adding a numeric scale, tick format/precision, an optional second scale, +# and alarm-limit lines. When false (default), the hand-drawn thermometer +# look is preserved. Requires restart to take effect. +thermometer_scale_mode = false