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 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..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 @@ -59,6 +59,9 @@ 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.model.widgets.ThermometerWidget; +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 +151,25 @@ 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()); + if (property.getWidget() instanceof ThermometerWidget) + return !Preferences.thermometer_scale_mode + && ThermometerWidget.SCALE_MODE_PROPS.contains(property.getName()); + return false; + } + void fill(final UndoableActionManager undo, final Collection> properties, final List other) { @@ -163,6 +185,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/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..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, @@ -241,6 +242,7 @@ public class Messages WidgetProperties_HourTickMarkColor, WidgetProperties_HourTickMarkVisible, WidgetProperties_Increment, + WidgetProperties_InnerPadding, WidgetProperties_InitialIndex, WidgetProperties_Insets, WidgetProperties_Interactive, @@ -326,6 +328,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/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 6f8e5ecd11..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,10 +26,13 @@ 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.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; import org.phoebus.ui.vtype.ScaleFormat; /** Base class for PV widgets that display a numeric value on a scale @@ -46,7 +53,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 +132,43 @@ 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); + + /** '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; @@ -141,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); @@ -178,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 48666c55da..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 @@ -7,9 +7,7 @@ *******************************************************************************/ 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; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFillColor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; @@ -32,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; @@ -74,36 +71,9 @@ 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); - /** '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 @@ -161,18 +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 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 */ @@ -193,10 +154,11 @@ 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)); - properties.add(border_width_prop = propTankBorderWidth.createProperty(this, 0)); + properties.add(border_width = propTankBorderWidth.createProperty(this, 0)); } @Override @@ -208,75 +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 '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/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 278fcc4753..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 @@ -227,6 +228,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 @@ -310,6 +312,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..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 @@ -227,6 +228,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 @@ -310,6 +312,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/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 f544fa699e..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 @@ -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)); @@ -85,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. * @@ -138,6 +151,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 +198,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 +228,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/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); + } +} 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..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 @@ -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()), @@ -121,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/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 new file mode 100644 index 0000000000..63c80f08bb --- /dev/null +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/RTScaledWidgetRepresentation.java @@ -0,0 +1,359 @@ +/******************************************************************************* + * 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
    • + *
    • {@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 + */ +@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); + 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. + *

    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); + } + + /** 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, + * 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 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. */ + 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/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-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..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 @@ -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,266 +7,51 @@ *******************************************************************************/ 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); - 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.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); - 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.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()); - } + applyScaleLook(); + tank.setBackground(JFXUtil.convert(model_widget.propBackground().getValue())); + tank.setEmptyColor(JFXUtil.convert(model_widget.propEmptyColor().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..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 @@ -22,6 +22,20 @@ 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; + + /** 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 32724cbba7..970f7ef0cd 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,16 @@ 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 + +# 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 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..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. * @@ -94,6 +99,43 @@ 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; + + /** 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; @@ -219,6 +261,42 @@ 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(); + } + + /** 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) { @@ -284,6 +362,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]. @@ -522,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 @@ -529,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] @@ -549,11 +676,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; @@ -568,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() { @@ -602,15 +954,34 @@ 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; - 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)); @@ -619,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. @@ -637,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/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) 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()