A circular gauge / meter (Phoenix)

Keywords : Gauge, Gauge meter, Speedometer, Speed meter, knob control.


Demonstrating :

Tested py3.8.10, wx4.x and Linux.

Tested py3.11.6, wx4.2.1 and Win10/11.

Are you ready to use some samples ? ;)

Test, modify, correct, complete, improve and share your discoveries ! (!)


First example

Latest version here : https://discuss.wxpython.org/t/a-circular-gauge-meter/36755

img_sample_one.png

   1 # -*- coding: utf-8 -*-
   2 '''
   3 Copyright Kevin Schlosser April 2019
   4 GNU General Public License v3.0
   5 
   6 https://github.com/kdschlosser/wxVolumeKnob
   7 
   8 Amendments by RolfofSaxony 2023
   9     Enabling the volume knob to become an all-round circular gauge, which can have multiple uses
  10     e.g. as a speedometer, a meter for voltage, amperes, revolutions per minute,  psi, a thermometer etc
  11 
  12     It has become reversible, orientable and invertible
  13 
  14     Amended Version 2.2
  15         code optimisation? to dramatically reduce certain calls during OnPaint, mainly creating the tick_list
  16         also only bind mouse move when Hotspots are active, otherwise, the function is called needlessly.
  17 
  18         Change
  19          TickFrequency must be >= the increment
  20         Change
  21          when neon_colour is calculated, to avoid unnecessary function calls
  22         Change
  23          value is set after min_value and max_value on initial start up, to ensure that it is within those 2 values
  24           this prevents an illegal value being set at startup.
  25         Change
  26          Left double mouse click is treated the same as a right click i.e. jump to click position
  27         Change
  28             automatic reduction of the number of texts to be displayed, now uses slicing as is it's faster
  29         Change
  30             automatic reduction of the number of texts to be displayed, is now aware of whether you are displaying
  31              the text inside the gauge or outside. Outside there's a maximum of 30 texts allowed, inside only 12.
  32         Change
  33             Gauge text now takes account of the Font settings of the KnobCtrl, if set, the exception is the Odometer
  34             (The font size is still calculated based on the size of the control)
  35         Change
  36             An attempt has been made to limit the number of ticks to below what I consider to be excessive.
  37             The arbitrary limit I've set is 600 for the entire gauge and no more than 1.66 ticks per degree,
  38              when setting the StartEndDegrees. An over crowded gauge is not only slow but visually messy
  39             Additionally, the number of Large ticks attempts to stay below 30, again for visual clarity
  40         Addition
  41             DefinedScaleValues are user defined tick override values
  42             this is a list of values within the scope of the gauge and each divisible by the tick_frequency,
  43              where you want a large tick with a text value.
  44             It overrides the automatic calculation of ticks and tick values, replacing it with your predefined values
  45              e.g. self.ctrl.DefinedScaleValues = [1.0, 4.4, 9.0 11.0] will only display large ticks with those values.
  46             Small ticks are unaffected.
  47             Values that are deemed not within the gauge range or not divisible by the tick frequency, will be excluded.
  48 
  49     Amended Version 2.1
  50         Fixed bug in mouse drag.
  51         Attempting to prevent unnecessary events once the meter's minimum value was hit, I failed to process the fact
  52          that it had hit the minimum - Thanks go to RichardT for pointing it out.
  53 
  54         Addition of variable DisableMinMaxMouseDrag - True or False - Default False
  55             If set to True, disables the ability, when dragging, to accidently flip from Min to Max or Max to Min
  56             The maximum valid mouse drag, by default, is set to 50% of the difference in degrees between start and end degrees.
  57             This can be adjusted by setting the control's variable 'mouse_max_move' to a value of your choice e.g.
  58                 self.ctrl.mouse_max_move = 50
  59             Any attempt to drag by more than that, is cancelled.
  60             Obviously, this also means that you can disable mouse dragging altogether, simply by setting mouse_max_move to zero.
  61 
  62     Amended Version 2.0
  63 
  64         Addition of variable UseHotSpots, which notes the dial thumb position and the Odometer position
  65         If the mouse is hovered over those positions whilst UseHotSpots is True, a ToolTip
  66             is displayed of the current value of the control for the dial thumb and the elapsed running time for the Odometer.
  67             The Odometer may also have Text set in addition, see below
  68 
  69         Addition of variable OdometerToolTip
  70             If given a string value this will be display when hovering over the Odometer, if SetHotSpot is active,
  71              in addition to the running time.
  72 
  73         Addition of function SetHotSpots()
  74             SetHotSpots toggles UseHotSpots between True and False
  75              additionally it turns off standard ToolTip if it is Set
  76 
  77         Addition of function GetHotSpots()
  78             returns current value of UseHotSpots
  79 
  80         Addition of function SetDefaultTickColour(colour)
  81             overrides the default tick colour, the colour used beyond the current value (normally Dark Grey)
  82             or the foreground colour of the parent panel.
  83 
  84         Addition of function GetDefaultTickColour(colour)
  85             returns the current default tick colour
  86 
  87         Addition of function SetTickRangePercentage(True/False) - Default True
  88             By default the tick range values given are converted to percentages to colour the gauge
  89             If this is set to False the values are used as discrete values
  90             This reinstates the original method as written by Kevin Schlosser
  91 
  92         Addition of variable Caption.
  93             If given a string value, this text is shown centred, at the foot of the meter.
  94             If positioned at the foot, there is only room for a single line
  95             The Caption gets it's colour from the DefaultTickColour.
  96 
  97         Addition CaptionPos = int Default 0
  98             The Caption by default is positioned centrally at the bottom of the gauge
  99             If you set this variable the caption will be positioned:
 100                 1 - Top Left
 101                 2 - Top Right
 102                 3 - Bottom Left
 103                 4 - Bottom Right
 104             In these positions there is more room for text, which may include newlines.
 105 
 106         Addition of coping with numeric keypad input of digits, as well as ordinary digits, to jump by a percentage
 107             0 = min value, 1 = 10% ...... 9 = 90%
 108 
 109         Addition of SetStartEndDegrees(start=n, end=n) - Permitting you to orient the gauge and decide how much of the circle to use
 110             Defaults 135.0 (7:30) and 405.0 (4:30) respectively
 111             Positions are specified in degrees with 0 degree angle corresponding to the positive horizontal axis (3 o’clock) direction
 112                 following the convention in drawing wx.DC arcs.
 113             i.e. to set the minimum position at 9 o'clock the start angle would be 180.0
 114                  to set the maximum position at 3 o'clock the end angle would be 360.0
 115             Each hourly position is +30°, so 7:30 would be 4.5 * 30 i.e. 7:30 minus 3:00 = 4.5 hours * 30 = 135.0
 116                                           with an end position of 4:30 = (1.5 + 12) * 30 = 405.0
 117 
 118             Both the Start and the End positions must both be Positive or both be Negative and the Range cannot exceed 360°
 119 
 120             Negative values are permitted but remember they are inverse:
 121                 so -135° is 10:30 and -405° is 1:30
 122 
 123             Reversed Start and End positions are allowed, to make the gauge appear to run from positive to negative
 124              e.g. start 405.0 end 135.0 or -405.0 to -135.0
 125 
 126             i.e.
 127                 Start   End        Min position   Max Position        Where is Midway
 128                 135     405         7:30            4:30                Top
 129                 405     135         4:30            7:30                Top
 130                -135    -405        10:30            1:30                Bottom
 131                -405    -135         1:30           10:30                Bottom
 132 
 133         Addition  GetStartEndDegrees() - returns a tuple
 134 
 135         Addition  SetAlwaysTickColours(True or False)
 136             Normally the ticks are only coloured based on the current value.
 137             If this is set to True, the ticks permanently have the colours assigned by the TickColourRanges and
 138                 the current position is denoted by the current value tick, being the default tick colour.
 139 
 140         Addition ShowScale True or False
 141             ShowScale now makes a best effort to show text scale values on the gauge, without overcrowding the image
 142             By default the values are displayed on the exterior of the gauge
 143 
 144         Addition InsideScale True or False
 145             If set in combination with either ShowScale or ShowMinMax, the scale is displayed on the interior
 146              of the gauge.
 147             Given the restricted space available, this can be slightly hit and miss, best results are achieved
 148              by adjusting the tick frequency.
 149 
 150         Addition GaugeImage = a wx.Image
 151             This, much like GaugeText displays the input centrally in the gauge.
 152             The image should be a wx.Image and is expected to be transparent.
 153             The image is resized, based on the control's size, although it makes sense to ensure
 154              that the image is as small as is appropriate, beforehand, just for efficiency.
 155             If you are displaying text too, you may wish to display that further up the gauge,
 156              include 1 or 2 linefeeds in the text, to separate it from the image.
 157 
 158         Addition GaugeImagePos = int Default 0
 159             The image by default is positioned centrally
 160             If you set this variable the image will be positioned:
 161                 1 - Top Left
 162                 2 - Top Right
 163                 3 - Bottom Left
 164                 4 - Bottom Right
 165 
 166         Addition of EVT_SCROLL_CHANGED, activated if the value changes via SetValue()
 167             This caters for events to be monitored if you feed value changes into the gauge, rather than treating it
 168              purely as an input control.
 169             Events available for Binding are:
 170                 wx.EVT_SCROLL_TOP, the gauge has hit maximum value;
 171                 wx.EVT_SCROLL_BOTTOM, the gauge has hit minimum value;
 172                 wx.EVT_SCROLL_LINEUP, gauge moved up one increment:;
 173                 wx.EVT_SCROLL_LINEDOWN', gauge moved down one increment
 174                 wx.EVT_SCROLL_PAGEUP, the user hit page up;
 175                 wx.EVT_SCROLL_PAGEDOWN, the user hit page down;
 176                 wx.EVT_SCROLL_THUMBRELEASE, the mouse has been released;
 177                 wx.EVT_SCROLL_CHANGED, the gauge value has changed;
 178             and the catch all, wx.EVT_SCROLL, an event occurred.
 179 
 180         Addition of variable OdometerBackgroundColour - Default None
 181 
 182         Bug fixes:
 183         The knobStyle values have been corrected.
 184          Default: KNOB_GLOW | KNOB_DEPRESSION | KNOB_HANDLE_GLOW | KNOB_TICKS | KNOB_SHADOW
 185          Originally the knobStyle values always seemed to produce True, so everything was always turn On, as the values
 186           assigned were incorrect.
 187          The knobStyle is one of the initial parameters but can be  overridden with SetKnobStyle(...)
 188             e.g. self.ctrl.SetKnobStyle(knob.KNOB_TICKS | knob.KNOB_SHADOW | knob.KNOB_DEPRESSION)
 189           valid style values are:
 190             KNOB_GLOW = 1                   # Adds a neon glow around the gauge
 191             KNOB_DEPRESSION = 2             # Adds the central depression
 192             KNOB_HANDLE_GLOW = 4            # Adds a neon glow to the thumb
 193             KNOB_TICKS = 8                  # Adds ticks to the gauge
 194             KNOB_SHADOW = 16                # Adds a shadow to the gauge
 195             KNOB_RIM = 32                   # Adds a coloured rim to indicate the position of the gauge
 196 
 197             KNOB_RIM is a new style, as an alternative to KNOB_GLOW, (although they can be used together)
 198             Whereas KNOB_GLOW lights up the gauge rim with the relevant diffused colour range, KNOB_RIM lights up
 199              the gauge rim with the relevant solid colour range, only up to the current value.
 200              In essence it acts as a separate value indicator.
 201 
 202             If you are a fan of bitwise operations, I find them confusing, you can manipulate the existing flags,
 203              using GetKnobStyle()
 204              e.g.
 205                 knobstyle = self.ctrl.GetKnobStyle()
 206                 self.ctrl.SetKnobStyle((knobstyle | knob.KNOB_RIM) &~ knob.KNOB_SHADOW)
 207              which adds the RIM style to the existing setting and removes the Shadow at the same time.
 208 
 209         Changes to the methods used to calculate the tick positions
 210             calculating tick positions for integer values was fine but trying to set the increments for fine values,
 211                 such as a meter measuring from -1.0 to 1.0 with an increment of 0.01 and a tick frequency of 0.02,
 212                 for example, runs into floating point Modulo issues ( a horror story in its own right)
 213             Either the tickfrequency could be refused or there would be missing ticks or too many ticks
 214             I've attempted to resolve those by importing Decimal and using round() and some other tweaks, including
 215              defining the required precision.
 216              The increment determines the precision that will be used e.g. 0.01 would set precision to 2, 0.005 to 3.
 217             Hopefully, I haven't broken anything. :)
 218 
 219         SetTickColours now handles the various ways of defining a colour e.g.
 220             (77, 77, 255)
 221             wx.Colour('#7777ff')
 222             '#7777ff'
 223             wx.BLUE
 224          The Bug was in setting the neon_colour for the ticks and body of the meter which expects a tuple.
 225 
 226         Changes:
 227 
 228         Change to existing function GetAverageSpeed()
 229          This now returns a tuple of Average value and the elapsed time period in seconds
 230 
 231         Change PageUp, PageDown to simple + or - 10%
 232 
 233         Event reporting can no longer issue double events e.g. SCROLL_PAGEUP and SCROLL_CHANGED
 234             Now the specific event is reported or SCROLL_CHANGED not both.
 235 
 236         Change TickColourRanges to cope with values < 1
 237             The tickcolourranges didn't handle ranges which strayed negative, they calculated a percentage of the maximum value.
 238             They now handle a range which goes from negative to positive e.g.
 239                 SetTickColourRanges([10, 50, 75]) for a minimum value of -10.0 and max of 20.0,
 240                  would set the values at -7.0, 5.0 and 12.5 as the range is actually 30 ( -10 -> 20 )
 241             It should also handle purely negative ranges
 242 
 243         GaugeText replaces SpeedoText in a variable name change - sorry about that, just more appropriate
 244 
 245         GaugeText has also become more vertically centrally located.
 246             If you wish the text to display further up the gauge include 1 or 2 linefeeds in the text.
 247             This is especially true if including a centrally located image with GaugeImage()
 248 
 249     Amended Version 1.1
 250         Bug fix for incorrect Unbind of the odometer update timer
 251         Addition of a pointer spine
 252         Addition of variable ShowMinMax values
 253         Addition of variable OdometerColour
 254 
 255     Amended version 1.0
 256 
 257     Display optional Volume/Speed value as text
 258      To allow for very large values when using RPM for example, if the initial value is set as an integer
 259         only an integer is displayed, allowing for much larger numbers to be catered to.
 260 
 261     Enable Right Click to jump to a position
 262 
 263     Calculate tick colour as a percentage of the maximum value rather than fixed value
 264 
 265     Optional pointer with shadow for Speedometer feel
 266 
 267     Optional Speedo text, expected to be something like Mph, Kph, Rpm ft/s etc
 268 
 269     Optional Odometer - defaults calculation to distance covered per hour
 270 
 271     Optional odometer period unit - "H" per Hour, "M" per Minute, "S" per Second - Default "H"
 272 
 273     Optional Odometer update period - expects a millisecond value like wxTimer
 274         a timer that updates the odometer irrespective of the value being changed
 275          based on the current Speed/Velocity
 276         A value > than 0 turns the odometer on, <= 0 turns it off
 277 
 278     Plus minor adjustments
 279 
 280     Variables added:
 281         ShowToolTip = True          Shows a tooltip of the Volume/Speed value
 282         ShowValue = True            Shows the Volume/Speed value in the centre of the widget
 283         ShowPointer = True          Shows a pointer indicating the Volume/Speed
 284         PointerColour = None        Sets the Colour of the pointer, allows for transparency
 285                                      The default is to use the current tick colour.
 286         SpeedoText = ''             Sets a short text to be displayed indicating the measurement
 287                                      e.g. Mph, Kph, Rpm
 288         ShowOdometer = False        True/False
 289         OdometerUpdate = 0          Value in milliseconds - Sets automatic odometer update
 290                                      without this the odometer is only updated on an event
 291                                     Note:
 292                                         Showing the odometer with an auto update is expensive and the more
 293                                         frequent the update, the more expensive
 294                                         ***************************************
 295         OdometerPeriod = "H"        "H", "M" or "S"
 296                                      If you change this after setting it initially, the odometer readings
 297                                       will be nonsense, you will have a mixture of unit readings
 298 
 299     Additional functions:
 300 
 301         GetOdometerUpdate()         return Odometer update period
 302 
 303         SetOdometerUpdate(value)    Set odometer update period in milliseconds
 304                                     Sets or cancels the odometer depending on positive or negative value
 305 
 306         GetAverageSpeed()           Returns the average speed depending on the odometer period unit
 307                                      from program start to now (running time in secs, as of version 2.0)
 308 
 309         GetOdometerValue()          Returns current odometer value
 310 
 311         GetOdometerHistory()        Returns the history of speed changes as a list
 312 
 313 '''
 314 
 315 import wx
 316 import math
 317 from decimal import Decimal
 318 import time
 319 
 320 digits_numeric_pad = [wx.WXK_NUMPAD0, wx.WXK_NUMPAD1, wx.WXK_NUMPAD2, wx.WXK_NUMPAD3, wx.WXK_NUMPAD4,
 321                       wx.WXK_NUMPAD5, wx.WXK_NUMPAD6, wx.WXK_NUMPAD7, wx.WXK_NUMPAD8, wx.WXK_NUMPAD9]
 322 
 323 
 324 def frange(start, stop=None, step=1.0, prec=1):
 325     """
 326     Range function that accepts floats
 327     """
 328 
 329     start = float(start)
 330     step = float(step)
 331 
 332     if stop is None:
 333         stop, start = start, 0.0
 334 
 335     count = int(abs(stop - start) / step) + 1
 336     return iter(round(start + (n * step), prec) for n in range(count))
 337 
 338 
 339 def _remap(value, old_min, old_max, new_min, new_max):
 340     old_range = old_max - old_min
 341     new_range = new_max - new_min
 342     try:
 343        return (
 344             (((value - old_min) * new_range) / old_range) + new_min
 345         )
 346     except ZeroDivisionError as e:
 347         return new_min
 348 
 349 
 350 class Handler(object):
 351 
 352     def __init__(self, parent=None):
 353         self._size = None
 354         self._tick_list = None
 355         self._value = None
 356         self._min_value = None
 357         self._max_value = None
 358         self._thumb_multiplier = 0.04
 359         self._thumb_position = None
 360         self._thumb_radius = None
 361         self._radius = None
 362         self._thumb_orbit = None
 363         self._neon_radius = None
 364         self._neon_colour = None
 365         self._foreground_colour = None
 366         self._background_colour = None
 367         self._tick_pens = []
 368         self._tick_ranges = []
 369         self._tick_range_percentage = True
 370         self._tick_range_colours = []
 371         self._default_tick_pen = None
 372         self._tick_frequency = 2.0
 373         self._increment = 1.0
 374         self._secondary_colour = wx.Colour(255, 255, 255)
 375         self._primary_colour = wx.Colour(33, 33, 33)
 376         self._page_size = None
 377         self._glow = False
 378         self._depression = False
 379         self._thumb_glow = False
 380         self._ticks = False
 381         self._always_tick_colours = False
 382         self._shadow = False
 383         self._precision = 0
 384         self._start_degree = 135.0
 385         self._end_degree = 405.0
 386         self._mid_point = 90.0
 387         self._mid_point_adj = 45.0
 388         self._negative_mid_point = 270.0
 389         self._parent = parent
 390         if parent:
 391             self._font = parent.GetFont()
 392         else:
 393             self._font = None
 394 
 395 
 396     @property
 397     def shadow(self):
 398         return self._shadow
 399 
 400     @shadow.setter
 401     def shadow(self, value):
 402         self._shadow = value
 403 
 404     @property
 405     def glow(self):
 406         return self._glow
 407 
 408     @glow.setter
 409     def glow(self, value):
 410         self._glow = value
 411 
 412     @property
 413     def depression(self):
 414         return self._depression
 415 
 416     @depression.setter
 417     def depression(self, value):
 418         self._depression = value
 419 
 420     @property
 421     def thumb_glow(self):
 422         return self._thumb_glow
 423 
 424     @thumb_glow.setter
 425     def thumb_glow(self, value):
 426         self._thumb_glow = value
 427 
 428     @property
 429     def primary_colour(self):
 430         return self._primary_colour
 431 
 432     @primary_colour.setter
 433     def primary_colour(self, value):
 434         self._primary_colour = value
 435 
 436     @property
 437     def secondary_colour(self):
 438         return self._secondary_colour
 439 
 440     @secondary_colour.setter
 441     def secondary_colour(self, value):
 442         self._secondary_colour = value
 443 
 444     @property
 445     def foreground_colour(self):
 446         return self._foreground_colour
 447 
 448     @foreground_colour.setter
 449     def foreground_colour(self, value):
 450         self._tick_list = None
 451         self._default_tick_pen = wx.Pen(value, 2)
 452         self._foreground_colour = value
 453 
 454     @property
 455     def background_colour(self):
 456         return self._background_colour
 457 
 458     @background_colour.setter
 459     def background_colour(self, value):
 460         self._background_colour = value
 461 
 462     @property
 463     def min_value(self):
 464         return self._min_value
 465 
 466     @min_value.setter
 467     def min_value(self, value):
 468         self._min_value = value
 469 
 470     @property
 471     def max_value(self):
 472         return self._max_value
 473 
 474     @max_value.setter
 475     def max_value(self, value):
 476         self._max_value = value
 477 
 478     @property
 479     def mid_point(self):
 480         return self._mid_point
 481 
 482     @mid_point.setter
 483     # used when the mouse is clicked or dragged
 484     def mid_point(self, value):
 485         start, end, = value
 486         if start > end:                             # find gap between start and end
 487             gap = start - end
 488         else:
 489             gap = end - start
 490         if gap < 0:                                 # find the reverse of that
 491             mod_diff = gap % -360.0
 492         else:
 493             mod_diff = gap % 360.0
 494         mod_diff = (360 - mod_diff) / 2             # find halfway point in difference
 495         self._mid_point_adj = mod_diff              # Note halfway point
 496         if start > end:
 497             mod_diff = end - self._mid_point_adj
 498         else:
 499             mod_diff = start - self._mid_point_adj
 500         if mod_diff < 0:
 501             mod_diff = abs(mod_diff + 360)
 502         self._mid_point = mod_diff                  # mid point opposite range e.g. 0 -> 180 = 270°
 503 
 504     @property
 505     def negative_mid_point(self):
 506         return self._negative_mid_point
 507 
 508     @negative_mid_point.setter
 509     # used when the mouse is clicked or dragged
 510     def negative_mid_point(self, value):
 511         start, end, = value
 512         if start > end:                             # find gap between start and end
 513             gap = start - end
 514         else:
 515             gap = end - start
 516         if gap < 0:                                 # find the reverse of that
 517             mod_diff = gap % 360.0
 518         else:
 519             mod_diff = gap % -360.0
 520         mod_diff = mod_diff / 2                     # find halfway point in difference
 521         self._mid_point_adj = abs(mod_diff)         # Note halfway point adjustment
 522         if start < end:
 523             mod_diff = end + self._mid_point_adj
 524         else:
 525             mod_diff = start + self._mid_point_adj
 526 
 527         mod_diff = mod_diff % 360
 528         self._negative_mid_point = mod_diff         # mid point opposite range e.g. 0 -> 180 = 270°
 529 
 530     @property
 531     def mid_point_adj(self):
 532         return self._mid_point_adj
 533 
 534     @property
 535     def precision(self):
 536         return self._precision
 537 
 538     @precision.setter
 539     def precision(self, value):
 540         self._precision = value
 541 
 542     @property
 543     def start_degree(self):
 544         return self._start_degree
 545 
 546     @start_degree.setter
 547     def start_degree(self, value):
 548         self._start_degree = value
 549 
 550     @property
 551     def end_degree(self):
 552         return self._end_degree
 553 
 554     @end_degree.setter
 555     def end_degree(self, value):
 556         self._tick_list = None
 557         self._end_degree = value
 558 
 559     @property
 560     def size(self):
 561         return self._size
 562 
 563     @size.setter
 564     def size(self, value):
 565         self._radius = None
 566         self._thumb_radius = None
 567         self._neon_radius = None
 568         self._thumb_orbit = None
 569         self._thumb_position = None
 570         self._tick_list = None
 571         self._size = value
 572 
 573     @property
 574     def center(self):
 575         width, height = self.size
 576 
 577         return int(width / 2), int(height / 2)
 578 
 579     @property
 580     def radius(self):
 581         if self._radius is None:
 582             width, height = self.size
 583             radius = (min(width, height) // 2) * 0.75
 584             self._radius = radius
 585 
 586         return self._radius
 587 
 588     @property
 589     def value(self):
 590         return self._value
 591 
 592     @value.setter
 593     def value(self, value):
 594         self._thumb_position = None
 595         self._tick_list = None
 596         if value < self.min_value:
 597             value = self.min_value
 598         if value > self.max_value:
 599             value = self.max_value
 600         self._value = value
 601 
 602     @property
 603     def neon_radius(self):
 604         if self._neon_radius is None:
 605             radius = self.radius
 606             self._neon_radius = radius - 1
 607 
 608         return self._neon_radius
 609 
 610     @property
 611     def thumb_multiplier(self):
 612         return self._thumb_multiplier
 613 
 614     @thumb_multiplier.setter
 615     def thumb_multiplier(self, value):
 616         self._thumb_radius = None
 617         self._thumb_orbit = None
 618         self._thumb_position = None
 619         self._thumb_multiplier = value
 620 
 621     @property
 622     def thumb_radius(self):
 623         if self._thumb_radius is None:
 624             radius = self.radius
 625             self._thumb_radius = radius * self.thumb_multiplier
 626 
 627         return self._thumb_radius
 628 
 629     @property
 630     def thumb_orbit(self):
 631         if self._thumb_orbit is None:
 632             radius = self.radius
 633             center_radius = self.center_radius
 634             self._thumb_orbit = int(round((radius - center_radius) / 2.0)) + center_radius
 635 
 636         return self._thumb_orbit
 637 
 638     @property
 639     def neon_colour(self):
 640         colour = self._neon_colour
 641         colours = self.tick_range_colors
 642         if colours:
 643             colour = self.tick_range_colors[0]
 644         for i, r_value in enumerate(self.tick_ranges):
 645             if r_value <= self.value:
 646                 try:
 647                     colour = colours[i + 1]
 648                 except IndexError:
 649                     break
 650         colour = wx.Colour(colour).Get(False)
 651         self._neon_colour = colour
 652         return colour
 653 
 654     @property
 655     def center_radius(self):
 656         thumb_radius = self.thumb_radius
 657 
 658         return self.radius - (thumb_radius * 2) - (self.radius * 0.1)
 659 
 660     @property
 661     def thumb_position(self):
 662         if self._thumb_position is None:
 663             width, height = self.size
 664 
 665             x_center = width // 2
 666             y_center = height // 2
 667 
 668             thumb_orbit = self.thumb_orbit
 669             thumb_degree = _remap(self.value, self.min_value, self.max_value, self.start_degree, self.end_degree)
 670             thumb_radian = math.radians(thumb_degree)
 671 
 672             cos = math.cos(thumb_radian)
 673             sin = math.sin(thumb_radian)
 674 
 675             thumb_x = x_center + int(round(thumb_orbit * cos))
 676             thumb_y = y_center + int(round(thumb_orbit * sin))
 677 
 678             self._thumb_position = (thumb_x, thumb_y)
 679 
 680         return self._thumb_position
 681 
 682     @property
 683     def tick_pens(self):
 684         return self._tick_pens
 685 
 686     @property
 687     def tick_range_colors(self):
 688         return self._tick_range_colours
 689 
 690     @tick_range_colors.setter
 691     def tick_range_colors(self, value):
 692         del self._tick_pens[:]
 693 
 694         for colour in value:
 695             self._tick_pens += [wx.Pen(colour, 1)]
 696 
 697         self._tick_list = None
 698         self._tick_range_colours = value
 699 
 700     @property
 701     def tick_ranges(self):
 702         return self._tick_ranges
 703 
 704     @tick_ranges.setter
 705     def tick_ranges(self, value):
 706         self._tick_list = None
 707         self._tick_ranges = value
 708 
 709     @property
 710     def tick_range_percentage(self):
 711         return self._tick_range_percentage
 712 
 713     @tick_range_percentage.setter
 714     def tick_range_percentage(self, value):
 715         self._tick_range_percentage = value
 716 
 717     @property
 718     def increment(self):
 719         return self._increment
 720 
 721     @increment.setter
 722     def increment(self, value):
 723         self._increment = value
 724 
 725     @property
 726     def tick_frequency(self):
 727         return self._tick_frequency
 728 
 729     @tick_frequency.setter
 730     def tick_frequency(self, value):
 731         self._tick_frequency = value
 732 
 733     @property
 734     def page_size(self):
 735         if self._page_size is None:
 736             return (self.max_value - self.min_value) / 10.0
 737         return self._page_size
 738 
 739     @page_size.setter
 740     def page_size(self, value):
 741         self._page_size = value
 742         self._tick_list = None
 743 
 744     @property
 745     def ticks(self):
 746         return self._ticks
 747 
 748     @ticks.setter
 749     def ticks(self, value):
 750         self._ticks = value
 751 
 752     @property
 753     def always_tick_colours(self):
 754         return self._always_tick_colours
 755 
 756     @always_tick_colours.setter
 757     def always_tick_colours(self, value):
 758         self._always_tick_colours = value
 759 
 760     # testing for a quicker way to remap values to degrees
 761     def make_remapper(self, val_min, val_max, deg_min, deg_max):
 762         # Compute the scale factor between value range and degree range
 763         scaleFactor = float(deg_max - deg_min) / float(val_max - val_min)
 764 
 765         def remap_degree(value): #returns both value and degree equivalent
 766             return value, deg_min + (value - val_min) * scaleFactor
 767 
 768         return remap_degree
 769 
 770     @property
 771     def tick_list(self):
 772         if self._tick_list is None:
 773 
 774             width, height = self.size
 775             center = int(round(min(width, height) / 2.0))
 776 
 777             center_x = int(round(width / 2.0))
 778             center_y = int(round(height / 2.0))
 779 
 780             large_outside_radius = center - int(round(center * 0.05))
 781             inside_radius = int(round(large_outside_radius * 0.90))
 782             small_outside_radius = int(round(self.radius * 1.20))
 783             inside_values_radius = int(round(self.center_radius * 0.85))
 784             ticks = []
 785             _value_ticks = []        # note tick list cordinates for ShowScale/ShowMinMax outside the gauge
 786             _tick_values = []        # note value at each large tick as a string for display
 787             _inside_value_ticks = [] # note tick list cordinates for display inside the gauge
 788 
 789             _tick_ranges = self.tick_ranges
 790             _max_value = self.max_value
 791             _min_value = self.min_value
 792             _tick_frequency = self.tick_frequency
 793             _precision = self.precision
 794 
 795             gauge_range = list(frange(_min_value, _max_value, _tick_frequency, _precision))
 796 
 797             # if a set of predefined scale values exist weed out any illegal values
 798             if self._parent and self._parent.DefinedScaleValues:
 799                 predefined = True
 800                 _tick_values = [str(x) for x in self._parent.DefinedScaleValues \
 801                                     if not Decimal(str(x)) % Decimal(str(_tick_frequency)) and x in gauge_range]
 802             else:
 803                 predefined = False
 804 
 805             base_fontsize = int(height/10)
 806             if self._font:
 807                 font = self._font
 808                 font.SetPointSize(int(base_fontsize/4))
 809             else:
 810                 font = wx.Font(int(base_fontsize/4), wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.NORMAL)
 811             pix_x, pix_y = font.GetPixelSize()
 812 
 813             tick_pens = self.tick_pens
 814             always_tick_colours = self.always_tick_colours
 815 
 816             num_small_ticks = int(round((_max_value - _min_value) / _tick_frequency)) + 1
 817             num_large_ticks = int(round((_max_value - _min_value) / self.page_size)) + 1
 818             num_small_ticks -= num_large_ticks
 819 
 820             self._neon_colour = self.neon_colour
 821             large_tick_frequency = (num_large_ticks - 1) * self.increment
 822 
 823             #limit large ticks or it gets messy
 824             while (_max_value - _min_value) / large_tick_frequency > 30:
 825                 large_tick_frequency *= 2.0
 826             pen_size = max(1.0, (center * 0.015) - (num_small_ticks / 100.0))
 827             half = round(((_max_value - _min_value) / 2) + _min_value, _precision)
 828             check_tick_frequency = _tick_frequency
 829             check_large_tick_frequency = Decimal(str(large_tick_frequency))
 830 
 831             scaler = self.make_remapper(_min_value, _max_value, self._start_degree, self._end_degree)
 832             remapped_degrees = map(scaler, gauge_range)
 833 
 834             for i, degree in remapped_degrees:
 835                 if always_tick_colours or i <= self._value:
 836                     # coloured tick
 837                     for pen_num, tick_range in enumerate(_tick_ranges):
 838                         if i <= tick_range:
 839                             if pen_num < len(tick_pens):
 840                                 pen = tick_pens[pen_num]
 841                             else:
 842                                 pen = self._default_tick_pen
 843                             break
 844                     else:
 845                         pen = self._default_tick_pen
 846                 else:
 847                     pen = self._default_tick_pen
 848 
 849                 pen.SetWidth(int(pen_size))
 850 
 851                 # Overrides - Pos 0 = Red, Pos halfway = Blue and always_tick_colours = Default colour for visibility
 852                 if i == 0:
 853                     pen = wx.Pen((255, 0, 0), int(pen_size + 1))
 854                 if i == half:
 855                     pen = wx.Pen((0, 0, 255), int(pen_size + 1))
 856                 if always_tick_colours and i == self._value:
 857                     pen = self._default_tick_pen
 858                     pen.SetWidth(int(pen_size + 2))
 859 
 860                 radian = math.radians(degree)
 861                 cos = math.cos(radian)
 862                 sin = math.sin(radian)
 863 
 864                 x2 = center_x + int(round(inside_radius * cos))
 865                 y2 = center_y + int(round(inside_radius * sin))
 866 
 867                 if not i.is_integer():          # remove superfluous characters for display purposes
 868                     str_i = str(i)
 869                 else:
 870                     str_i = str(int(i))
 871 
 872                 if predefined: # if a set of predefined scale values exist use them for the tick entries
 873                     if str(i) in _tick_values: # Large tick with text
 874                         x1 = tvx1 = center_x + int(round(large_outside_radius * cos))
 875                         y1 = tvy1 = center_y + int(round(large_outside_radius * sin))
 876 
 877                         tvx2 = center_x + int(round(inside_values_radius * cos))
 878                         tvy2 = center_y + int(round(inside_values_radius * sin)) - int(pix_y / 2)
 879                         tvx2 = tvx2 - int((len(str(i)) / 3.0) * pix_x)
 880 
 881                         if x1 < (width/2):          # adjust text position if on left hand side
 882                             tvx1 = x1 - int(((len(str(i)) * 0.75) * pix_x))
 883                         elif x1 == (width/2):
 884                             tvx1 = x1 - int(((len(str(i)) * 0.25) * pix_x))
 885                         else:
 886                             tvx1 += pix_x
 887 
 888                         tvy1 = y1 - int(pix_y / 2)
 889 
 890                         _value_ticks += [[tvx1, tvy1]]
 891                         _inside_value_ticks += [[tvx2, tvy2]]
 892                     else: # Small tick
 893                         x1 = center_x + int(round(small_outside_radius * cos))
 894                         y1 = center_y + int(round(small_outside_radius * sin))
 895 
 896                 else:        # No predefined scale values, calculate the tick values
 897                     if Decimal(str(i)) % check_large_tick_frequency: # Small tick
 898                         x1 = center_x + int(round(small_outside_radius * cos))
 899                         y1 = center_y + int(round(small_outside_radius * sin))
 900                     else:                                            # Large tick mark with text
 901                         x1 = tvx1 = center_x + int(round(large_outside_radius * cos))
 902                         y1 = tvy1 = center_y + int(round(large_outside_radius * sin))
 903 
 904                         tvx2 = center_x + int(round(inside_values_radius * cos))
 905                         tvy2 = center_y + int(round(inside_values_radius * sin)) - int(pix_y / 2)
 906                         tvx2 = tvx2 - int((len(str(i)) / 3.0) * pix_x)
 907 
 908                         if x1 < (width/2):          # adjust text position if on left hand side
 909                             tvx1 = x1 - int(((len(str(i)) * 0.75) * pix_x))
 910                         elif x1 == (width/2):
 911                             tvx1 = x1 - int(((len(str(i)) * 0.25) * pix_x))
 912                         else:
 913                             tvx1 += pix_x
 914                         tvy1 = y1 - int(pix_y / 2)
 915 
 916                         _value_ticks += [[tvx1, tvy1]]
 917                         _tick_values += [str_i]
 918                         _inside_value_ticks += [[tvx2, tvy2]]
 919 
 920                 ticks += [[i, pen, [x1, y1, x2, y2]]]
 921 
 922             self._tick_list = ticks
 923 
 924             count = len(_value_ticks)
 925             last_tick = _tick_values[-1]
 926             last_value = _value_ticks[-1]
 927             last_inside_value = _inside_value_ticks[-1]
 928 
 929             if not self._parent or not self._parent.InsideScale:
 930                 if count > 31: # reduce excessive text which will present as a jumble of numbers - outside up to 30 values
 931                     step, count = count, 0
 932                     while step > 30:
 933                         step = math.ceil(step/30)
 934                         count += step
 935                     step = count
 936                     _value_ticks = _value_ticks[::step]
 937                     _tick_values = _tick_values[::step]
 938                     _inside_value_ticks = _inside_value_ticks[::step]
 939             elif self._parent and self._parent.InsideScale:
 940                 if count > 13: # inside up to 12 values
 941                     step, count = count, 0
 942                     while step > 12:
 943                         step = math.ceil(step/12)
 944                         count += step
 945                     step = count
 946                     _value_ticks = _value_ticks[::step]
 947                     _tick_values = _tick_values[::step]
 948                     _inside_value_ticks = _inside_value_ticks[::step]
 949 
 950             _value_ticks[-1] = last_value   #ensure last entry is the maximum
 951             _tick_values[-1] = last_tick
 952             _inside_value_ticks[-1] = last_inside_value
 953 
 954             self.value_ticks = _value_ticks
 955             self.tick_values = _tick_values
 956             self.inside_value_ticks = _inside_value_ticks
 957 
 958         return self._tick_list
 959 
 960     def _get_tick_number(self, value):
 961         value_range = self.max_value + self.increment - self.min_value
 962         num_ticks = value_range * self.tick_frequency
 963 
 964         tick_num = _remap(value, self.min_value, self._max_value, 0, num_ticks)
 965         return int(tick_num)
 966 
 967     def is_value_line_up(self, value):
 968         if value < self.value:
 969             return False
 970 
 971         ticks = self.tick_list
 972 
 973         for i, (v, pen, coords) in enumerate(ticks):
 974             if v == value:
 975                 break
 976         else:
 977             return False
 978 
 979         if i == len(ticks) - 1:
 980             return False
 981         if ticks[i + 1][2] != coords:
 982             return True
 983 
 984         return False
 985 
 986     def is_value_line_down(self, value):
 987         if value > self.value:
 988             return False
 989         ticks = self.tick_list
 990 
 991         for i, (v, pen, coords) in enumerate(ticks):
 992             if v == value:
 993                 break
 994         else:
 995             return False
 996 
 997         if i == 0:
 998             return False
 999         if ticks[i - 1][2] != coords:
1000             return True
1001 
1002         return False
1003 
1004     def is_page(self, value):
1005         if value % self.page_size:
1006             return False
1007         return True
1008 
1009 
1010 class KnobEvent(wx.PyCommandEvent):
1011     """
1012     Wrapper around wx.ScrollEvent to allow the GetPosition and SetPosition
1013     to accept floats
1014     """
1015 
1016     def __init__(self, event_type, id=1):
1017         wx.PyCommandEvent.__init__(self, event_type, id)
1018 
1019         self.__orientation = None
1020         self.__position = 0
1021 
1022     def SetPosition(self, value):
1023         self.__position = value
1024 
1025     def GetPosition(self):
1026         return self.__position
1027 
1028     def SetOrientation(self, value):
1029         self.__orientation = value
1030 
1031     def GetOrientation(self):
1032         return self.__orientation
1033 
1034     def GetEventUserData(self):
1035         return None
1036 
1037     Position = property(fget=GetPosition, fset=SetPosition)
1038     Orientation = property(fget=GetOrientation, fset=SetOrientation)
1039 
1040 
1041 KNOB_GLOW = 1 << 0          # 1
1042 KNOB_DEPRESSION = 1 << 1    # 2
1043 KNOB_HANDLE_GLOW = 1 << 2   # 4
1044 KNOB_TICKS = 1 << 3         # 8
1045 KNOB_SHADOW = 1 << 4        # 16
1046 KNOB_RIM = 1 << 5           # 32
1047 
1048 DefaultKnobStyle = KNOB_GLOW | KNOB_DEPRESSION | KNOB_HANDLE_GLOW | KNOB_TICKS | KNOB_SHADOW
1049 KnobNameStr = 'Knob Control'
1050 # noinspection PyPep8Naming
1051 class KnobCtrl(wx.Control):
1052 
1053     # noinspection PyShadowingBuiltins
1054     def __init__(
1055         self,
1056         parent,
1057         id=wx.ID_ANY,
1058         value=0.0,
1059         minValue=0.0,
1060         maxValue=100.0,
1061         increment=1.0,
1062         pos=wx.DefaultPosition,
1063         size=wx.DefaultSize,
1064         style=0,
1065         name=KnobNameStr,
1066         knobStyle=DefaultKnobStyle
1067     ):
1068 
1069         wx.Control.__init__(
1070             self,
1071             parent,
1072             id=id,
1073             pos=pos,
1074             size=size,
1075             style=style | wx.BORDER_NONE,
1076             name=name
1077         )
1078 
1079         self.SetBackgroundColour(parent.GetBackgroundColour())
1080 
1081         self.increment = increment
1082         self.ShowToolTip = False
1083         self.ShowValue = False
1084         self.ShowMinMax = False
1085         self.ShowScale = False
1086         self.InsideScale = False
1087         self.DefinedScaleValues = []
1088         self.ShowPointer = False
1089         self.PointerColour = None
1090         self.GaugeText = ''
1091         self.GaugeImage = None
1092         self.GaugeImagePos = 0
1093         self.ShowOdometer = False
1094         self.OdometerUpdate = 0
1095         self.distance_matrix = []
1096         self.OdometerPeriod = "H"
1097         self.OdometerColour = '#30303080'
1098         self.OdometerBackgroundColour = None
1099         self.UseHotSpots = False
1100         self.HotSpot = None
1101         self.OdometerHotSpot = None
1102         self.OdometerToolTip = ''
1103         self.Caption = ''
1104         self.CaptionPos = 0
1105         self.negative = False
1106         self.DisableMinMaxMouseDrag = False
1107 
1108         self.Bind(wx.EVT_PAINT, self.OnPaint)
1109         self.Bind(wx.EVT_SIZE, self._on_size)
1110         self.Bind(wx.EVT_ERASE_BACKGROUND, self._on_erase_background)
1111         self.Bind(wx.EVT_MOUSE_CAPTURE_LOST, self._on_mouse_lost_capture)
1112         self.Bind(wx.EVT_LEFT_DOWN, self._on_mouse_left_down)
1113         self.Bind(wx.EVT_RIGHT_DOWN, self._on_mouse_right_down)
1114         self.Bind(wx.EVT_LEFT_DCLICK, self._on_mouse_right_down)
1115         self.Bind(wx.EVT_LEFT_UP, self._on_mouse_left_up)
1116         self.Bind(wx.EVT_MOUSEWHEEL, self._on_mouse_wheel)
1117         self.Bind(wx.EVT_CHAR_HOOK, self._on_char_hook)
1118         self._handler = Handler(self)
1119         _prec = str(increment).split('.')
1120         if len(_prec) > 1:
1121             self.precision = len(_prec[-1])
1122         else:
1123             self.precision = 0
1124         self._handler.precision = self.precision
1125         if minValue >= maxValue:
1126             raise ValueError(f'Min value {minValue} is higher then the Max value {maxValue}')
1127         self._handler.min_value = minValue
1128         self._handler.max_value = maxValue
1129         self._handler.increment = increment
1130         self._handler.value = value
1131         self._handler.type = value
1132         self._handler.background_colour = parent.GetBackgroundColour()
1133         self._handler.foreground_colour = parent.GetForegroundColour()
1134         self._last_degrees = None
1135 
1136         # 50% in degrees of default end_degrees - start_degrees - used for restricting mouse movement See: DisableMinMaxMouseDrag
1137         self.mouse_max_move = abs(self._handler.end_degree - self._handler.start_degree) * 0.5
1138 
1139         self._handler.size = self.GetBestSize()
1140 
1141         self._handler.glow = bool(knobStyle & KNOB_GLOW)
1142         self._handler.depression = bool(knobStyle & KNOB_DEPRESSION)
1143         self._handler.thumb_glow = bool(knobStyle & KNOB_HANDLE_GLOW)
1144         self._handler.ticks = bool(knobStyle & KNOB_TICKS)
1145         self._handler.shadow = bool(knobStyle & KNOB_SHADOW)
1146         self._handler.highlight_rim = bool(knobStyle & KNOB_RIM)
1147         self._knob_style = knobStyle
1148 
1149         self.current_time = time.time()
1150         self.start_time = self.current_time
1151         self.current_speed = self._handler.value
1152         wx.ToolTip.Enable(True)
1153         wx.ToolTip.SetDelay(10)
1154 
1155 
1156     def HasGlow(self):
1157         return self._handler.glow
1158 
1159     def HasDepression(self):
1160         return self._handler.depression
1161 
1162     def HasHandleGlow(self):
1163         return self._handler.thumb_glow
1164 
1165     def HasTicks(self):
1166         return self._handler.ticks
1167 
1168     def HasShadow(self):
1169         return self._handler.shadow
1170 
1171     def GetKnobStyle(self):
1172         return self._knob_style
1173 
1174     def SetKnobStyle(self, knobStyle):
1175         self._handler.glow = bool(knobStyle & KNOB_GLOW)
1176         self._handler.depression = bool(knobStyle & KNOB_DEPRESSION)
1177         self._handler.thumb_glow = bool(knobStyle & KNOB_HANDLE_GLOW)
1178         self._handler.ticks = bool(knobStyle & KNOB_TICKS)
1179         self._handler.shadow = bool(knobStyle & KNOB_SHADOW)
1180         self._handler.highlight_rim = bool(knobStyle & KNOB_RIM)
1181         self._knob_style = knobStyle
1182 
1183         def _do():
1184             self.Refresh()
1185             self.Update()
1186 
1187         wx.CallAfter(_do)
1188 
1189     def GetPageSize(self):
1190         return self._handler.page_size
1191 
1192     def SetPageSize(self, value):
1193         if Decimal(str(self._handler.max_value - self._handler.min_value)) % Decimal(str(value)):
1194             raise RuntimeError(
1195                 f'Page size needs to be a multiple of the value range {self._handler.max_value - self._handler.min_value}'
1196             )
1197         self._handler.page_size = value
1198 
1199         def _do():
1200             self.Refresh()
1201             self.Update()
1202 
1203         wx.CallAfter(_do)
1204 
1205     def GetValueRange(self):
1206         return self._handler.min_value, self._handler.max_value
1207 
1208     def SetValueRange(self, minValue, maxValue):
1209         self._handler.min_value = minValue
1210         self._handler.max_value = maxValue
1211 
1212         def _do():
1213             self.Refresh()
1214             self.Update()
1215 
1216         wx.CallAfter(_do)
1217 
1218     def _create_event(self, event, value):
1219         """
1220         Internal use, creates a new KnobEvent
1221         :param event: wx event.
1222         :return: None
1223         """
1224         event = KnobEvent(event, self.GetId())
1225         event.SetId(self.GetId())
1226         event.SetEventObject(self)
1227         event.SetPosition(value)
1228         event.SetOrientation(wx.HORIZONTAL)
1229         self.GetEventHandler().ProcessEvent(event)
1230 
1231     def _on_char_hook(self, evt):
1232 
1233         key_code = evt.GetKeyCode()
1234         if key_code in (wx.WXK_PAGEUP, wx.WXK_NUMPAD_PAGEUP): #jump 10% (or manually set page size)
1235             value = self._handler.value + self._handler.page_size
1236             #value -= value % self._handler.page_size
1237             event = wx.wxEVT_SCROLL_PAGEUP
1238 
1239         elif key_code in (wx.WXK_PAGEDOWN, wx.WXK_NUMPAD_PAGEDOWN): #jump 10%
1240             value = self._handler.value - self._handler.page_size
1241 
1242             #if value == self._handler.value:
1243             #   value -= self._handler.page_size
1244             event = wx.wxEVT_SCROLL_PAGEDOWN
1245 
1246         elif key_code in (
1247             wx.WXK_UP,
1248             wx.WXK_ADD,
1249             wx.WXK_NUMPAD_UP,
1250             wx.WXK_NUMPAD_ADD
1251         ):
1252             event = wx.wxEVT_SCROLL_LINEUP
1253             value = self._handler.value + self._handler.increment
1254 
1255         elif key_code in (
1256             wx.WXK_DOWN,
1257             wx.WXK_SUBTRACT,
1258             wx.WXK_NUMPAD_DOWN,
1259             wx.WXK_NUMPAD_SUBTRACT
1260         ):
1261             event = wx.wxEVT_SCROLL_LINEDOWN
1262             value = self._handler.value - self._handler.increment
1263 
1264         elif key_code in (wx.WXK_HOME, wx.WXK_NUMPAD_HOME):
1265             value = self._handler.min_value
1266             event = None
1267 
1268         elif key_code in (wx.WXK_END, wx.WXK_NUMPAD_END):
1269             value = self._handler.max_value
1270             event = None
1271 
1272         # numbers that represent 10% (or manually set page size) incremnts of the value range
1273         elif key_code - 48 in list(range(0, 10)):
1274             mult  = key_code - 48
1275             value = (mult * self._handler.page_size) + self._handler.min_value
1276             event = None
1277         elif key_code in digits_numeric_pad:
1278             idx = [index for (index , item) in enumerate(digits_numeric_pad) if item == key_code]
1279             if idx:
1280                 mult = idx[0]
1281                 value = (mult * self._handler.page_size) + self._handler.min_value
1282                 event = None
1283         else:
1284             evt.Skip()
1285             return
1286 
1287         if self.precision:
1288             round2 = self.precision
1289         else:
1290             round2 = None
1291         value = round(value, round2)
1292 
1293         self._last_degrees = None
1294         self.__generate_events(event, value)
1295 
1296         evt.Skip()
1297 
1298     def _on_erase_background(self, _):
1299         pass
1300 
1301     def _on_mouse_lost_capture(self, evt):
1302         if self.HasCapture():
1303             pass
1304         else:
1305             #evt.Skip()
1306             return
1307         self._last_degrees = None
1308         self.ReleaseMouse()
1309         self.Unbind(wx.EVT_MOTION, handler=self._on_mouse_move)
1310         if self.UseHotSpots:
1311             self.Bind(wx.EVT_MOTION, handler=self._on_motion)
1312         self._create_event(wx.wxEVT_SCROLL_THUMBRELEASE, round(self.GetValue(), self.precision))
1313         self.Refresh()
1314         #self.Update()
1315         #evt.Skip()
1316 
1317     def __generate_events(self, event, value, degrees=None):
1318         if value >= self._handler.max_value:
1319             value = self._handler.max_value
1320 
1321         elif value <= self._handler.min_value:
1322             value = self._handler.min_value
1323 
1324         if value != self._handler.value:
1325             self._last_degrees = degrees
1326             handler_value = self._handler.value
1327             self._handler.value = value
1328 
1329             def _do():
1330                 self.Refresh()
1331             #    self.Update()
1332 
1333             wx.CallAfter(_do)
1334 
1335             if event is not None:
1336                 self._create_event(event, value)
1337                 return
1338             if value == self._handler.max_value:
1339                 self._create_event(wx.wxEVT_SCROLL_TOP, value)
1340                 return
1341             elif value == self._handler.min_value:
1342                 self._create_event(wx.wxEVT_SCROLL_BOTTOM, value)
1343                 return
1344             if self._handler.is_page(value):
1345                 if value > handler_value:
1346                     self._create_event(wx.wxEVT_SCROLL_PAGEUP, value)
1347                 else:
1348                     self._create_event(wx.wxEVT_SCROLL_PAGEDOWN, value)
1349                 return
1350 
1351             self._create_event(wx.wxEVT_SCROLL_CHANGED, value)
1352             return True
1353 
1354         return False
1355 
1356     def _on_mouse_wheel(self, evt):
1357         wheel_delta = evt.GetWheelRotation()
1358         value = self._handler.value
1359 
1360         if wheel_delta < 0:
1361             value -= self._handler.increment
1362             event = wx.wxEVT_SCROLL_LINEDOWN
1363 
1364         elif wheel_delta > 0:
1365             value += self._handler.increment
1366             event = wx.wxEVT_SCROLL_LINEUP
1367 
1368         else:
1369             evt.Skip()
1370             return
1371 
1372         if self.precision:
1373             round2 = self.precision
1374         else:
1375             round2 = None
1376         value = round(value, round2)
1377         self.__generate_events(event, value)
1378 
1379         evt.Skip()
1380 
1381     # define if mouse click is in the invalid range of the circle and if so is it nearer to the lower valid range
1382     # or the upper valid range
1383     # permits a decision to jump to nearest valid range if outside of valid range.
1384     # returns True/False if in invalid range and True if the nearest valid range is lower, else False
1385     def angle_in_range(self, hitpos, lower, upper):
1386         Inzone = (hitpos - lower) % 360 <= (upper - lower) % 360
1387         if Inzone:
1388             midpoint = ((upper - lower) % 360) / 2
1389             pos = (hitpos - lower) % 360
1390             if pos < midpoint:
1391                 lower = True
1392             else:
1393                 lower = False
1394         else:
1395             lower = False
1396         return Inzone, lower
1397 
1398     def _on_mouse_left_up(self, evt):
1399         if self.HasCapture():
1400             self.Unbind(wx.EVT_MOTION, handler=self._on_mouse_move)
1401             if self.UseHotSpots:
1402                 self.Bind(wx.EVT_MOTION, handler=self._on_motion)
1403             self.ReleaseMouse()
1404             self._create_event(wx.wxEVT_SCROLL_THUMBRELEASE, round(self.GetValue(), self.precision))
1405             self.Refresh()
1406             self.Update()
1407 
1408         evt.Skip()
1409 
1410     def _on_mouse_left_down(self, evt):
1411         thumb_x, thumb_y = self._handler.thumb_position
1412         thumb_radius = self._handler.thumb_radius
1413 
1414         # Is the click near the thumb
1415         region = wx.Region(int(thumb_x - (thumb_radius * 2)), int(thumb_y - (thumb_radius * 2)),
1416                            int(thumb_radius * 4), int(thumb_radius * 4))
1417 
1418         pos = evt.GetPosition()
1419         if region.Contains(pos):
1420             self.CaptureMouse()
1421             self.Unbind(wx.EVT_MOTION, handler=self._on_motion)
1422             self.Bind(wx.EVT_MOTION, self._on_mouse_move)
1423 
1424         evt.Skip()
1425 
1426     def _on_mouse_right_down(self, evt):
1427 
1428         width, height = self.GetSize()
1429         center_x = width / 2.0
1430         center_y = height / 2.0
1431         x, y = evt.GetPosition()
1432         radians = math.atan2(y - center_y, x - center_x)
1433         cpd = radians * (180 / math.pi)
1434         if cpd < 0:
1435             cpd += 360
1436         degrees = cpd                               # degrees between 0 and 360 counting from 3 o,clock clockwise
1437 
1438         if not self.negative:
1439             up = int(self._handler.mid_point - self._handler.mid_point_adj)
1440             down = int(self._handler.mid_point + self._handler.mid_point_adj)
1441 
1442         else:
1443             up = int(self._handler.negative_mid_point - self._handler.mid_point_adj)
1444             down = int(self._handler.negative_mid_point + self._handler.mid_point_adj)
1445 
1446         outofrange, jumplower = self.angle_in_range(degrees, up, down)
1447 
1448         if outofrange and jumplower:
1449             if self._handler.end_degree > self._handler.start_degree:            # Is the gauge defined to travel positive
1450                 degrees = self._handler.end_degree
1451             else:                                       # or negative
1452                 degrees = self._handler.start_degree
1453         if outofrange and not jumplower:
1454             if self._handler.end_degree > self._handler.start_degree:            # Is the gauge defined to travel positive
1455                 degrees = self._handler.start_degree
1456             else:                                       # or negative
1457                 degrees = self._handler.end_degree
1458 
1459         if not self.negative:
1460             if self._handler.end_degree > 360 and degrees < self._handler.start_degree:
1461                 degrees += 360
1462             if self._handler.start_degree > 360 and degrees < self._handler.end_degree: # gauge defined for negative travel
1463                 degrees += 360
1464         else:
1465             degrees = degrees % -360
1466             if self._handler.end_degree < -360 and degrees > self._handler.start_degree:
1467                 degrees -= 360
1468             if self._handler.start_degree < -360 and degrees > self._handler.end_degree: # gauge defined for negative travel
1469                 degrees -= 360
1470 
1471         value = float(_remap(degrees, self._handler.start_degree, self._handler.end_degree, self._handler.min_value, self._handler.max_value))
1472         value = math.ceil(value / self.increment) * self.increment
1473         value = round(value, self.precision)
1474         self.__generate_events(wx.wxEVT_SCROLL_THUMBRELEASE, value)
1475         self.Refresh()
1476         self.Update()
1477 
1478         #evt.Skip()
1479 
1480     def _on_mouse_move(self, evt):
1481         if self.HasCapture():
1482             pass
1483         else:
1484             evt.Skip()
1485             return
1486 
1487         x, y = evt.GetPosition()
1488         width, height = self.GetSize()
1489         center_x = width / 2.0
1490         center_y = height / 2.0
1491         radians = math.atan2(y - center_y, x - center_x)
1492         degrees = math.degrees(radians)
1493 
1494         disabled_minmax_jump = self._last_degrees   # used to determine if a jump from min to max or max to min is allowed
1495 
1496         cpd = radians * (180 / math.pi)
1497         if cpd < 0:
1498             cpd += 360
1499         degrees = cpd                               # degrees between 0 and 360 counting from 3 o,clock clockwise
1500 
1501         if not self.negative:
1502             up = int(self._handler.mid_point - self._handler.mid_point_adj)
1503             down = int(self._handler.mid_point + self._handler.mid_point_adj)
1504         else:
1505             up = int(self._handler.negative_mid_point - self._handler.mid_point_adj)
1506             down = int(self._handler.negative_mid_point + self._handler.mid_point_adj)
1507 
1508 
1509         outofrange, jumplower = self.angle_in_range(degrees, up, down)
1510 
1511         if outofrange and jumplower:
1512             if self._handler.end_degree > self._handler.start_degree:               # Is the gauge defined to travel positive
1513                 degrees = self._handler.end_degree
1514             else:                                                                   # or negative
1515                 degrees = self._handler.start_degree
1516         if outofrange and not jumplower:
1517             if self._handler.end_degree > self._handler.start_degree:               # Is the gauge defined to travel positive
1518                 degrees = self._handler.start_degree
1519             else:                                                                   # or negative
1520                 degrees = self._handler.end_degree
1521 
1522         if not self.negative:
1523             if self._handler.end_degree > 360 and degrees < self._handler.start_degree:
1524                 degrees += 360
1525             if self._handler.start_degree > 360 and degrees < self._handler.end_degree: # gauge defined for negative travel
1526                 degrees += 360
1527         else:
1528             degrees = degrees % -360
1529             if self._handler.end_degree < -360 and degrees > self._handler.start_degree:
1530                 degrees -= 360
1531             if self._handler.start_degree < -360 and degrees > self._handler.end_degree: # gauge defined for negative travel
1532                 degrees -= 360
1533 
1534         value = _remap(degrees, self._handler.start_degree, self._handler.end_degree, self._handler.min_value, self._handler.max_value)
1535         if (value % self._handler.increment) * 2 >= self._handler.increment:
1536             if self._last_degrees < degrees:
1537                 #value -= (value % self._handler.increment)
1538                 #value = round(value, self.precision)
1539                 value = math.ceil(value / self.increment) * self.increment
1540                 event = wx.wxEVT_SCROLL_LINEUP
1541             elif self._last_degrees > degrees:
1542                 #value -= (value % self._handler.increment)
1543                 #value = round(value, self.precision)
1544                 value = math.ceil(value / self.increment) * self.increment
1545                 event = wx.wxEVT_SCROLL_LINEDOWN
1546             else:
1547                 self._last_degrees = degrees
1548                 evt.Skip()
1549                 return
1550         else:
1551             self._last_degrees = degrees
1552             evt.Skip()
1553             event = None
1554            # return
1555 
1556         if self.DisableMinMaxMouseDrag:
1557             if abs(degrees - disabled_minmax_jump) > self.mouse_max_move:
1558                 self._on_mouse_lost_capture(None)
1559                 return
1560 
1561         #if self.DisableMinMaxMouseDrag:     # Prevent mouse move accidently flipping instantly from Min to Max or Max to Min
1562         #    if disabled_minmax_jump == self._handler.start_degree and degrees == self._handler.end_degree:
1563         #        value = self._handler.min_value
1564         #        self._on_mouse_lost_capture(None)
1565         #    if disabled_minmax_jump == self._handler.end_degree and degrees == self._handler.start_degree:
1566         #        value = self._handler.max_value
1567         #        self._on_mouse_lost_capture(None)
1568 
1569         value = round(value, self.precision)
1570 
1571         if self.UseHotSpots:
1572             self.SetToolTip(str(value))
1573 
1574         if self.__generate_events(event, value, degrees):
1575             self._create_event(wx.wxEVT_SCROLL_THUMBTRACK, value)
1576 
1577         evt.Skip()
1578 
1579     def _on_size(self, evt):
1580         width, height = evt.GetSize()
1581         self._handler.size = (width, height)
1582 
1583         def do():
1584             self.Refresh()
1585             self.Update()
1586 
1587         wx.CallAfter(do)
1588         evt.Skip()
1589 
1590     def _on_motion(self, event):
1591         if self.UseHotSpots:
1592             if self.HotSpot:
1593                 ClientPos = event.GetPosition()
1594                 if self.HotSpot.Contains(ClientPos):
1595                     if self.precision:
1596                         show_val = f'{self._handler.value:3.{self.precision}f}'
1597                     else:
1598                         show_val = f'{int(self._handler.value):3}'
1599                     self.SetToolTip(show_val)
1600                 elif self.OdometerHotSpot and self.OdometerHotSpot.Contains(ClientPos):
1601                     t = f'{time.time() - self.start_time:3.2f}'
1602                     if self.OdometerToolTip:
1603                         tip = self.OdometerToolTip  + "\nRunning Time in Secs: " + t
1604                     else:
1605                         tip = "Running Time: " + t
1606                     self.SetToolTip(tip)
1607                 else:
1608                     self.SetToolTip('')
1609         event.Skip()
1610 
1611 
1612     def GetPrimaryColour(self):
1613         return self._handler.primary_colour
1614 
1615     def SetPrimaryColour(self, value):
1616 
1617         if isinstance(value, (list, tuple)):
1618             value = wx.Colour(*value)
1619 
1620         self._handler.primary_colour = value
1621 
1622         def do():
1623             self.Refresh()
1624             self.Update()
1625 
1626         wx.CallAfter(do)
1627 
1628     def GetSecondaryColour(self):
1629         return self._handler.secondary_colour
1630 
1631     def SetSecondaryColour(self, value):
1632 
1633         if isinstance(value, (list, tuple)):
1634             value = wx.Colour(*value)
1635 
1636         self._handler.secondary_colour = value
1637 
1638         def do():
1639             self.Refresh()
1640             self.Update()
1641 
1642         wx.CallAfter(do)
1643 
1644     def GetTickFrequency(self):
1645         return self._handler.tick_frequency
1646 
1647     def SetTickFrequency(self, value):
1648         value_range = self._handler.max_value - self._handler.min_value
1649         ticks = value_range / value
1650         if ticks > 600:
1651             raise RuntimeError(f'This tick frequency would produce excessive ticks: {ticks} - The limit is 600')
1652 
1653         inc = self._handler.increment
1654 
1655         # Issue with modulo e.g. 4 % 0.1 does NOT return zero so we use Decimal
1656         if Decimal(str(value_range)) % Decimal(str(value)):
1657             raise RuntimeError(f'The Value Range: {value_range} is Not divisible by the Tick Frequency: {value}')
1658 
1659         if value < inc:
1660             raise RuntimeError(f'The tick frequency: {value} is less than the increment: {inc}')
1661 
1662         self._handler.tick_frequency = value
1663 
1664         def do():
1665             self.Refresh()
1666             self.Update()
1667 
1668         wx.CallAfter(do)
1669 
1670     def GetThumbSize(self):
1671         return int(self._handler.thumb_multiplier * 100)
1672 
1673     def SetThumbSize(self, value):
1674         value /= 100.0
1675         self._handler.thumb_multiplier = value
1676 
1677         def do():
1678             self.Refresh()
1679             self.Update()
1680 
1681         wx.CallAfter(do)
1682 
1683     def GetTickColours(self):
1684         return self._handler.tick_range_colors
1685 
1686     def SetTickColours(self, values):
1687 
1688         colours = []
1689 
1690         for colour in values:
1691             if isinstance(colour, (list, tuple)):
1692                 colour = wx.Colour(*colour)
1693 
1694             colours += [colour]
1695 
1696         self._handler.tick_range_colors = colours
1697 
1698         def do():
1699             self.Refresh()
1700             self.Update()
1701 
1702         wx.CallAfter(do)
1703 
1704     def GetTickColorRanges(self):
1705         return self._handler.tick_ranges
1706 
1707     def SetTickColourRanges(self, values):
1708         percs_to_values = []
1709         # calculate value as a percentage, allowing for negatives in the range of min/max values e.g. -10.0 -> +10.0
1710         if self._handler.tick_range_percentage:
1711             for r_value in values:
1712                 percs_to_values.append(round(((r_value * (self._handler.max_value - self._handler.min_value) / 100) + self._handler.min_value), self.precision))
1713         else:
1714             # Use value as given
1715             for r_value in values:
1716                 percs_to_values.append(r_value)
1717         self._handler.tick_ranges = percs_to_values
1718 
1719         def do():
1720             self.Refresh()
1721             self.Update()
1722 
1723         wx.CallAfter(do)
1724 
1725     def SetDefaultTickColour(self, value):
1726         self._handler._default_tick_pen.SetColour(value)
1727 
1728     def GetDefaultTickColour(self):
1729         return self._handler._default_tick_pen.GetColour()
1730 
1731     def SetTickRangePercentage(self, value):
1732         self._handler.tick_range_percentage = value
1733 
1734     def SetAlwaysTickColours(self, value=False):
1735         self._handler.always_tick_colours = value
1736 
1737     def SetSize(self, size):
1738         wx.Control.SetSize(self, size)
1739         width, height = self.GetSize()
1740         self._handler.size = (width, height)
1741 
1742     def GetValue(self):
1743         return self._handler.value
1744 
1745     def SetValue(self, value):
1746         self._last_degrees = None
1747 
1748         if self._handler.min_value > value:
1749             raise ValueError(f'new value {value} is lower then the set minimum {self._handler.min_value}')
1750             value = self._handler.min_value
1751         if self._handler.max_value < value:
1752             raise ValueError(f'new value {value} is higher then the set maximum {self._handler.max_value}')
1753             value = self._handler.max_value
1754         diff = round(value - self._handler.value, self.precision)
1755         self._handler.value = value
1756 
1757         if diff:
1758             event = wx.wxEVT_SCROLL_CHANGED
1759             self._create_event(event, value)
1760 
1761         def do():
1762             self.Refresh()
1763             self.Update()
1764 
1765         wx.CallAfter(do)
1766 
1767     def GetIncrement(self):
1768         return self._handler.increment
1769 
1770     def SetIncrement(self, increment):
1771         self._handler.increment = increment
1772 
1773         def do():
1774             self.Refresh()
1775             self.Update()
1776 
1777         wx.CallAfter(do)
1778 
1779     def GetMinValue(self):
1780         return self._handler.min_value
1781 
1782     def SetMinValue(self, value):
1783         self._handler.min_value = value
1784 
1785         def do():
1786             self.Refresh()
1787             self.Update()
1788 
1789         wx.CallAfter(do)
1790 
1791     def GetMaxValue(self):
1792         return self._handler.max_value
1793 
1794     def SetMaxValue(self, value):
1795         self._handler.max_value = value
1796 
1797         def do():
1798             self.Refresh()
1799             self.Update()
1800 
1801         wx.CallAfter(do)
1802 
1803     def GetOdometerUpdate(self):
1804         return self.OdometerUpdate
1805 
1806     def SetOdometerUpdate(self, value):
1807         self.OdometerUpdate = value
1808         if value > 0:
1809             self.ShowOdometer = True
1810             self.timer = wx.Timer(self)
1811             self.Bind(wx.EVT_TIMER, self.OnTimer, self.timer)
1812             self.timer.Start(value)
1813         else:
1814             self.ShowOdometer = False
1815             self.Unbind(wx.EVT_TIMER, self.timer)
1816             self.timer.Stop()
1817             self.Refresh()
1818             self.Update()
1819 
1820     def SetHotSpots(self):
1821         self.UseHotSpots = not self.UseHotSpots
1822         if self.UseHotSpots:
1823             self.Unbind(wx.EVT_MOTION, handler=self._on_mouse_move)
1824             self.Bind(wx.EVT_MOTION, handler=self._on_motion)
1825             self.ShowToolTip = False    # Turn off standard tooltip if On
1826         else:
1827             self.Unbind(wx.EVT_MOTION, handler=self._on_motion)
1828 
1829     def GetHotSpots(self):
1830         return self.UseHotSpots
1831 
1832     def GetStartEndDegrees(self):
1833         return self._handler.start_degree, self._handler.end_degree
1834 
1835     def SetStartEndDegrees(self, start = 135.0, end = 405.0):
1836         start = float(start)
1837         end = float(end)
1838         if (start < 0 and end > 0) or (start > 0 and end < 0):
1839             raise RuntimeError(f'Choose a direction - Positive or Negative - Not Both - {start} | {end}')
1840 
1841         self._handler.start_degree = start
1842         self._handler.end_degree = end
1843 
1844         self.mouse_max_move = abs(end - start) * 0.5
1845 
1846         if start >= 0:
1847             self.negative = False
1848             if abs(start - end) > 360.0:
1849                 raise RuntimeError(
1850                 'Distance between Start and End points, cannot exceed 360° - Currently: ' + str(abs(start - end))
1851                 )
1852 
1853             self._handler.mid_point = (start, end)
1854         else:
1855             self.negative = True
1856             if (start - end) < -360:
1857                 raise RuntimeError(
1858                 'Distance between Start and End points, cannot exceed 360° - Currently: ' + str(start - end)
1859                 )
1860             self._handler.negative_mid_point = (start, end)
1861 
1862         ticks = (self._handler.max_value - self._handler.min_value) / self._handler.tick_frequency
1863         excess = ticks / abs(end - start)
1864         if excess > 1.666: # max limit 600 ticks or 1.66 per degree
1865                 raise RuntimeError(
1866                 f'Excessive ticks in this range ( {excess} per Degree) - adjust TickFrequency or StartEndDegrees'
1867                 )
1868 
1869         def do():
1870             self.Refresh()
1871             self.Update()
1872 
1873         # Force a Repaint
1874         value = self.GetValue()
1875         #if value + self._handler.increment > self._handler.max_value:
1876         #    sval = value - self._handler.increment
1877         #else:
1878         #    sval = value + self._handler.increment
1879         #self.SetValue(sval)
1880         self.SetValue(value)
1881         wx.CallAfter(do)
1882         return
1883 
1884     def OnTimer(self, evt):
1885         #self.RefreshRect(self.OdometerHotSpot.GetBox())
1886         self.Refresh() # quickest x 1000!
1887         #self.Update()
1888 
1889     def GetAverageSpeed(self):
1890         t = time.time() - self.start_time
1891         d = sum(self.distance_matrix)
1892         if self.OdometerPeriod.upper() == "S":
1893             mult = 1
1894         elif self.OdometerPeriod.upper() == "M":
1895             mult = 60
1896         else:
1897             mult = 3600
1898         return (((d/t) * mult), t)
1899 
1900     def GetOdometerValue(self):
1901         return sum(self.distance_matrix)
1902 
1903     def GetOdometerHistory(self):
1904         return self.distance_matrix
1905 
1906     def OnPaint(self, _):
1907         width, height = self._handler.size
1908 
1909         if width <= 0 or height <= 0:
1910             bmp = wx.Bitmap.FromRGBA(
1911                 1,
1912                 1
1913             )
1914             pdc = wx.PaintDC(self)
1915             gcdc = wx.GCDC(pdc)
1916             gcdc.DrawBitmap(bmp, 0, 0)
1917 
1918             gcdc.Destroy()
1919             del gcdc
1920 
1921             return
1922 
1923         bmp = wx.Bitmap.FromRGBA(
1924             width,
1925             height
1926         )
1927 
1928         if self.ShowOdometer:
1929             # Distance travelled defaults to per hour
1930             update_time = time.time() - self.current_time
1931             if self.OdometerPeriod.upper() == "S":
1932                 d = update_time * self.current_speed
1933             elif self.OdometerPeriod.upper() == "M":
1934                 d = ((update_time * self.current_speed) / 60)
1935             else:
1936                 d = (((update_time * self.current_speed) / 60) / 60)
1937             if d:
1938                 self.distance_matrix.append(d)
1939             distance_travelled = sum(self.distance_matrix)
1940             self.current_time  = time.time()
1941             self.current_speed = self._handler.value
1942 
1943         dc = wx.MemoryDC()
1944         dc.SelectObject(bmp)
1945         gc = wx.GraphicsContext.Create(dc)
1946         gcdc = wx.GCDC(gc)
1947 
1948         gcdc.SetBrush(wx.Brush(self.GetBackgroundColour()))
1949         gcdc.SetPen(wx.TRANSPARENT_PEN)
1950 
1951         gcdc.DrawRectangle(0, 0, width, height)
1952 
1953         def draw_circle(x, y, r, _gcdc):
1954             _gcdc.DrawEllipse(
1955                 int(round(float(x) - r)),
1956                 int(round(float(y) - r)),
1957                 int(round(r * 2.0)),
1958                 int(round(r * 2.0))
1959             )
1960 
1961         def draw_circle_rim(x, y, r, _gcdc):
1962             x_center, y_center = self._handler.center
1963             thumb_x, thumb_y = self._handler.thumb_position
1964             if not self.negative:
1965                 start = abs(self._handler.start_degree % -360)
1966                 end = abs(self._handler.end_degree % -360)
1967             else:
1968                 start = self._handler.start_degree % 360
1969                 end = self._handler.end_degree % 360
1970             radians = math.atan2(thumb_y - y_center, thumb_x - x_center)
1971             if not self.negative:
1972                 degrees = abs(radians * (180 / math.pi) % -360)
1973             else:
1974                 degrees = abs(radians * (180 / math.pi) % 360)
1975                 start, degrees = -start, -degrees
1976             if self._handler.start_degree > self._handler.end_degree:
1977                 degrees = degrees % -360
1978                 start = start % 360
1979                 start, degrees = degrees, start
1980             _gcdc.SetBrush(wx.Brush(self._handler.neon_colour))
1981 
1982             # Only draw arc if it indicates a value of > minimum value - also test for a reversed start and end value
1983             #  without these tests, a minimum value will highlight the entire rim
1984             rim_reverse_start = round(degrees % -360)
1985             start = round(start)
1986             if round(degrees) != start and start != rim_reverse_start:
1987                 _gcdc.DrawEllipticArc(
1988                     int(round(float(x) - r)),
1989                     int(round(float(y) - r)),
1990                     int(round(r * 2.0)),
1991                     int(round(r * 2.0)),
1992                     degrees, start
1993                     )
1994 
1995         gcdc.SetBrush(wx.TRANSPARENT_BRUSH)
1996         x_center, y_center = self._handler.center
1997 
1998         gcdc.SetPen(wx.TRANSPARENT_PEN)
1999         radius = self._handler.radius
2000 
2001         if self._handler.shadow:
2002             # shadow
2003             stops = wx.GraphicsGradientStops()
2004             stops.Add(wx.GraphicsGradientStop(wx.TransparentColour, 0.45))
2005             stops.Add(wx.GraphicsGradientStop(wx.Colour(0, 0, 0, 255), 0.25))
2006 
2007             stops.SetStartColour(wx.Colour(0, 0, 0, 255))
2008             stops.SetEndColour(wx.TransparentColour)
2009 
2010             gc.SetBrush(
2011                 gc.CreateRadialGradientBrush(
2012                     x_center + (radius * 0.10),
2013                     y_center + (radius * 0.10),
2014                     x_center + (radius * 0.30),
2015                     y_center + (radius * 0.30),
2016                     radius * 2.3,
2017                     stops
2018                 )
2019             )
2020 
2021             draw_circle(x_center + (radius * 0.10), y_center + (radius * 0.10), radius * 2, gcdc)
2022 
2023             # eliminate any shadow under the knob just in case there is a color
2024             # used in the gradient of the knob that does not have an alpha level of 255
2025 
2026             gc.SetBrush(wx.Brush(self.GetBackgroundColour()))
2027             draw_circle(x_center, y_center, radius - 2, gcdc)
2028 
2029         if self._handler.glow or self._handler.highlight_rim: # glow 100% of rim or highlight to the rim value
2030             neon_colour = self._handler.neon_colour
2031 
2032             stops = wx.GraphicsGradientStops()
2033 
2034             stops.Add(wx.GraphicsGradientStop(wx.TransparentColour, 0.295)) # 0.265
2035             stops.Add(wx.GraphicsGradientStop(wx.Colour(*neon_colour + (200,)), 0.25))
2036             stops.Add(wx.GraphicsGradientStop(wx.TransparentColour, 0.248))
2037 
2038             stops.SetStartColour(wx.TransparentColour)
2039             stops.SetEndColour(wx.TransparentColour)
2040 
2041             gc.SetBrush(
2042                 gc.CreateRadialGradientBrush(
2043                     x_center,
2044                     y_center,
2045                     x_center,
2046                     y_center,
2047                     radius * 4,
2048                     stops
2049                 )
2050             )
2051 
2052             if self._handler.glow:
2053                 draw_circle(x_center, y_center, radius * 2, gcdc)
2054             if self._handler.highlight_rim:
2055                 draw_circle_rim(x_center, y_center, radius+(radius*0.05), gcdc)
2056 
2057         # outside ring of volume knob
2058 
2059         gc.SetBrush(
2060             gc.CreateRadialGradientBrush(
2061                 x_center - radius,
2062                 y_center - radius,
2063                 x_center,
2064                 y_center - radius,
2065                 radius * 2,
2066                 self._handler.secondary_colour,
2067                 self._handler.primary_colour
2068 
2069             )
2070         )
2071 
2072         draw_circle(x_center, y_center, radius, gcdc)
2073 
2074         thumb_x, thumb_y = self._handler.thumb_position
2075         thumb_radius = self._handler.thumb_radius
2076 
2077         # inside of volume knob
2078         if self._handler.depression:
2079             center_radius = self._handler.center_radius
2080             gc.SetBrush(
2081                 gc.CreateRadialGradientBrush(
2082                     x_center + center_radius,
2083                     y_center + center_radius,
2084                     x_center,
2085                     y_center + center_radius,
2086                     center_radius * 2,
2087                     self._handler.secondary_colour,
2088                     self._handler.primary_colour
2089                 )
2090             )
2091 
2092             draw_circle(x_center, y_center, center_radius, gcdc)
2093 
2094         if self._last_degrees is None:
2095             self._last_degrees = _remap(
2096                 self._handler.value,
2097                 self._handler.min_value,
2098                 self._handler.max_value,
2099                 self._handler.start_degree,
2100                 self._handler.end_degree
2101             )
2102 
2103         # handle of the volume knob
2104         gc.SetBrush(
2105             gc.CreateRadialGradientBrush(
2106                 thumb_x + thumb_radius,
2107                 thumb_y + thumb_radius,
2108                 thumb_x,
2109                 thumb_y + thumb_radius,
2110                 thumb_radius * 2,
2111                 self._handler.secondary_colour,
2112                 self._handler.primary_colour
2113             )
2114         )
2115 
2116         draw_circle(thumb_x, thumb_y, thumb_radius, gcdc)
2117 
2118         base_fontsize = int(height/10)
2119 
2120         if self._handler.thumb_glow:
2121             neon_colour = self._handler.neon_colour
2122 
2123             stops = wx.GraphicsGradientStops()
2124 
2125             stops.Add(wx.GraphicsGradientStop(wx.TransparentColour, 0.355))
2126             stops.Add(wx.GraphicsGradientStop(wx.Colour(*neon_colour + (255,)), 0.28))
2127             stops.Add(wx.GraphicsGradientStop(wx.TransparentColour, 0.258))
2128 
2129             stops.SetStartColour(wx.TransparentColour)
2130             stops.SetEndColour(wx.TransparentColour)
2131 
2132             gc.SetBrush(
2133                 gc.CreateRadialGradientBrush(
2134                     thumb_x,
2135                     thumb_y,
2136                     thumb_x,
2137                     thumb_y,
2138                     thumb_radius * 4,
2139                     stops
2140                 )
2141             )
2142 
2143             draw_circle(thumb_x, thumb_y, thumb_radius * 2, gcdc)
2144 
2145         # Note position of volume/speed knob for hotspot tooltip, this noted if even off, in case it's turned on later
2146         # The size of the hotspot is adjusted to a minimum of 20x20 if the control is so small,
2147         # that hitting the hotspot with the mouse pointer would be difficult
2148         if thumb_radius < 10:
2149             hs_adj = 10
2150         else:
2151             hs_adj = thumb_radius
2152         self.HotSpot = wx.Region(int(thumb_x - hs_adj), int(thumb_y - hs_adj), \
2153                         int(hs_adj * 2), int(hs_adj * 2))
2154 
2155         gcdc.SetBrush(wx.TRANSPARENT_BRUSH)
2156 
2157         # draw the tick marks
2158         # first ensure handler is aware of whether the text for ticks is for inside or outside the gauge
2159         if self._handler.ticks:
2160             ticks = []
2161             pens = []
2162             for _, pen, coords in self._handler.tick_list:
2163                 ticks += [coords]
2164                 pens += [pen]
2165 
2166             gcdc.DrawLineList(ticks, pens)
2167 
2168         if self.ShowScale:
2169             if not self._handler.tick_list:
2170                 _ = self._handler.tick_list # declared here to ensure tick_list has been created even without TICKS style
2171             #font = wx.Font(int(base_fontsize/4), wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.NORMAL)
2172             font = self.Font
2173             font.SetPointSize(int(base_fontsize/4))
2174             gcdc.SetFont(font)
2175             gcdc.SetTextForeground(self.GetDefaultTickColour())
2176             if not self.InsideScale:                                                # draw scale on the gauge exterior
2177                 gcdc.DrawTextList(self._handler.tick_values, self._handler.value_ticks)
2178             else:                                                                   # draw scale on the gauge interior
2179                 gcdc.DrawTextList(self._handler.tick_values, self._handler.inside_value_ticks)
2180 
2181         if self.ShowMinMax:
2182             if not self._handler.tick_list:
2183                 _ = self._handler.tick_list # declared here to ensure tick_list has been created even without TICKS style
2184             font = self.Font
2185             font.SetPointSize(int(base_fontsize/4))
2186             gcdc.SetFont(font)
2187             gcdc.SetTextForeground(self.GetDefaultTickColour())
2188             minmax_values = [self._handler.tick_values[0],self._handler.tick_values[-1]]
2189             if not self.InsideScale:                                                # draw minmax on the gauge exterior
2190                 minmax_ticks = [self._handler.value_ticks[0],self._handler.value_ticks[-1]]
2191             else:                                                                   # draw minmax on the gauge interior
2192                 minmax_ticks = [self._handler.inside_value_ticks[0],self._handler.inside_value_ticks[-1]]
2193             gcdc.DrawTextList(minmax_values, minmax_ticks)
2194 
2195         # For very high values like rpm, allow the initial value to be an int and only display an integer value
2196         if self.precision:
2197             show_val = f'{self._handler.value:3.{self.precision}f}'
2198         else:
2199             show_val = f'{int(self._handler.value):3}'
2200 
2201         # Draw gauge image
2202         if self.GaugeImage:
2203             gcdc.SetTextBackground(wx.TransparentColour)
2204             image = self.GaugeImage
2205             iw, ih = image.GetSize()
2206             adj = base_fontsize / ih
2207             iw = int(adj * iw)
2208             gaugebmp = wx.Bitmap(image.Scale(iw, base_fontsize))
2209             bw, bh = gaugebmp.GetSize()
2210             adjx = x_center - int(bw/2)
2211             adjy = y_center - int((bh) + (bh/4))
2212             if self.GaugeImagePos == 1:
2213                 adjx = adjy = 0
2214             elif self.GaugeImagePos == 2:
2215                 adjx = width - bw
2216                 adjy = 0
2217             elif self.GaugeImagePos == 3:
2218                 adjx = 0
2219                 adjy = height - bh
2220             elif self.GaugeImagePos == 4:
2221                 adjx = width - bw
2222                 adjy = height - bh
2223             gcdc.DrawBitmap(gaugebmp, adjx, adjy)
2224 
2225         # Draw central gauge text
2226         if self.GaugeText:
2227             #font = wx.Font(int(base_fontsize/2), wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.NORMAL)
2228             font = self.Font
2229             font.SetPointSize(int(base_fontsize/2))
2230             gcdc.SetFont(font)
2231             gcdc.SetTextForeground(self._handler.neon_colour)
2232             tw, th = gcdc.GetTextExtent(self.GaugeText)
2233             adj = x_center - int(tw/2)
2234             gcdc.DrawText(self.GaugeText, adj, y_center - int(th))
2235 
2236         # draw text value
2237         if self.ShowValue:
2238             #font = wx.Font(base_fontsize, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.BOLD)
2239             font = self.Font.Bold()
2240             font.SetPointSize(int(base_fontsize))
2241             gcdc.SetFont(font)
2242             gcdc.SetTextForeground(self._handler.neon_colour)
2243             tw, th = gcdc.GetTextExtent(show_val)
2244             adj = x_center - int(tw/2)
2245             gcdc.DrawText(show_val, adj, y_center)
2246 
2247         # Show ToolTip
2248         if self.ShowToolTip:
2249             self.SetToolTip(show_val)
2250 
2251         # Draw pointer with shadow
2252         if self.ShowPointer:
2253             center_radius = self._handler.center_radius
2254             radians = math.atan2(thumb_y - y_center, thumb_x - x_center)
2255             PxEnd = x_center + (center_radius - base_fontsize/4.5) * math.cos(radians)
2256             PyEnd = y_center + (center_radius - base_fontsize/4.5) * math.sin(radians)
2257 
2258             spine_size = int(base_fontsize/10)
2259             if spine_size < 1:
2260                 spine_size = 1
2261             p_pen_size =  spine_size * 2
2262             if spine_size % 2 != 0: # pen size odd if spine size odd
2263                 p_pen_size += 1
2264 
2265             shadow_adj = 0.075
2266             #halfway point flip shadow - assumes page size is the default 10%
2267             if self._handler.value >= (5 * self._handler.page_size) + self._handler.min_value:
2268                 shadow_adj = -0.075
2269 
2270             # Draw shadow if outside of range from light source (1/3 and 1/2 of max_value) or not near max_value
2271             if self._handler.value < self._handler.max_value * 0.9 \
2272                 and int(self._handler.value * 10) not in \
2273                     range(int((self._handler.max_value / 3) * 10), int((self._handler.max_value / 2) * 10)):
2274                 if int(self._handler.value * 10) > int((self._handler.max_value / 2) * 10):     # Short shadow
2275                     PxEndShadow = x_center + (center_radius - base_fontsize/3.5) * math.cos(radians + shadow_adj)
2276                     PyEndShadow = y_center + (center_radius - base_fontsize/3.5) * math.sin(radians + shadow_adj)
2277                 else:                                                                           # Long shadow
2278                     PxEndShadow = x_center + (center_radius - base_fontsize/5) * math.cos(radians + shadow_adj)
2279                     PyEndShadow = y_center + (center_radius - base_fontsize/5) * math.sin(radians + shadow_adj)
2280                 spen = wx.Pen('#30303080', p_pen_size)
2281                 #spen.SetCap(wx.CAP_PROJECTING)
2282                 gcdc.SetPen(spen)
2283                 gcdc.DrawLine(x_center, y_center, int(PxEndShadow), int(PyEndShadow))
2284 
2285             # Pointer Spine
2286             if not self.PointerColour:
2287                 pointer_colour = wx.Colour(self._handler.neon_colour)
2288             else:
2289                 pointer_colour = self.PointerColour
2290 
2291 
2292             ppen = wx.Pen(pointer_colour, spine_size)
2293             gcdc.SetPen(ppen)
2294             gcdc.DrawLine(x_center, y_center, int(PxEnd), int(PyEnd))
2295 
2296             if not self.PointerColour:
2297                 pointer_colour = wx.Colour(*self._handler.neon_colour + (160,))
2298             else:
2299                 pointer_colour = self.PointerColour
2300 
2301             ppen = wx.Pen(pointer_colour, p_pen_size)#base_fontsize/4))
2302             #ppen.SetCap(wx.CAP_PROJECTING)
2303             gcdc.SetPen(ppen)
2304             gcdc.DrawLine(x_center, y_center, int(PxEnd), int(PyEnd))
2305 
2306         if self.ShowOdometer:
2307             font = wx.Font(int(base_fontsize/2), wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.NORMAL)
2308             #font = self.Font
2309             #font.SetPointSize(int(base_fontsize/2))
2310             gcdc.SetFont(font)
2311             gcdc.SetTextForeground(self.OdometerColour)
2312             dist = f'{round(distance_travelled, 2):07.2f}'
2313             if len(dist) > 7:
2314                 dist = f'{round(distance_travelled, 1):07.1f}'
2315                 if len(dist) > 7:
2316                     dist = str(int(distance_travelled))
2317             tw, th = gcdc.GetTextExtent(dist)
2318             adjx = x_center - int(tw/2)
2319             adjy = int(y_center + (th * 1.75))
2320             dpen = wx.Pen(self.OdometerColour, 1)
2321             gcdc.SetPen(dpen)
2322             if self.OdometerBackgroundColour:
2323                 gcdc.SetBrush(wx.Brush(self.OdometerBackgroundColour))
2324             else:
2325                 gcdc.SetBrush(wx.TRANSPARENT_BRUSH)
2326             gcdc.DrawRectangle(adjx, adjy, tw, th)
2327             gcdc.DrawText(dist, adjx, adjy)
2328             self.OdometerHotSpot = wx.Region(adjx, adjy, tw, th)
2329 
2330         if self.Caption:
2331             #font = wx.Font(int(base_fontsize/4), wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.NORMAL)
2332             font = self.Font
2333             font.SetPointSize(int(base_fontsize/4))
2334             gcdc.SetFont(font)
2335             gcdc.SetTextForeground(self.GetDefaultTickColour())
2336             tw, th = gcdc.GetTextExtent(self.Caption)
2337             adjx = x_center - int(tw/2)
2338             adjy = int(height - th)
2339             if self.CaptionPos == 1:
2340                 adjx = adjy = 0
2341             elif self.CaptionPos == 2:
2342                 adjx = width - tw
2343                 adjy = 0
2344             elif self.CaptionPos == 3:
2345                 adjx = 0
2346                 adjy = height - th
2347             elif self.CaptionPos == 4:
2348                 adjx = width - tw
2349                 adjy = height - th
2350             dpen = wx.Pen(self.GetDefaultTickColour(), 1)
2351             gcdc.SetPen(dpen)
2352             gcdc.DrawText(self.Caption, adjx, adjy)
2353 
2354         dc.SelectObject(wx.Bitmap(1, 1))
2355         gcdc.Destroy()
2356         del gcdc
2357 
2358         dc.Destroy()
2359         del dc
2360 
2361         # create a buffered paint dc to draw the bmp to the client area
2362         pdc = wx.PaintDC(self)
2363         gcdc = wx.GCDC(pdc)
2364         gcdc.DrawBitmap(bmp, 0, 0)
2365 
2366         gcdc.Destroy()
2367         del gcdc
2368 
2369 
2370 if __name__ == '__main__':
2371     EVENT_MAPPING = {
2372         wx.EVT_SCROLL_TOP.typeId: 'EVT_SCROLL_TOP',
2373         wx.EVT_SCROLL_BOTTOM.typeId: 'EVT_SCROLL_BOTTOM',
2374         wx.EVT_SCROLL_LINEUP.typeId: 'EVT_SCROLL_LINEUP',
2375         wx.EVT_SCROLL_LINEDOWN.typeId: 'EVT_SCROLL_LINEDOWN',
2376         wx.EVT_SCROLL_PAGEUP.typeId: 'EVT_SCROLL_PAGEUP',
2377         wx.EVT_SCROLL_PAGEDOWN.typeId: 'EVT_SCROLL_PAGEDOWN',
2378         wx.EVT_SCROLL_THUMBTRACK.typeId: 'EVT_SCROLL_THUMBTRACK',
2379         wx.EVT_SCROLL_THUMBRELEASE.typeId: 'EVT_SCROLL_THUMBRELEASE',
2380         wx.EVT_SCROLL_CHANGED.typeId: 'EVT_SCROLL_CHANGED'
2381     }
2382 
2383 
2384     class Frame(wx.Frame):
2385 
2386         def __init__(self):
2387 
2388             wx.Frame.__init__(self, None, -1, "Volume Knob Demo", size=(400, 500))
2389 
2390             sizer = wx.BoxSizer(wx.VERTICAL)
2391             self.ctrl = KnobCtrl(self, value=0.0, minValue=0.0, maxValue=11.0, increment=0.1, size=(150, 150))
2392             self.ctrl.ShowToolTip = False
2393             self.ctrl.ShowPointer = False
2394             self.ctrl.ShowValue = True
2395             #self.ctrl.PointerColour = "#ffffff80"
2396             self.ctrl.SetThumbSize(7)
2397             self.ctrl.SetTickFrequency(0.1)
2398             self.ctrl.SetTickColours([(0, 255, 0, 255), (255, 187, 0, 255), (255, 61, 0, 255)])
2399             #self.ctrl.SetTickRangePercentage(False)
2400             self.ctrl.SetTickColourRanges([80, 90, 100])
2401             self.ctrl.SetBackgroundColour(wx.Colour(120, 120, 120))
2402             #self.ctrl.SetPrimaryColour((33, 36, 112, 255))
2403             self.ctrl.SetSecondaryColour((225, 225, 225, 255))
2404             #self.ctrl.SetOdometerUpdate(1000)
2405             self.ctrl.ShowScale = True
2406 
2407             self.ctrl.UseHotSpots = True
2408             self.ctrl.DisableMinMaxMouseDrag = True   # because this is a volume knob demonstration
2409             self.ctrl.InsideScale = False
2410             self.ctrl.SetDefaultTickColour('#ffffff') # White
2411 
2412             self.ctrl.SetStartEndDegrees(135.0, 405.0)
2413             help = wx.TextCtrl(self, -1, value="\nQuick Demonstration - Please wait!", size=(-1, 110),
2414                                style=wx.TE_MULTILINE|wx.TE_READONLY|wx.TE_CENTRE)
2415             self.ctrl.Bind(wx.EVT_SCROLL, self.on_event)
2416             help.Bind(wx.EVT_SET_FOCUS, self.on_focus_event)
2417             self.Bind(wx.EVT_CLOSE, self.OnQuit)
2418             sizer.Add(self.ctrl, 1, wx.EXPAND, 0)
2419             sizer.Add(help, 0, wx.EXPAND)
2420             self.SetSizer(sizer)
2421             self.Show()
2422             self.Refresh()
2423             self.Update()
2424 
2425             help.SetValue("Adjust Speedometer with Mouse or Keyboard\nLeft Click & Drag - Right Click\nMouse Scroll Up / Down\nKeyboard Up / Down / Page Up / Page Down\nKeyboard Home / End / Plus & Minus\nKeyboard numbers as a %")
2426 
2427         def on_event(self, event):
2428             print (EVENT_MAPPING[event.GetEventType()], event.Position)
2429             #print ("Avge speed \ time:", self.ctrl.GetAverageSpeed(), "Distance:", self.ctrl.GetOdometerValue())
2430 
2431         # Prevent keyboard event taking focus from ctrl
2432         def on_focus_event(self, event):
2433             self.ctrl.SetFocus()
2434             event.Skip()
2435 
2436         def OnQuit(self, event):
2437             self.Destroy()
2438 
2439     app = wx.App()
2440 
2441     frame = Frame()
2442     #frame.Show()
2443     app.MainLoop()


Download source

source.zip


Additional Information

Link :

- - - - -

https://wiki.wxpython.org/TitleIndex

https://docs.wxpython.org/

https://github.com/kdschlosser/wxVolumeKnob

https://discuss.wxpython.org/t/a-circular-gauge-meter/36755


Thanks to

Kevin Schlosser (knob.py coding), J. Healey (improvments), the wxPython community...


About this page

Date(d/m/y) Person (bot) Comments :

21/04/24 - Ecco (Created page for wxPython Phoenix).


Comments

- blah, blah, blah...

A circular gauge/meter (Phoenix) (last edited 2024-04-21 11:23:58 by Ecco)

NOTE: To edit pages in this wiki you must be a member of the TrustedEditorsGroup.