"""Dear PyGui renderer foundation for gauge drawlists.""" from __future__ import annotations import math import time from typing import Any, cast import dearpygui.dearpygui as dpg from .animation import advance_animation from .draw_layers import DrawLayers from .gauges import ( AnalogGaugeConfig, BarGaugeConfig, BatteryGaugeConfig, CompassGaugeConfig, LevelGaugeConfig, MultiValueGaugeConfig, SegmentedGaugeConfig, StatusLightConfig, ) from .scales import ( active_segments, fill_fraction, fill_span, format_gauge_value, generate_ticks, normalized_span, value_to_angle, ) from .sizing import effective_size, square_gauge_rect, update_measured_size from .smoothing import advance_smoothing from .state import ( DirtyFlags, GaugeState, animation_config, size_state_for_gauge, smoothing_config, update_state_size, ) from .themes import GaugeStyle from .types import StatusState, Tag Rect = tuple[float, float, float, float] class GaugeRenderer: """Small renderer that keeps a gauge drawlist populated between updates.""" def __init__(self, state: GaugeState) -> None: self.state = state self.layers: DrawLayers = DrawLayers() self._scheduled = False def render(self) -> None: state = self.state self._measure() self._advance_values() self._redraw() state.dirty = DirtyFlags.NONE if state.animation_state.active or state.smoothing_state.active: self.schedule_frame() def delete(self) -> None: if dpg.does_item_exist(self.state.container_tag): dpg.delete_item(self.state.container_tag) def schedule_frame(self) -> None: if self._scheduled: return self._scheduled = True frame = dpg.get_frame_count() + 1 dpg.set_frame_callback(frame, self._frame_callback) def debug_state(self) -> dict[str, Any]: state = self.state return { "container_tag": state.container_tag, "drawlist_tag": state.drawlist_tag, "layers": {layer.name: tuple(layer.item_tags) for layer in self.layers.all_layers()}, "scheduled": self._scheduled, } def _frame_callback(self) -> None: self._scheduled = False self.state.dirty |= DirtyFlags.DYNAMIC | DirtyFlags.TEXT self.render() def _measure(self) -> None: state = self.state if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): return width = dpg.get_item_width(state.drawlist_tag) or 0 height = dpg.get_item_height(state.drawlist_tag) or 0 size = size_state_for_gauge(state) if update_measured_size(size, width, height): update_state_size(state, size) def _advance_values(self) -> None: state = self.state now = time.monotonic() config = animation_config(state.config) if state.animation_state.active: state.displayed_value, active = advance_animation(state.animation_state, config, now) state.animation_state.active = active smoothing = smoothing_config(state.config) if not state.animation_state.active and smoothing is not None and smoothing.enabled: state.displayed_value, _ = advance_smoothing( state.smoothing_state, state.clamped_value, smoothing ) def _redraw(self) -> None: state = self.state if state.drawlist_tag is None or not dpg.does_item_exist(state.drawlist_tag): return width, height = effective_size(size_state_for_gauge(state)) width = width or max(state.config.width, 1) or 180 height = height or max(state.config.height, 1) or 80 dpg.configure_item(state.drawlist_tag, width=width, height=height, show=state.config.show) dpg.configure_item(state.container_tag, show=state.config.show) dpg.delete_item(state.drawlist_tag, children_only=True) self.layers.clear() style = state.config.style or GaugeStyle() self._draw_frame(width, height, style) if state.gauge_type == "digital": self._draw_digital(width, height, style) elif state.gauge_type in {"analog", "compass"}: self._draw_analog(width, height, style) elif state.gauge_type in {"bar", "level"}: self._draw_bar_or_level(width, height, style) elif state.gauge_type == "segmented": self._draw_segmented(width, height, style) elif state.gauge_type == "thermometer": self._draw_thermometer(width, height, style) elif state.gauge_type == "battery": self._draw_battery(width, height, style) elif state.gauge_type == "multi_value": self._draw_multi_value(width, height, style) elif state.gauge_type == "status_light": self._draw_status_light(width, height, style) else: self._draw_placeholder_text(width, height, style) def _draw_frame(self, width: int, height: int, style: GaugeStyle) -> None: state = self.state drawlist = _drawlist_tag(state) background = self.layers.background.add(f"{state.tag}__background") frame = self.layers.static.add(f"{state.tag}__frame") dpg.draw_rectangle( (0, 0), (width, height), parent=drawlist, tag=background, color=style.border_color, fill=style.background_color, rounding=6, thickness=1, ) dpg.draw_rectangle( (4, 4), (max(width - 4, 4), max(height - 4, 4)), parent=drawlist, tag=frame, color=style.frame_color, rounding=4, thickness=1, ) def _draw_label(self, style: GaugeStyle, *, x: float = 12, y: float = 10) -> None: state = self.state if state.config.show_label and state.config.label: drawlist = _drawlist_tag(state) label = self.layers.text.add(f"{state.tag}__label") dpg.draw_text( (x, y), state.config.label, parent=drawlist, tag=label, color=style.muted_text_color, size=14, ) def _draw_digital(self, _width: int, height: int, style: GaugeStyle) -> None: self._draw_label(style) state = self.state drawlist = _drawlist_tag(state) value_tag = self.layers.dynamic.add(f"{state.tag}__value") size = max(min(height * 0.34, 34), 20) dpg.draw_text( (12, max((height - size) * 0.52, 26)), self._display_text(), parent=drawlist, tag=value_tag, color=style.value_color if not state.invalid_value else style.redline_color, size=size, ) def _draw_placeholder_text(self, _width: int, height: int, style: GaugeStyle) -> None: self._draw_label(style) state = self.state drawlist = _drawlist_tag(state) value_tag = self.layers.dynamic.add(f"{state.tag}__value") dpg.draw_text( (12, max(height * 0.45 - 10, 24)), self._display_text(), parent=drawlist, tag=value_tag, color=style.value_color if not state.invalid_value else style.redline_color, size=24, ) def _draw_bar_or_level(self, width: int, height: int, style: GaugeStyle) -> None: state = self.state config = state.config if not isinstance(config, BarGaugeConfig | LevelGaugeConfig): return self._draw_label(style) bar = _bar_rect(width, height, config.orientation) self._draw_bar_track(bar, style, config.corner_radius) self._draw_thresholds(bar, config.orientation, config.corner_radius) if state.displayed_value is not None and not state.invalid_value: start, end = fill_span( state.displayed_value, config.min_value, config.max_value, fill_mode=config.fill_mode, clamp=config.clamp, ) self._draw_bar_fill(_oriented_rect(bar, start, end, config.orientation), style) self._draw_markers(bar, config.orientation) if config.show_value: drawlist = _drawlist_tag(state) value_tag = self.layers.text.add(f"{state.tag}__value") dpg.draw_text( (12, height - 24), self._display_text(), parent=drawlist, tag=value_tag, color=style.text_color, size=14, ) def _draw_bar_track(self, rect: Rect, style: GaugeStyle, rounding: float) -> None: drawlist = _drawlist_tag(self.state) tag = self.layers.static.add(f"{self.state.tag}__bar_track") dpg.draw_rectangle( (rect[0], rect[1]), (rect[2], rect[3]), parent=drawlist, tag=tag, color=style.border_color, fill=style.frame_color, rounding=rounding, thickness=1, ) def _draw_bar_fill(self, rect: Rect, style: GaugeStyle) -> None: if rect[2] <= rect[0] or rect[3] <= rect[1]: return drawlist = _drawlist_tag(self.state) tag = self.layers.dynamic.add(f"{self.state.tag}__bar_fill") dpg.draw_rectangle( (rect[0], rect[1]), (rect[2], rect[3]), parent=drawlist, tag=tag, color=style.value_color, fill=style.value_color, rounding=2, thickness=1, ) def _draw_thresholds(self, rect: Rect, orientation: str, rounding: float) -> None: state = self.state drawlist = _drawlist_tag(state) for index, threshold in enumerate(state.config.thresholds): start, end = normalized_span( threshold.start, threshold.end, state.config.min_value, state.config.max_value ) threshold_rect = _oriented_rect(rect, start, end, orientation) if threshold_rect[2] <= threshold_rect[0] or threshold_rect[3] <= threshold_rect[1]: continue tag = self.layers.thresholds.add(f"{state.tag}__threshold_{index}") dpg.draw_rectangle( (threshold_rect[0], threshold_rect[1]), (threshold_rect[2], threshold_rect[3]), parent=drawlist, tag=tag, color=threshold.color, fill=threshold.color, rounding=rounding, thickness=1, ) def _draw_markers(self, rect: Rect, orientation: str) -> None: state = self.state drawlist = _drawlist_tag(state) for index, marker in enumerate(state.config.markers): fraction = fill_fraction( marker.value, state.config.min_value, state.config.max_value, clamp=True ) tag = self.layers.markers.add(f"{state.tag}__marker_{index}") if orientation == "vertical": y = rect[3] - (rect[3] - rect[1]) * fraction dpg.draw_line( (rect[0] - 4, y), (rect[2] + 4, y), parent=drawlist, tag=tag, color=marker.color, thickness=marker.thickness, ) else: x = rect[0] + (rect[2] - rect[0]) * fraction dpg.draw_line( (x, rect[1] - 4), (x, rect[3] + 4), parent=drawlist, tag=tag, color=marker.color, thickness=marker.thickness, ) def _draw_status_light(self, width: int, height: int, style: GaugeStyle) -> None: state = self.state config = state.config if not isinstance(config, StatusLightConfig): return drawlist = _drawlist_tag(state) status = _resolve_status(config, style) radius = max(min(width, height) * 0.18, 10) center = (max(radius + 14, 28), height * 0.5) light = self.layers.dynamic.add(f"{state.tag}__status_light") dpg.draw_circle( center, radius, parent=drawlist, tag=light, color=style.border_color, fill=status.color, thickness=2, ) text = status.text or str(config.state) if config.show_label and config.label: text = f"{config.label}: {text}" text_tag = self.layers.text.add(f"{state.tag}__status_text") dpg.draw_text( (center[0] + radius + 14, max(center[1] - 9, 8)), text, parent=drawlist, tag=text_tag, color=status.value_color or style.text_color, size=18, ) def _draw_analog(self, width: int, height: int, style: GaugeStyle) -> None: state = self.state config = state.config if not isinstance(config, AnalogGaugeConfig | CompassGaugeConfig): return drawlist = _drawlist_tag(state) rect = square_gauge_rect(width - 16, height - 16) cx = 8 + rect.x + rect.width / 2.0 cy = 8 + rect.y + rect.height / 2.0 radius = max(min(rect.width, rect.height) * 0.43, 12.0) if config.show_label and config.label: self._draw_label(style, x=12, y=10) self._draw_arc( (cx, cy), radius, config.start_angle, config.end_angle, style.frame_color, config.arc_width, ) for threshold in config.thresholds: start = value_to_angle( threshold.start, config.min_value, config.max_value, config.start_angle, config.end_angle, ) end = value_to_angle( threshold.end, config.min_value, config.max_value, config.start_angle, config.end_angle, ) self._draw_arc((cx, cy), radius, start, end, threshold.color, config.arc_width + 2) if config.redline_start is not None: start = value_to_angle( config.redline_start, config.min_value, config.max_value, config.start_angle, config.end_angle, ) self._draw_arc( (cx, cy), radius, start, config.end_angle, style.redline_color, config.arc_width + 3 ) ticks = generate_ticks(config.min_value, config.max_value, max(config.major_ticks, 2)) for index, tick in enumerate(ticks): angle = value_to_angle( tick, config.min_value, config.max_value, config.start_angle, config.end_angle ) outer = _point_on_circle(cx, cy, radius + 4, angle) inner = _point_on_circle(cx, cy, radius - 10, angle) tag = self.layers.static.add(f"{state.tag}__tick_{index}") dpg.draw_line( outer, inner, parent=drawlist, tag=tag, color=style.tick_color, thickness=2 ) if config.show_tick_labels: label_pos = _point_on_circle(cx, cy, radius - 26, angle) label = self.layers.text.add(f"{state.tag}__tick_label_{index}") text = f"{tick:.0f}" dpg.draw_text( (label_pos[0] - 10, label_pos[1] - 6), text, parent=drawlist, tag=label, color=style.muted_text_color, size=10, ) for index, marker in enumerate(config.markers): angle = value_to_angle( marker.value, config.min_value, config.max_value, config.start_angle, config.end_angle, ) outer = _point_on_circle(cx, cy, radius + 8, angle) inner = _point_on_circle(cx, cy, radius - 18, angle) tag = self.layers.markers.add(f"{state.tag}__marker_{index}") dpg.draw_line( outer, inner, parent=drawlist, tag=tag, color=marker.color, thickness=marker.thickness, ) if state.displayed_value is not None and not state.invalid_value: angle = value_to_angle( state.displayed_value, config.min_value, config.max_value, config.start_angle, config.end_angle, ) end = _point_on_circle(cx, cy, radius * config.needle_length, angle) needle = self.layers.dynamic.add(f"{state.tag}__needle") dpg.draw_line( (cx, cy), end, parent=drawlist, tag=needle, color=style.needle_color, thickness=config.needle_width, ) cap = self.layers.dynamic.add(f"{state.tag}__center_cap") dpg.draw_circle( (cx, cy), config.center_cap_radius, parent=drawlist, tag=cap, color=style.border_color, fill=style.value_color, ) if config.show_value: value = self.layers.text.add(f"{state.tag}__value") dpg.draw_text( (cx - radius * 0.45, cy + radius * 0.38), self._display_text(), parent=drawlist, tag=value, color=style.text_color, size=14, ) def _draw_arc( self, center: tuple[float, float], radius: float, start_angle: float, end_angle: float, color: tuple[int, ...], thickness: float, ) -> None: drawlist = _drawlist_tag(self.state) steps = max(int(abs(end_angle - start_angle) / 8), 2) previous = _point_on_circle(center[0], center[1], radius, start_angle) for index in range(1, steps + 1): angle = start_angle + (end_angle - start_angle) * (index / steps) current = _point_on_circle(center[0], center[1], radius, angle) tag = self.layers.static.add( f"{self.state.tag}__arc_{len(self.layers.static.item_tags)}" ) dpg.draw_line( previous, current, parent=drawlist, tag=tag, color=color, thickness=thickness ) previous = current def _draw_segmented(self, width: int, height: int, style: GaugeStyle) -> None: state = self.state config = state.config if not isinstance(config, SegmentedGaugeConfig): return self._draw_label(style) drawlist = _drawlist_tag(state) count = config.segments active = 0 if state.displayed_value is not None and not state.invalid_value: active = active_segments( state.displayed_value, config.min_value, config.max_value, count ) gap = 4.0 left, top, right, bottom = 12.0, 38.0, max(width - 12.0, 13.0), max(height - 28.0, 39.0) segment_width = max((right - left - gap * (count - 1)) / count, 1.0) for index in range(count): x1 = left + index * (segment_width + gap) x2 = x1 + segment_width color = style.value_color if index < active else style.frame_color tag = self.layers.dynamic.add(f"{state.tag}__segment_{index}") dpg.draw_rectangle( (x1, top), (x2, bottom), parent=drawlist, tag=tag, color=style.border_color, fill=color, rounding=config.corner_radius, ) def _draw_thermometer(self, width: int, height: int, style: GaugeStyle) -> None: self._draw_bar_or_level(width, height, style) drawlist = _drawlist_tag(self.state) bulb = self.layers.dynamic.add(f"{self.state.tag}__bulb") dpg.draw_circle( (width / 2.0, max(height - 30.0, 18.0)), 16, parent=drawlist, tag=bulb, color=style.border_color, fill=style.value_color, ) def _draw_battery(self, width: int, height: int, style: GaugeStyle) -> None: state = self.state config = state.config if not isinstance(config, BatteryGaugeConfig): return self._draw_label(style) drawlist = _drawlist_tag(state) body = (18.0, 40.0, max(width - 34.0, 58.0), max(height - 26.0, 74.0)) nub = ( body[2], body[1] + (body[3] - body[1]) * 0.33, body[2] + 10, body[1] + (body[3] - body[1]) * 0.67, ) dpg.draw_rectangle( (body[0], body[1]), (body[2], body[3]), parent=drawlist, tag=self.layers.static.add(f"{state.tag}__battery_body"), color=style.border_color, thickness=2, rounding=3, ) dpg.draw_rectangle( (nub[0], nub[1]), (nub[2], nub[3]), parent=drawlist, tag=self.layers.static.add(f"{state.tag}__battery_nub"), color=style.border_color, fill=style.border_color, rounding=2, ) if state.displayed_value is not None and not state.invalid_value: fraction = fill_fraction( state.displayed_value, config.min_value, config.max_value, clamp=True ) pad = 5.0 fill = ( body[0] + pad, body[1] + pad, body[0] + pad + (body[2] - body[0] - pad * 2) * fraction, body[3] - pad, ) if fill[2] > fill[0]: dpg.draw_rectangle( (fill[0], fill[1]), (fill[2], fill[3]), parent=drawlist, tag=self.layers.dynamic.add(f"{state.tag}__battery_fill"), color=style.value_color, fill=style.value_color, rounding=2, ) if config.show_value: dpg.draw_text( (18, height - 22), self._display_text(), parent=drawlist, tag=self.layers.text.add(f"{state.tag}__value"), color=style.text_color, size=14, ) def _draw_multi_value(self, _width: int, height: int, style: GaugeStyle) -> None: state = self.state config = state.config if not isinstance(config, MultiValueGaugeConfig): return self._draw_label(style) drawlist = _drawlist_tag(state) values = config.values or {} y = 34.0 for index, (name, value) in enumerate(values.items()): text = f"{name}: {value}" tag = self.layers.text.add(f"{state.tag}__multi_{index}") dpg.draw_text((12, y), text, parent=drawlist, tag=tag, color=style.text_color, size=16) y += 22.0 if y > height - 18: break def _display_text(self) -> str: state = self.state if not state.config.show_value: return "" if state.invalid_value or state.displayed_value is None: return "--" if state.config.format_value is not None: return state.config.format_value(state.displayed_value) unit = state.config.unit if state.config.show_unit else "" return format_gauge_value(state.displayed_value, state.config.precision, unit) def render_attached_gauge(state: GaugeState) -> None: if state.renderer is not None: state.renderer.render() def _drawlist_tag(state: GaugeState) -> Tag: return cast(Tag, state.drawlist_tag) def _bar_rect(width: int, height: int, orientation: str) -> Rect: if orientation == "vertical": top = 34.0 bottom = max(height - 34.0, top + 1.0) bar_width = min(max(width * 0.34, 28.0), 56.0) left = (width - bar_width) / 2.0 return left, top, left + bar_width, bottom top = 36.0 bottom = max(height - 32.0, top + 1.0) return 12.0, top, max(width - 12.0, 13.0), bottom def _oriented_rect(rect: Rect, start: float, end: float, orientation: str) -> Rect: start = min(max(start, 0.0), 1.0) end = min(max(end, 0.0), 1.0) if orientation == "vertical": height = rect[3] - rect[1] return rect[0], rect[3] - height * end, rect[2], rect[3] - height * start width = rect[2] - rect[0] return rect[0] + width * start, rect[1], rect[0] + width * end, rect[3] def _point_on_circle( cx: float, cy: float, radius: float, angle_degrees: float ) -> tuple[float, float]: radians = math.radians(angle_degrees) return cx + math.cos(radians) * radius, cy - math.sin(radians) * radius def _resolve_status(config: StatusLightConfig, style: GaugeStyle) -> StatusState: if config.states is not None and config.state in config.states: return config.states[config.state] if isinstance(config.state, bool): return StatusState( style.value_color if config.state else style.frame_color, "ON" if config.state else "OFF", ) return StatusState(style.value_color, str(config.state))