A circular gauge / meter (Phoenix)
Keywords : Gauge, Gauge meter, Speedometer, Speed meter, knob control.
Contents
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
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
Additional Information
Link :
- - - - -
https://wiki.wxpython.org/TitleIndex
https://github.com/kdschlosser/wxVolumeKnob
https://discuss.wxpython.org/t/a-circular-gauge-meter/36755
Thanks to
Kevin Schlosser (knob.py coding), J. Healey (aka RolfofSaxony), 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...