| 1 | // Copyright © SixtyFPS GmbH <info@slint.dev> |
| 2 | // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 |
| 3 | |
| 4 | import { ColoredTextStyle } from "./internal-components.slint" ; |
| 5 | |
| 6 | export struct TimeSelectorStyle { |
| 7 | foreground: brush, |
| 8 | foreground-selected: brush, |
| 9 | font-size: length, |
| 10 | font-weight: float |
| 11 | } |
| 12 | |
| 13 | component TimeSelector inherits Rectangle { |
| 14 | in property <bool> selected; |
| 15 | in property <int> value; |
| 16 | in property <TimeSelectorStyle> style; |
| 17 | callback clicked <=> touch-area.clicked; |
| 18 | |
| 19 | width: max(48px, text-label.min-width); |
| 20 | height: max(48px, text-label.min-height); |
| 21 | border-radius: max(root.width, root.height) / 2; |
| 22 | vertical-stretch: 0; |
| 23 | horizontal-stretch: 0; |
| 24 | |
| 25 | touch-area := TouchArea { } |
| 26 | |
| 27 | text-label := Text { |
| 28 | text: root.value; |
| 29 | vertical-alignment: center; |
| 30 | horizontal-alignment: center; |
| 31 | color: root.style.foreground; |
| 32 | font-size: root.style.font-size; |
| 33 | font-weight: root.style.font-weight; |
| 34 | } |
| 35 | |
| 36 | states [ |
| 37 | selected when root.selected: { |
| 38 | text-label.color: root.style.foreground-selected; |
| 39 | } |
| 40 | ] |
| 41 | } |
| 42 | |
| 43 | export struct ClockStyle { |
| 44 | background: brush, |
| 45 | foreground: brush, |
| 46 | time-selector-style: TimeSelectorStyle |
| 47 | } |
| 48 | |
| 49 | export component Clock { |
| 50 | in property <[int]> model; |
| 51 | in property <bool> two-columns; |
| 52 | in property <ClockStyle> style; |
| 53 | in property <int> total; |
| 54 | in-out property <int> current-item; |
| 55 | in property <int> current-value; |
| 56 | |
| 57 | callback current-item-changed(index: int); |
| 58 | |
| 59 | property <length> radius: max(root.width, root.height) / 2; |
| 60 | property <length> picker-ditameter: 48px; |
| 61 | property <length> center: root.radius - root.picker-ditameter / 2; |
| 62 | property <length> outer-padding: 2px; |
| 63 | property <length> inner-padding: 32px; |
| 64 | property <length> radius-outer: root.center - root.outer-padding; |
| 65 | property <length> radius-inner: root.center - root.inner-padding; |
| 66 | property <int> half-total: root.total / 2; |
| 67 | property <angle> rotation: 0.25turn; |
| 68 | property <length> current-x: get-index-x(root.current-value); |
| 69 | property <length> current-y: get-index-y(root.current-value); |
| 70 | |
| 71 | min-width: 256px; |
| 72 | min-height: 256px; |
| 73 | vertical-stretch: 0; |
| 74 | horizontal-stretch: 0; |
| 75 | |
| 76 | background-layer := Rectangle { |
| 77 | border-radius: max(self.width, self.height) / 2; |
| 78 | background: root.style.background; |
| 79 | } |
| 80 | |
| 81 | if root.current-item >= 0 || root.current-item < root.model.length: Path { |
| 82 | stroke: root.style.foreground; |
| 83 | stroke-width: 2px; |
| 84 | viewbox-width: self.width / 1px; |
| 85 | viewbox-height: self.height / 1px; |
| 86 | |
| 87 | MoveTo { |
| 88 | x: root.width / 2px; |
| 89 | y: root.height / 2px; |
| 90 | } |
| 91 | |
| 92 | LineTo { |
| 93 | x: (root.current-x + root.picker-ditameter / 2) / 1px; |
| 94 | y: (root.current-y + root.picker-ditameter / 2) / 1px; |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | Rectangle { |
| 99 | width: 8px; |
| 100 | height: 8px; |
| 101 | background: root.style.foreground; |
| 102 | border-radius: 4px; |
| 103 | } |
| 104 | |
| 105 | if root.current-item < root.model.length: Rectangle { |
| 106 | x: root.current-x; |
| 107 | y: root.current-y; |
| 108 | width: root.picker-ditameter; |
| 109 | height: root.picker-ditameter; |
| 110 | border-radius: root.picker-ditameter / 2; |
| 111 | background: root.style.foreground; |
| 112 | |
| 113 | if root.current-item < 0: Rectangle { |
| 114 | width: 4px; |
| 115 | height: 4px; |
| 116 | border-radius: 2px; |
| 117 | background: root.style.time-selector-style.foreground; |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | for val[index] in root.model: TimeSelector { |
| 122 | x: get-index-x(val); |
| 123 | y: get-index-y(val); |
| 124 | width: root.picker-ditameter; |
| 125 | height: root.picker-ditameter; |
| 126 | value: val; |
| 127 | selected: index == root.current-item; |
| 128 | style: root.style.time-selector-style; |
| 129 | accessible-role: button; |
| 130 | accessible-label: @tr("{} Hours or minutes of {}" , val, root.total); |
| 131 | accessible-action-default => { |
| 132 | self.clicked(); |
| 133 | } |
| 134 | |
| 135 | clicked => { |
| 136 | root.set-current-item(index); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | pure function value-to-angle(value: int) -> angle { |
| 141 | if root.two-columns { |
| 142 | if value >= root.half-total { |
| 143 | return clamp((value - root.half-total) / root.half-total * 1turn, 0, 0.999999turn) - root.rotation; |
| 144 | } |
| 145 | return clamp(value / root.half-total * 1turn, 0, 0.99999turn) - root.rotation; |
| 146 | } |
| 147 | clamp(value / root.total * 1turn, 0, 0.99999turn) - root.rotation; |
| 148 | } |
| 149 | |
| 150 | pure function get-index-x(value: int) -> length { |
| 151 | if root.two-columns && value >= root.half-total { |
| 152 | return root.center + (root.radius-inner / 1px * cos(root.value-to-angle(value))) * 1px; |
| 153 | } |
| 154 | root.center + (root.radius-outer / 1px * cos(root.value-to-angle(value))) * 1px |
| 155 | } |
| 156 | |
| 157 | pure function get-index-y(value: int) -> length { |
| 158 | // this is only for 24 mode |
| 159 | if root.total == 24 && value == 0 { |
| 160 | return root.center + (root.radius-inner / 1px * sin(root.value-to-angle(value))) * 1px; |
| 161 | } |
| 162 | if root.total == 24 && value == 12 { |
| 163 | return root.center + (root.radius-outer / 1px * sin(root.value-to-angle(value))) * 1px; |
| 164 | } |
| 165 | if root.two-columns && value >= root.half-total { |
| 166 | return root.center + (root.radius-inner / 1px * sin(root.value-to-angle(value))) * 1px; |
| 167 | } |
| 168 | root.center + (root.radius-outer / 1px * sin(root.value-to-angle(value))) * 1px |
| 169 | } |
| 170 | |
| 171 | function set-current-item(index: int) { |
| 172 | root.current-item-changed(index); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | export struct TimePickerInputStyle { |
| 177 | background: brush, |
| 178 | background-selected: brush, |
| 179 | foreground: brush, |
| 180 | foreground-selected: brush, |
| 181 | border-radius: length, |
| 182 | font-size: length, |
| 183 | font-weight: float |
| 184 | } |
| 185 | |
| 186 | export component TimePickerInput { |
| 187 | in property <TimePickerInputStyle> style; |
| 188 | in property <bool> read-only <=> text-input.read-only; |
| 189 | in property <bool> checked; |
| 190 | in-out property <string> text <=> text-input.text; |
| 191 | |
| 192 | callback clicked; |
| 193 | callback edited(int); |
| 194 | |
| 195 | min-width: max(96px, text-input.min-width); |
| 196 | min-height: max(80px, text-input.min-height); |
| 197 | vertical-stretch: 0; |
| 198 | horizontal-stretch: 0; |
| 199 | |
| 200 | forward-focus: text-input; |
| 201 | |
| 202 | background-layer := Rectangle { |
| 203 | border-radius: root.style.border-radius; |
| 204 | background: root.style.background; |
| 205 | } |
| 206 | |
| 207 | text-input := TextInput { |
| 208 | vertical-alignment: center; |
| 209 | horizontal-alignment: center; |
| 210 | width: 100%; |
| 211 | height: 100%; |
| 212 | color: root.style.foreground; |
| 213 | font-size: root.style.font-size; |
| 214 | font-weight: root.style.font-weight; |
| 215 | input-type: number; |
| 216 | edited => { |
| 217 | root.edited(self.text.to-float()); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | if root.read-only: TouchArea { |
| 222 | clicked => { |
| 223 | root.clicked(); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | states [ |
| 228 | checked when root.checked: { |
| 229 | background-layer.background: root.style.background-selected; |
| 230 | text-input.color: root.style.foreground-selected; |
| 231 | } |
| 232 | ] |
| 233 | } |
| 234 | |
| 235 | export struct PeriodSelectorItemStyle { |
| 236 | font-size: length, |
| 237 | font-weight: float, |
| 238 | foreground: brush, |
| 239 | background-selected: brush, |
| 240 | foreground-selected: brush |
| 241 | } |
| 242 | |
| 243 | export component PeriodSelectorItem { |
| 244 | in property <PeriodSelectorItemStyle> style; |
| 245 | in property <string> text <=> label.text; |
| 246 | in property <bool> checked; |
| 247 | |
| 248 | callback clicked <=> touch-area.clicked; |
| 249 | |
| 250 | touch-area := TouchArea { } |
| 251 | |
| 252 | background-layer := Rectangle { } |
| 253 | |
| 254 | label := Text { |
| 255 | font-size: root.style.font-size; |
| 256 | font-weight: root.style.font-weight; |
| 257 | color: root.style.foreground; |
| 258 | horizontal-alignment: center; |
| 259 | } |
| 260 | |
| 261 | states [ |
| 262 | checked when root.checked: { |
| 263 | background-layer.background: root.style.background-selected; |
| 264 | label.color: root.style.foreground-selected; |
| 265 | } |
| 266 | ] |
| 267 | } |
| 268 | |
| 269 | export struct PeriodSelectorStyle { |
| 270 | item-style: PeriodSelectorItemStyle, |
| 271 | border-brush: brush, |
| 272 | border-radius: length, |
| 273 | border-width: length} |
| 274 | |
| 275 | export component PeriodSelector { |
| 276 | in property <PeriodSelectorStyle> style; |
| 277 | in property <bool> am-selected; |
| 278 | |
| 279 | callback update-period(bool); |
| 280 | |
| 281 | min-width: max(38px, layout.min-width); |
| 282 | accessible-label: "AM or PM" ; |
| 283 | accessible-role: checkbox; |
| 284 | accessible-checked: root.am-selected; |
| 285 | |
| 286 | Rectangle { |
| 287 | border-radius: border.border-radius; |
| 288 | clip: true; |
| 289 | |
| 290 | layout := VerticalLayout { |
| 291 | PeriodSelectorItem { |
| 292 | text: "AM" ; |
| 293 | checked: root.am-selected; |
| 294 | style: root.style.item-style; |
| 295 | |
| 296 | clicked => { |
| 297 | if root.am-selected { |
| 298 | return; |
| 299 | } |
| 300 | root.update-period(true); |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | Rectangle { |
| 305 | height: 1px; |
| 306 | background: border.border-color; |
| 307 | vertical-stretch: 0; |
| 308 | } |
| 309 | |
| 310 | PeriodSelectorItem { |
| 311 | text: "PM" ; |
| 312 | checked: !root.am-selected; |
| 313 | style: root.style.item-style; |
| 314 | |
| 315 | clicked => { |
| 316 | if !root.am-selected { |
| 317 | return; |
| 318 | } |
| 319 | root.update-period(false); |
| 320 | } |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | border := Rectangle { |
| 326 | border-radius: root.style.border-radius; |
| 327 | border-width: root.style.border-width; |
| 328 | border-color: root.style.border-brush; |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | export struct Time { |
| 333 | hour: int, |
| 334 | minute: int, |
| 335 | second: int |
| 336 | } |
| 337 | |
| 338 | export struct TimePickerStyle { |
| 339 | foreground: brush, |
| 340 | horizontal-spacing: length, |
| 341 | vertical-spacing: length, |
| 342 | clock-style: ClockStyle, |
| 343 | input-style: TimePickerInputStyle, |
| 344 | period-selector-style: PeriodSelectorStyle, |
| 345 | title-style: ColoredTextStyle, |
| 346 | } |
| 347 | |
| 348 | export component TimePickerBase { |
| 349 | in property <bool> use-24-hour-format: SlintInternal.use-24-hour-format; |
| 350 | in property <bool> selection-mode: true; |
| 351 | in property <TimePickerStyle> style; |
| 352 | in property <Time> time: { hour: 12 }; |
| 353 | in property <string> title; |
| 354 | |
| 355 | property <bool> minutes-selected; |
| 356 | property <bool> am-selected: true; |
| 357 | property <[int]> twelf-hour-model: [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; |
| 358 | property <[int]> use-24-hour-format-model: [ |
| 359 | 12, |
| 360 | 1, |
| 361 | 2, |
| 362 | 3, |
| 363 | 4, |
| 364 | 5, |
| 365 | 6, |
| 366 | 7, |
| 367 | 8, |
| 368 | 9, |
| 369 | 10, |
| 370 | 11, |
| 371 | 0, |
| 372 | 13, |
| 373 | 14, |
| 374 | 15, |
| 375 | 16, |
| 376 | 17, |
| 377 | 18, |
| 378 | 19, |
| 379 | 20, |
| 380 | 21, |
| 381 | 22, |
| 382 | 23 |
| 383 | ]; |
| 384 | property <[int]> minute-model: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]; |
| 385 | property <[int]> current-model: root.get-current-model(); |
| 386 | property <int> current-item: root.minutes-selected ? root.index-of-minute(root.current-time.minute) : root.index-of-hour(root.current-time.hour); |
| 387 | property <Time> current-time: root.time; |
| 388 | property <int> time-picker-hour: hour-time-picker.text.to-float(); |
| 389 | property <int> time-picker-minute: minute-time-picker.text.to-float(); |
| 390 | |
| 391 | min-width: content-layer.min-width; |
| 392 | min-height: content-layer.min-height; |
| 393 | |
| 394 | content-layer := VerticalLayout { |
| 395 | spacing: root.style.vertical-spacing; |
| 396 | |
| 397 | HorizontalLayout { |
| 398 | Text { |
| 399 | text: root.title; |
| 400 | horizontal-alignment: left; |
| 401 | overflow: elide; |
| 402 | font-size: root.style.title-style.font-size; |
| 403 | font-weight: root.style.title-style.font-weight; |
| 404 | color: root.style.title-style.foreground; |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | HorizontalLayout { |
| 409 | spacing: root.style.horizontal-spacing; |
| 410 | alignment: center; |
| 411 | |
| 412 | hour-time-picker := TimePickerInput { |
| 413 | read-only: root.selection-mode; |
| 414 | checked: self.read-only && !root.minutes-selected; |
| 415 | text: root.current-time.hour; |
| 416 | style: root.style.input-style; |
| 417 | |
| 418 | accessible-role: AccessibleRole.text-input; |
| 419 | accessible-value: self.text; |
| 420 | accessible-label: "hour" ; |
| 421 | |
| 422 | clicked => { |
| 423 | root.minutes-selected = false; |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | separator := Text { |
| 428 | text: ":" ; |
| 429 | color: root.style.foreground; |
| 430 | font-size: root.style.input-style.font-size; |
| 431 | font-weight: root.style.input-style.font-weight; |
| 432 | vertical-alignment: center; |
| 433 | } |
| 434 | |
| 435 | minute-time-picker := TimePickerInput { |
| 436 | read-only: root.selection-mode; |
| 437 | checked: self.read-only && root.minutes-selected; |
| 438 | text: root.current-time.minute; |
| 439 | style: root.style.input-style; |
| 440 | |
| 441 | accessible-role: AccessibleRole.text-input; |
| 442 | accessible-value: self.text; |
| 443 | accessible-label: "minute" ; |
| 444 | |
| 445 | clicked => { |
| 446 | root.minutes-selected = true; |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | if !root.use-24-hour-format: PeriodSelector { |
| 451 | am-selected: root.am-selected; |
| 452 | style: root.style.period-selector-style; |
| 453 | |
| 454 | update-period(am) => { |
| 455 | root.am-selected = am; |
| 456 | } |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | if root.selection-mode: HorizontalLayout { |
| 461 | alignment: center; |
| 462 | |
| 463 | Clock { |
| 464 | width: 256px; |
| 465 | height: 256px; |
| 466 | model: root.current-model; |
| 467 | style: root.style.clock-style; |
| 468 | two-columns: !root.minutes-selected && root.use-24-hour-format; |
| 469 | current-item: root.current-item; |
| 470 | total: root.minutes-selected ? 60 : root.use-24-hour-format ? 24 : 12; |
| 471 | current-value: root.minutes-selected ? root.current-time.minute : root.current-time.hour; |
| 472 | |
| 473 | current-item-changed(index) => { |
| 474 | root.update-time-by-item(index); |
| 475 | |
| 476 | if !root.minutes-selected { |
| 477 | root.minutes-selected = true; |
| 478 | } |
| 479 | } |
| 480 | } |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | pure public function ok-enabled() -> bool { |
| 485 | if root.selection-mode { |
| 486 | return root.current-time.hour <= 23 && root.current-time.minute <= 59; |
| 487 | } |
| 488 | if root.use-24-hour-format { |
| 489 | return root.time-picker-hour <= 23 && root.time-picker-minute <= 59; |
| 490 | } |
| 491 | root.time-picker-hour <= 12 && root.time-picker-minute <= 59 |
| 492 | } |
| 493 | |
| 494 | public function get-current-time() -> Time { |
| 495 | if root.selection-mode { |
| 496 | if !root.use-24-hour-format && !root.am-selected { |
| 497 | if root.current-time.hour == 12 { |
| 498 | return { hour: 0, minute: root.current-time.minute }; |
| 499 | } |
| 500 | return { hour: root.current-time.hour + 12, minute: root.current-time.minute }; |
| 501 | } |
| 502 | return root.current-time; |
| 503 | } |
| 504 | return { hour: root.time-picker-hour, minute: root.time-picker-minute }; |
| 505 | } |
| 506 | |
| 507 | changed selection-mode => { |
| 508 | if !root.selection-mode { |
| 509 | return; |
| 510 | } |
| 511 | |
| 512 | root.update-time(min(root.time-picker-hour, root.use-24-hour-format ? 23 : 12), min(root.time-picker-minute, 59)); |
| 513 | } |
| 514 | |
| 515 | changed time => { |
| 516 | root.current-time = root.time; |
| 517 | } |
| 518 | |
| 519 | function update-time-by-item(index: int) { |
| 520 | root.update-time-by-value(root.current-model[index]); |
| 521 | } |
| 522 | |
| 523 | function update-time-by-value(value: int) { |
| 524 | if root.minutes-selected { |
| 525 | root.update-time(root.current-time.hour, value); |
| 526 | return; |
| 527 | } |
| 528 | root.update-time(value, root.current-time.minute) |
| 529 | } |
| 530 | |
| 531 | function update-time(hour: int, minute: int) { |
| 532 | root.current-time = { hour: hour, minute: minute }; |
| 533 | hour-time-picker.text = root.current-time.hour; |
| 534 | minute-time-picker.text = minute; |
| 535 | } |
| 536 | |
| 537 | pure function index-of-minute(minute: int) -> int { |
| 538 | if mod(minute, 5) != 0 { |
| 539 | return -1; |
| 540 | } |
| 541 | minute / 5 |
| 542 | } |
| 543 | |
| 544 | pure function index-of-hour(hour: int) -> int { |
| 545 | if hour == 12 { |
| 546 | return 0; |
| 547 | } |
| 548 | if hour == 0 { |
| 549 | return 12; |
| 550 | } |
| 551 | if !root.use-24-hour-format && hour > 12 { |
| 552 | return hour - 12; |
| 553 | } |
| 554 | return hour; |
| 555 | } |
| 556 | |
| 557 | pure function get-current-model() -> [int] { |
| 558 | if root.minutes-selected { |
| 559 | return root.minute-model; |
| 560 | } |
| 561 | if root.use-24-hour-format { |
| 562 | return root.use-24-hour-format-model; |
| 563 | } |
| 564 | root.twelf-hour-model; |
| 565 | } |
| 566 | } |
| 567 | |