Skip to content

Commit 8bc779e

Browse files
committed
TimePicker - new Clock widget to scrub to the desired time
1 parent 158084b commit 8bc779e

File tree

1 file changed

+305
-10
lines changed

1 file changed

+305
-10
lines changed

lib/Widgets/TimePicker.vala

+305-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2022-2023 Fyra Labs
2+
* Copyright (c) 2022-2024 Fyra Labs
33
* Copyright (c) 2014–2021 elementary, Inc. (https://elementary.io)
44
*
55
* This program is free software: you can redistribute it and/or modify
@@ -53,6 +53,8 @@ public class He.TimePicker : Gtk.Entry {
5353
}
5454

5555
update_text (true);
56+
clock.hour = _time.get_hour ();
57+
clock.minute = _time.get_minute ();
5658
changing_time = false;
5759
}
5860
}
@@ -65,6 +67,7 @@ public class He.TimePicker : Gtk.Entry {
6567
private Gtk.SpinButton minutes_spinbutton;
6668
private Gtk.ToggleButton am_togglebutton;
6769
private Gtk.ToggleButton pm_togglebutton;
70+
private ClockWidget clock;
6871

6972
/**
7073
* Creates a new TimePicker widget with the given format strings.
@@ -108,8 +111,10 @@ public class He.TimePicker : Gtk.Entry {
108111

109112
if (is_clock_format_12h ()) {
110113
hours_spinbutton = new Gtk.SpinButton.with_range (1, 12, 1);
114+
clock.is_military_mode = false;
111115
} else {
112116
hours_spinbutton = new Gtk.SpinButton.with_range (0, 23, 1);
117+
clock.is_military_mode = true;
113118
}
114119

115120
hours_spinbutton.orientation = Gtk.Orientation.VERTICAL;
@@ -138,16 +143,39 @@ public class He.TimePicker : Gtk.Entry {
138143
var separation_label = new Gtk.Label (":");
139144
separation_label.add_css_class ("display");
140145

141-
var pop_grid = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6) {
146+
var pop_grid_top = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6) {
142147
margin_top = 12,
143-
margin_bottom = 12,
144148
margin_start = 12,
145149
margin_end = 12,
146150
};
147-
pop_grid.append (hours_spinbutton);
148-
pop_grid.append (separation_label);
149-
pop_grid.append (minutes_spinbutton);
150-
pop_grid.append (am_pm_box);
151+
pop_grid_top.append (hours_spinbutton);
152+
pop_grid_top.append (separation_label);
153+
pop_grid_top.append (minutes_spinbutton);
154+
pop_grid_top.append (am_pm_box);
155+
156+
clock = new ClockWidget ();
157+
clock.time_selected.connect ((hour, minute) => {
158+
hours_spinbutton.set_text (hour.to_string ());
159+
if (minute < 10) {
160+
minutes_spinbutton.set_text ("0" + minute.to_string ());
161+
} else {
162+
minutes_spinbutton.set_text (minute.to_string ());
163+
}
164+
165+
time_changed ();
166+
update_time (true);
167+
update_time (false);
168+
});
169+
170+
var pop_grid_middle = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6) {
171+
margin_start = 12,
172+
margin_end = 12,
173+
};
174+
pop_grid_middle.append (clock);
175+
176+
var pop_grid = new Gtk.Box (Gtk.Orientation.VERTICAL, 24);
177+
pop_grid.append (pop_grid_top);
178+
pop_grid.append (pop_grid_middle);
151179

152180
popover = new Gtk.Popover () {
153181
autohide = true,
@@ -160,8 +188,7 @@ public class He.TimePicker : Gtk.Entry {
160188

161189
var focus_controller = new Gtk.EventControllerFocus ();
162190
var scroll_controller = new Gtk.EventControllerScroll (
163-
Gtk.EventControllerScrollFlags.BOTH_AXES
164-
| Gtk.EventControllerScrollFlags.DISCRETE
191+
Gtk.EventControllerScrollFlags.BOTH_AXES | Gtk.EventControllerScrollFlags.DISCRETE
165192
);
166193

167194
add_controller (focus_controller);
@@ -272,11 +299,13 @@ public class He.TimePicker : Gtk.Entry {
272299

273300
// Make sure that bounds are set correctly
274301
hours_spinbutton.set_range (1, 12);
302+
clock.is_military_mode = false;
275303
} else {
276304
am_pm_box.hide ();
277305
hours_spinbutton.set_value (time.get_hour ());
278306

279307
hours_spinbutton.set_range (0, 23);
308+
clock.is_military_mode = true;
280309
}
281310

282311
minutes_spinbutton.set_value (time.get_minute ());
@@ -390,4 +419,270 @@ public class He.TimePicker : Gtk.Entry {
390419
time_changed ();
391420
}
392421
}
393-
}
422+
423+
private class ClockWidget : Gtk.Widget {
424+
private const double SIZE = 256.0;
425+
private const double CENTER = SIZE / 2;
426+
private const double RADIUS = (SIZE / 2) - 10.0;
427+
private const double SELECTION_CIRCLE_RADIUS = 24.0;
428+
private const double INNER_RADIUS = RADIUS - SELECTION_CIRCLE_RADIUS - 30.0;
429+
private const double OUTER_RADIUS = RADIUS - SELECTION_CIRCLE_RADIUS;
430+
private const double HAND_LINE_WIDTH = 2.0;
431+
private const double HAND_CENTER_WIDTH = 4.0;
432+
private const string FONT_FAMILY = "Manrope";
433+
private const int HOUR_FONT_SIZE = 20;
434+
private const int MINUTE_FONT_SIZE = 18;
435+
private const int HALF_DAY = 12;
436+
private const int FULL_DAY = 24;
437+
private const int MINUTES = 60;
438+
439+
public bool selecting_hour { get; set; default = true;}
440+
public bool is_military_mode { get; set; default = false;}
441+
442+
private int selected_hour = 0;
443+
private int selected_minute = 0;
444+
private double last_angle = 0.0;
445+
446+
private Gdk.RGBA _accent_color = { 1, 1, 1, 1 };
447+
public Gdk.RGBA accent_color {
448+
get { return _accent_color; }
449+
set { _accent_color = value; queue_draw (); }
450+
}
451+
452+
public int hour {
453+
get { return selected_hour; }
454+
set {
455+
selected_hour = ((int)(value * (is_military_mode ? FULL_DAY : HALF_DAY) / (2 * Math.PI)) + 3) % (is_military_mode ? FULL_DAY : HALF_DAY);
456+
if (selected_hour == 0) selected_hour = (is_military_mode ? FULL_DAY : HALF_DAY);
457+
queue_draw ();
458+
}
459+
}
460+
461+
public int minute {
462+
get { return selected_minute; }
463+
set {
464+
selected_minute = (int)Math.round ((value * 30 / Math.PI) + 15) % MINUTES;
465+
queue_draw ();
466+
}
467+
}
468+
469+
public signal void time_selected (int hour, int minute);
470+
471+
construct {
472+
var click_gesture = new Gtk.GestureClick ();
473+
click_gesture.pressed.connect ((n_press, x, y) => {
474+
last_angle = get_angle_from_coords (x, y);
475+
});
476+
click_gesture.released.connect (() => {
477+
if (selecting_hour) {
478+
selecting_hour = false;
479+
queue_draw ();
480+
} else {
481+
emit_time_selected ();
482+
reset_to_hour_selection ();
483+
}
484+
});
485+
add_controller (click_gesture);
486+
487+
var drag_gesture = new Gtk.GestureDrag ();
488+
drag_gesture.drag_update.connect ((offset_x, offset_y) => {
489+
double x, y;
490+
drag_gesture.get_bounding_box_center (out x, out y);
491+
update_selection (x, y);
492+
});
493+
add_controller (drag_gesture);
494+
495+
hexpand = true;
496+
vexpand = true;
497+
width_request = (int)SIZE;
498+
height_request = (int)SIZE;
499+
}
500+
501+
protected override void dispose () {
502+
reset_to_hour_selection ();
503+
base.dispose ();
504+
}
505+
506+
protected override void snapshot (Gtk.Snapshot snapshot) {
507+
var rect = Graphene.Rect ();
508+
rect.init (0, 0, (float)SIZE, (float)SIZE);
509+
var cr = snapshot.append_cairo (rect);
510+
511+
// Font used
512+
cr.select_font_face (FONT_FAMILY, Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL);
513+
514+
// Draw clock face
515+
cr.set_source_rgba ((0.15 * 1), (0.15 * 1), (0.15 * 1), 0.08);
516+
cr.arc (CENTER, CENTER, RADIUS, 0, 2 * Math.PI);
517+
cr.fill ();
518+
519+
// Draw selection hand
520+
double hand_angle = selecting_hour ? get_hour_angle () : get_minute_angle ();
521+
double hand_radius = get_hand_radius (RADIUS);
522+
double hand_x = CENTER + hand_radius * Math.cos (hand_angle);
523+
double hand_y = CENTER + hand_radius * Math.sin (hand_angle);
524+
cr.set_source_rgba (accent_color.red, accent_color.green, accent_color.blue, 1);
525+
cr.arc (CENTER, CENTER, HAND_CENTER_WIDTH, 0, 2 * Math.PI);
526+
cr.fill_preserve ();
527+
cr.set_source_rgba (accent_color.red, accent_color.green, accent_color.blue, 1);
528+
cr.set_line_width (HAND_LINE_WIDTH);
529+
cr.move_to (CENTER, CENTER);
530+
cr.line_to (hand_x, hand_y);
531+
cr.stroke ();
532+
cr.set_source_rgba (accent_color.red, accent_color.green, accent_color.blue, 1);
533+
cr.arc (hand_x, hand_y, SELECTION_CIRCLE_RADIUS, 0, 2 * Math.PI);
534+
cr.fill ();
535+
536+
// Draw minutes
537+
if (!selecting_hour) {
538+
for (int i = 0; i < MINUTES; i += 5) {
539+
double minute_angle = (i - 15) * (2 * Math.PI) / MINUTES;
540+
double minute_x = CENTER + OUTER_RADIUS * Math.cos (minute_angle);
541+
double minute_y = CENTER + OUTER_RADIUS * Math.sin (minute_angle);
542+
543+
double selection_angle = get_minute_angle ();
544+
if (selection_angle < -Math.PI / 2) {
545+
selection_angle += 2 * Math.PI;
546+
}
547+
double label_angle = minute_angle;
548+
if (label_angle < -Math.PI / 2) {
549+
label_angle += 2 * Math.PI;
550+
}
551+
552+
bool is_selected = Math.fabs (selection_angle - label_angle) < Math.PI / MINUTES;
553+
554+
if (is_selected) {
555+
cr.set_source_rgba ((0.32 * 1), (0.32 * 1), (0.32 * 1), 1);
556+
} else {
557+
cr.set_source_rgba ((0.15 * 1), (0.15 * 1), (0.15 * 1), 1);
558+
}
559+
560+
cr.set_font_size (MINUTE_FONT_SIZE);
561+
562+
if (i >= 6) {
563+
cr.move_to (minute_x - 18 / 2.0, minute_y + 16 / 2.0);
564+
} else if (i == 1) {
565+
cr.move_to (minute_x - 6 / 2.0, minute_y + 16 / 2.0);
566+
} else {
567+
cr.move_to (minute_x - 10 / 2.0, minute_y + 16 / 2.0);
568+
}
569+
cr.show_text (i.to_string ());
570+
}
571+
}
572+
573+
// Draw hours
574+
if (selecting_hour) {
575+
int hour_count = is_military_mode ? FULL_DAY : HALF_DAY;
576+
577+
for (int i = 1; i <= hour_count; i++) {
578+
double hour_angle = ((i - 3) * Math.PI) / (HALF_DAY / 2);
579+
double label_radius = (i > HALF_DAY && is_military_mode) ? INNER_RADIUS : OUTER_RADIUS;
580+
double label_x = CENTER + label_radius * Math.cos (hour_angle);
581+
double label_y = CENTER + label_radius * Math.sin (hour_angle);
582+
583+
double selection_angle = get_hour_angle ();
584+
if (selection_angle < -Math.PI / 2) {
585+
selection_angle += 2 * Math.PI;
586+
}
587+
double label_angle = hour_angle;
588+
if (label_angle < -Math.PI / 2) {
589+
label_angle += 2 * Math.PI;
590+
}
591+
592+
bool is_selected = Math.fabs (selection_angle - label_angle) < Math.PI / HALF_DAY;
593+
594+
if (is_selected) {
595+
cr.set_source_rgba ((0.32 * 1), (0.32 * 1), (0.32 * 1), 1);
596+
} else {
597+
cr.set_source_rgba ((0.15 * 1), (0.15 * 1), (0.15 * 1), 1);
598+
}
599+
600+
if (i >= 10) {
601+
cr.move_to (label_x - 18 / 2.0, label_y + 16 / 2.0);
602+
} else if (i == 1) {
603+
cr.move_to (label_x - 6 / 2.0, label_y + 16 / 2.0);
604+
} else {
605+
cr.move_to (label_x - 10 / 2.0, label_y + 16 / 2.0);
606+
}
607+
608+
if (i <= HALF_DAY) {
609+
cr.set_font_size (HOUR_FONT_SIZE);
610+
} else {
611+
cr.set_font_size (MINUTE_FONT_SIZE);
612+
}
613+
614+
// If i is 1 to 12, display 1 to 12
615+
// If i is 13 to 23, display 13 to 23
616+
// If i is 24, display 0
617+
cr.show_text ((i > HALF_DAY ? (i == 24 ? 0 : i) : i).to_string ());
618+
}
619+
}
620+
}
621+
622+
private void emit_time_selected () {
623+
time_selected (selected_hour, selected_minute);
624+
}
625+
626+
private void reset_to_hour_selection () {
627+
selecting_hour = true;
628+
queue_draw ();
629+
}
630+
631+
private double get_hour_angle () {
632+
double hour = selected_hour % HALF_DAY;
633+
if (hour == 0) hour = HALF_DAY;
634+
double angle = (hour * (2 * Math.PI / HALF_DAY)) - Math.PI / 2;
635+
return angle;
636+
}
637+
638+
private double get_minute_angle () {
639+
return (selected_minute % MINUTES) * (2 * Math.PI / MINUTES) - Math.PI / 2;
640+
}
641+
642+
private double get_hand_radius (double base_radius) {
643+
if (selecting_hour && is_military_mode && selected_hour > HALF_DAY) {
644+
return INNER_RADIUS;
645+
} else {
646+
return OUTER_RADIUS;
647+
}
648+
}
649+
650+
private void update_selection (double x, double y) {
651+
double dx = x - CENTER;
652+
double dy = y - CENTER;
653+
654+
double distance = Math.sqrt (dx * dx + dy * dy);
655+
double angle = Math.atan2 (dy, dx) + Math.PI / 2;
656+
if (angle < 0) {
657+
angle += 2 * Math.PI;
658+
}
659+
660+
if (selecting_hour) {
661+
int temp_hour = (int)Math.round ((angle * HALF_DAY / (2 * Math.PI)));
662+
if (temp_hour <= 0) temp_hour += HALF_DAY;
663+
if (is_military_mode) {
664+
if (distance < INNER_RADIUS) {
665+
temp_hour += HALF_DAY;
666+
}
667+
}
668+
selected_hour = temp_hour;
669+
} else {
670+
selected_minute = (int)Math.round ((angle * MINUTES / (2 * Math.PI))) % MINUTES;
671+
if (selected_minute < 0) selected_minute += MINUTES;
672+
}
673+
674+
last_angle = angle;
675+
676+
queue_draw ();
677+
}
678+
679+
private double get_angle_from_coords (double x, double y) {
680+
double angle = Math.atan2 (y - CENTER, x - CENTER);
681+
if (angle < -Math.PI / 2) {
682+
angle += 2 * Math.PI;
683+
}
684+
685+
return angle;
686+
}
687+
}
688+
}

0 commit comments

Comments
 (0)