├── ui0.lua ├── ui0.md ├── ui0_button.lua ├── ui0_calendar.lua ├── ui0_colorpicker.lua ├── ui0_demo.lua ├── ui0_demo_bundle.bat ├── ui0_dropdown.lua ├── ui0_editbox.lua ├── ui0_grid.lua ├── ui0_layout_editor.lua ├── ui0_layout_editor_app.lua ├── ui0_list.lua ├── ui0_menu.lua ├── ui0_popup.lua ├── ui0_progressbar.lua ├── ui0_scrollbox.lua ├── ui0_slider.lua ├── ui0_tablist.lua ├── ui0_todo.txt └── ui0_zoomcalendar.lua /ui0_button.lua: -------------------------------------------------------------------------------- 1 | 2 | --Button widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | 8 | local button = ui.layer:subclass'button' 9 | ui.button = button 10 | button.iswidget = true 11 | button.focusable = true 12 | 13 | button.layout = 'textbox' 14 | button.min_ch = 20 15 | button.align_x = 'start' 16 | button.align_y = 'center' 17 | button.text_align_x = 'center' 18 | button.padding_left = 8 19 | button.padding_right = 8 20 | button.padding_top = 2 21 | button.padding_bottom = 2 22 | button.line_spacing = .9 23 | 24 | button.background_color = '#444' 25 | button.border_color = '#888' 26 | 27 | button.default = false 28 | button.cancel = false 29 | button.tags = 'standalone' 30 | 31 | ui:style('button', { 32 | transition_background_color = true, 33 | transition_border_color = true, 34 | transition_duration_background_color = .5, 35 | transition_duration_border_color = .5, 36 | }) 37 | 38 | ui:style('button :default', { 39 | background_color = '#092', 40 | }) 41 | 42 | ui:style('button :hot', { 43 | background_color = '#999', 44 | border_color = '#999', 45 | text_color = '#000', 46 | }) 47 | 48 | ui:style('button :default :hot', { 49 | background_color = '#3e3', 50 | }) 51 | 52 | ui:style('button !:enabled', { 53 | background_color = '#222', 54 | border_color = '#444', 55 | text_color = '#666', 56 | transition_duration = 0, 57 | }) 58 | 59 | ui:style('button :active :over', { 60 | background_color = '#fff', 61 | text_color = '#000', 62 | shadow_blur = 2, 63 | shadow_color = '#666', 64 | transition_duration = 0.2, 65 | }) 66 | 67 | ui:style('button :default :active :over', { 68 | background_color = '#9f7', 69 | }) 70 | 71 | ui:style('button :focused', { 72 | border_color = '#fff', 73 | border_width = 1, 74 | shadow_blur = 3, 75 | shadow_color = '#666', 76 | }) 77 | 78 | button:init_priority{ 79 | text=-2, key=-1, --because text can contain a key 80 | } 81 | 82 | --button style profiles 83 | 84 | button.profile = false 85 | 86 | ui:style('button profile=text', { 87 | background_type = false, 88 | border_width = 0, 89 | text_color = '#999', 90 | }) 91 | 92 | ui:style('button profile=text :hot', { 93 | text_color = '#ccc', 94 | }) 95 | 96 | ui:style('button profile=text :focused', { 97 | shadow_blur = 2, 98 | shadow_color = '#111', 99 | text_color = '#ccc', 100 | }) 101 | 102 | ui:style('button profile=text :active :over', { 103 | text_color = '#fff', 104 | shadow_blur = 2, 105 | shadow_color = '#111', 106 | }) 107 | 108 | button:stored_property'profile' 109 | function button:set_profile(new_profile, old_profile) 110 | if old_profile then self:settag('profile='..old_profile, false) end 111 | if new_profile then self:settag('profile='..new_profile, true) end 112 | end 113 | button:nochange_barrier'profile' --gives `old_profile` arg 114 | 115 | function button:press() 116 | if self:fire'pressed' ~= nil then 117 | return 118 | end 119 | if self.default then 120 | self.window:close'default' 121 | elseif self.cancel then 122 | self.window:close'cancel' 123 | end 124 | end 125 | 126 | function button:override_set_text(inherited, s) 127 | if s == '' or not s then s = false end 128 | if not s then 129 | s = false 130 | self.underline_pos = false 131 | self.underline_text = false 132 | else 133 | s = s:gsub('&&', '\0') --TODO: hack 134 | local pos, key = s:match'()&(.)' 135 | s = s:gsub('&', ''):gsub('%z', '&') 136 | if key then 137 | self.key = key:upper() 138 | self.underline_pos = pos 139 | self.underline_text = key 140 | end 141 | end 142 | if not inherited(self, s) then return end 143 | end 144 | 145 | button:stored_property'key' 146 | function button:after_set_key(key) 147 | self.underline_pos = false 148 | end 149 | 150 | function button:mousedown() 151 | if self.active_by_key then return end 152 | self.active = true 153 | end 154 | 155 | function button:mousemove(mx, my) 156 | if self.active_by_key then return end 157 | local mx, my = self:to_parent(mx, my) 158 | self:settag(':over', self.active and self:hit_test(mx, my, 'activate') == self) 159 | end 160 | 161 | function button:mouseup() 162 | if self.active_by_key then return end 163 | self.active = false 164 | if self.tags[':over'] then 165 | self:press() 166 | end 167 | end 168 | 169 | function button:activate_by_key(key) 170 | if self.active_by_key then return end 171 | self.active = true 172 | self.active_by_key = key 173 | self:settag(':over', true) 174 | return true 175 | end 176 | 177 | function button:keydown(key) 178 | if self.active_by_key then return end 179 | if key == 'enter' or key == 'space' then 180 | return self:activate_by_key(key) 181 | end 182 | end 183 | 184 | function button:keyup(key) 185 | if not self.active_by_key then return end 186 | local press = key == self.active_by_key 187 | if press or key == 'esc' then 188 | self:settag(':over', false) 189 | if press then 190 | self:press() 191 | if not self.ui then --window closed 192 | return true 193 | end 194 | end 195 | self.active = false 196 | self.active_by_key = false 197 | return true 198 | end 199 | end 200 | 201 | --default & cancel properties/tags 202 | 203 | button:stored_property'default' 204 | function button:after_set_default(default) 205 | self:settag(':default', default) 206 | end 207 | 208 | button:stored_property'cancel' 209 | function button:after_set_cancel(cancel) 210 | self:settag(':cancel', cancel) 211 | end 212 | 213 | function button:after_set_window(win) 214 | if not win then return end 215 | win:on({'keydown', self}, function(win, key) 216 | if self.key and self.ui:key(self.key) then 217 | return self:activate_by_key(key) 218 | elseif self.default and key == 'enter' then 219 | return self:activate_by_key(key) 220 | elseif self.cancel and key == 'esc' then 221 | return self:activate_by_key(key) 222 | end 223 | end) 224 | 225 | --if the button is not focusable, we need to catch keyups globally too 226 | win:on({'keyup', self}, function(win, key) 227 | return self:keyup(key) 228 | end) 229 | end 230 | 231 | --drawing 232 | 233 | function button:before_draw_text(cr) 234 | if not self:text_visible() then return end 235 | if not self.underline_pos then return end 236 | do return end --TODO: use the future hi-level text API to draw the underline 237 | --measure 238 | local x, y, w, h = self:text_bounding_box() --sets font 239 | local line_w = self.window:text_size(self.underline_text, self.text_multiline) 240 | local s = self.text:sub(1, self.underline_pos - 1) 241 | local line_pos = self.window:text_size(s, self.text_multiline) 242 | --draw 243 | cr:rgba(self.ui:rgba(self.text_color)) 244 | cr:line_width(1) 245 | cr:move_to(x + line_pos + 1, math.floor(y + h + 2) + .5) 246 | cr:rel_line_to(line_w - 2, 0) 247 | cr:stroke() 248 | end 249 | 250 | --checkbox ------------------------------------------------------------------- 251 | 252 | local checkbox = ui.layer:subclass'checkbox' 253 | ui.checkbox = checkbox 254 | 255 | checkbox.layout = 'flexbox' 256 | checkbox.item_align_y = 'baseline' 257 | checkbox.align_items_y = 'center' 258 | checkbox.align_y = 'center' 259 | 260 | --checked property 261 | 262 | checkbox.checked = false 263 | checkbox:stored_property'checked' 264 | function checkbox:after_set_checked(checked) 265 | self.button.text = self.checked 266 | and self.button.text_checked 267 | or self.button.text_unchecked 268 | self:settag(':checked', checked) 269 | self:fire(checked and 'was_checked' or 'was_unchecked') 270 | end 271 | function checkbox:override_set_checked(inherited, checked) 272 | if self:canset_checked(checked) then 273 | return inherited(self, checked) 274 | end 275 | end 276 | checkbox:track_changes'checked' 277 | 278 | function checkbox:canset_checked(checked) 279 | return 280 | self:fire(checked and 'checking' or 'unchecking') ~= false 281 | and self:fire('value_changing', self:value_for(checked), self.value) ~= false 282 | end 283 | 284 | function checkbox:toggle() 285 | self.checked = not self.checked 286 | end 287 | 288 | --value property 289 | 290 | checkbox.checked_value = true 291 | checkbox.unchecked_value = false 292 | 293 | function checkbox:value_for(checked) 294 | if checked then 295 | return self.checked_value 296 | else 297 | return self.unchecked_value 298 | end 299 | end 300 | 301 | function checkbox:checked_for(val) 302 | return val == self.checked_value 303 | end 304 | 305 | function checkbox:get_value() 306 | return self:value_for(self.checked) 307 | end 308 | 309 | function checkbox:set_value(val) 310 | self.checked = self:checked_for(val) 311 | end 312 | 313 | --align property 314 | 315 | checkbox.align = 'left' 316 | 317 | checkbox:stored_property'align' 318 | function checkbox:after_set_align(align) 319 | if align == 'right' then 320 | self.button:to_front() 321 | else 322 | self.button:to_back() 323 | end 324 | end 325 | 326 | --check button 327 | 328 | local cbutton = ui.button:subclass'checkbox_button' 329 | checkbox.button_class = cbutton 330 | 331 | cbutton.font = 'Ionicons,16' 332 | cbutton.text_checked = '\u{f2bc}' 333 | cbutton.text_unchecked = '' 334 | 335 | cbutton.align_y = false 336 | cbutton.fr = 0 337 | cbutton.layout = false 338 | cbutton.min_cw = 16 339 | cbutton.min_ch = 16 340 | cbutton.padding_top = 0 341 | cbutton.padding_bottom = 0 342 | cbutton.padding_left = 0 343 | cbutton.padding_right = 0 344 | 345 | ui:style('checkbox_button :hot', { 346 | text_color = '#fff', 347 | background_color = '#777', 348 | }) 349 | 350 | ui:style('checkbox_button :active :over', { 351 | background_color = '#888', 352 | }) 353 | 354 | function cbutton:override_hit_test(inherited, mx, my, reason) 355 | local widget, area = inherited(self, mx, my, reason) 356 | if not widget then 357 | self:validate() 358 | local lbl = self.checkbox.label 359 | widget, area = lbl.super.super.hit_test(lbl, mx, my, reason) 360 | if widget then 361 | return self, 'label' 362 | end 363 | end 364 | return widget, area 365 | end 366 | 367 | function cbutton:checkbox_press() 368 | self.checkbox:toggle() 369 | end 370 | 371 | function cbutton:before_press() 372 | self:checkbox_press() 373 | end 374 | 375 | function checkbox:create_button() 376 | return self.button_class(self.ui, { 377 | parent = self, 378 | checkbox = self, 379 | iswidget = false, 380 | }, self.button) 381 | end 382 | 383 | --label 384 | 385 | local clabel = ui.layer:subclass'checkbox_label' 386 | checkbox.label_class = clabel 387 | 388 | clabel.layout = 'textbox' 389 | clabel.line_spacing = .6 390 | 391 | function clabel:override_hit_test(inherited, mx, my, reason) 392 | local widget, area = inherited(self, mx, my, reason) 393 | if widget then 394 | return self.checkbox.button, area 395 | end 396 | end 397 | 398 | function clabel:after_sync_styles() 399 | local align = self.checkbox.align 400 | self.text_align_x = align 401 | local padding = self.checkbox.button.min_ch / 2 402 | self.padding_left = align == 'left' and padding or 0 403 | self.padding_right = align == 'right' and padding or 0 404 | end 405 | 406 | function checkbox:create_label() 407 | return self.label_class(self.ui, { 408 | parent = self, 409 | checkbox = self, 410 | iswidget = false, 411 | }, self.label) 412 | end 413 | 414 | checkbox:init_ignore{align=1, checked=1} 415 | 416 | function checkbox:after_init(t) 417 | self.button = self:create_button() 418 | self.label = self:create_label() 419 | self.align = t.align 420 | self.button:settag('standalone', false) 421 | self._checked = t --force setting of checked property 422 | self.checked = t.checked 423 | end 424 | 425 | --radio button --------------------------------------------------------------- 426 | 427 | local radio = ui.checkbox:subclass'radio' 428 | ui.radio = radio 429 | 430 | radio.radio_group = 'default' 431 | radio.item_align_y = 'center' 432 | 433 | radio:init_ignore{checked=1} 434 | 435 | function radio:override_canset_checked(inherited, checked) 436 | if not inherited(self, checked) then --refused 437 | return 438 | end 439 | if not checked then 440 | return true 441 | end 442 | --find the first radio button with the same group and uncheck it. 443 | local unchecked = self.window.view:each_child(function(rb) 444 | if rb.isradio 445 | and rb ~= self 446 | and rb.radio_group == self.radio_group 447 | and rb.checked 448 | then 449 | rb.checked = false 450 | return not rb.checked --unchecking allowed or refused 451 | end 452 | end) 453 | if unchecked == nil then --none found to uncheck 454 | unchecked = true 455 | end 456 | return unchecked 457 | end 458 | 459 | local rbutton = ui.checkbox.button_class:subclass'radio_button' 460 | radio.button_class = rbutton 461 | 462 | rbutton.padding_left = 0 463 | rbutton.padding_right = 0 464 | 465 | function rbutton:after_sync_styles() 466 | self.corner_radius = self.w 467 | end 468 | 469 | function rbutton:draw_text() end 470 | 471 | rbutton.circle_radius = 0 472 | 473 | ui:style('radio_button', { 474 | transition_circle_radius = true, 475 | transition_duration_circle_radius = .2, 476 | }) 477 | 478 | ui:style('radio :checked > radio_button', { 479 | circle_radius = .45, 480 | transition_duration_circle_radius = .2, 481 | }) 482 | 483 | function rbutton:before_draw_content(cr) 484 | local r = glue.lerp(self.circle_radius, 0, 1, 0, self.cw / 2) 485 | if r <= 0 then return end 486 | cr:circle(self.cw / 2, self.ch / 2, r) 487 | cr:rgba(self.ui:rgba(self.text_color)) 488 | cr:fill() 489 | end 490 | 491 | function rbutton:checkbox_press() 492 | self.checkbox.checked = true 493 | end 494 | 495 | --radio button list ---------------------------------------------------------- 496 | 497 | local rblist = ui.layer:subclass'radio_list' 498 | ui.radiolist = rblist 499 | 500 | --config 501 | rblist.radio_class = ui.radio 502 | rblist.align_x = 'stretch' 503 | rblist.layout = 'flexbox' 504 | rblist.flex_flow = 'y' 505 | 506 | --features 507 | rblist.option_list = false --{{value=, text=}, ...} 508 | rblist.options = false --{value->text} 509 | rblist.none_checked_value = false 510 | 511 | --value property 512 | 513 | function rblist:get_checked_button() 514 | return self.radios[self.value] 515 | end 516 | 517 | function rblist:set_value(val) 518 | local rb = self.radios[val] 519 | if rb then 520 | rb.checked = true 521 | else 522 | if self.checked_button then 523 | self.checked_button.checked = false 524 | end 525 | end 526 | end 527 | rblist:track_changes'value' 528 | 529 | --init 530 | 531 | rblist:init_ignore{options=1, value=1} 532 | 533 | function rblist:create_radio(index, value, text, radio) 534 | local rb = self:radio_class({ 535 | iswidget = false, 536 | checked_value = value, 537 | button = glue.update({ 538 | tabgroup = self.tabgroup or self, 539 | tabindex = index, 540 | }), 541 | label = glue.update({ 542 | text = text, 543 | }), 544 | radio_group = self, 545 | }, self.radio, radio) 546 | 547 | self.radios[value] = rb 548 | 549 | rb:on('checked_changed', function(rb, checked) 550 | if checked then 551 | self._value = rb.value 552 | else 553 | self._value = self.none_checked_value 554 | end 555 | end) 556 | end 557 | 558 | function rblist:after_init(t) 559 | self.radios = {} --{value -> button} 560 | if t.option_list then 561 | for i,v in ipairs(t.option_list) do 562 | local value, text, radio 563 | if type(v) == 'table' then 564 | value = v.value 565 | text = v.text 566 | radio = v.radio 567 | else 568 | value = v 569 | text = v 570 | end 571 | self:create_radio(i, value, text, radio) 572 | end 573 | end 574 | if t.options then 575 | local i = 1 576 | for value, text in glue.sortedpairs(t.options) do 577 | self:create_radio(i, value, text) 578 | i = i + 1 579 | end 580 | end 581 | if t.value ~= nil then 582 | self.value = t.value 583 | else 584 | self.value = self.none_checked_value 585 | end 586 | end 587 | 588 | --multi-choice button -------------------------------------------------------- 589 | 590 | local choicebutton = ui.layer:subclass'choicebutton' 591 | ui.choicebutton = choicebutton 592 | 593 | choicebutton.layout = 'flexbox' 594 | choicebutton.align_items_y = 'center' 595 | 596 | --model 597 | 598 | choicebutton.option_list = {} --{{index=, text=, value=, ...}, ...} 599 | 600 | function choicebutton:get_value() 601 | local btn = self.selected_button 602 | return btn and btn.choicebutton_value 603 | end 604 | 605 | function choicebutton:set_value(value) 606 | self:select_button(self:button_by_value(value)) 607 | end 608 | 609 | --view 610 | 611 | ui:style('button :selected', { 612 | background_color = '#ccc', 613 | text_color = '#000', 614 | }) 615 | 616 | function choicebutton:find_button(selects) 617 | for i,btn in ipairs(self) do 618 | if btn.choicebutton == self and selects(btn) then 619 | return btn 620 | end 621 | end 622 | end 623 | 624 | function choicebutton:button_by_value(value) 625 | return self:find_button(function(btn) return btn.choicebutton_value == value end) 626 | end 627 | 628 | function choicebutton:button_by_index(index) 629 | return self:find_button(function(btn) return btn.index == index end) 630 | end 631 | 632 | function choicebutton:get_selected_button() 633 | return self:find_button(function(btn) return btn.tags[':selected'] end) 634 | end 635 | 636 | function choicebutton:set_selected_button(btn) 637 | self:select_button(btn) 638 | end 639 | 640 | function choicebutton:unselect_button(btn) 641 | btn:settag(':selected', false) 642 | end 643 | 644 | function choicebutton:select_button(btn, focus) 645 | if not btn then return end 646 | local sbtn = self.selected_button 647 | if sbtn == btn then return end 648 | if sbtn then 649 | self:unselect_button(sbtn) 650 | end 651 | if focus then 652 | btn:focus() 653 | end 654 | btn:settag(':selected', true) 655 | self:fire('value_changed', btn.choicebutton_value) 656 | end 657 | 658 | --drawing 659 | 660 | choicebutton.button_corner_radius = 0 661 | 662 | function choicebutton:sync_layout_for_button(b) 663 | local r = self.button_corner_radius 664 | b.corner_radius_top_left = b.index == 1 and r or 0 665 | b.corner_radius_bottom_left = b.index == 1 and r or 0 666 | b.corner_radius_top_right = b.index == #self.option_list and r or 0 667 | b.corner_radius_bottom_right = b.index == #self.option_list and r or 0 668 | end 669 | 670 | --init 671 | 672 | choicebutton.button_class = ui.button 673 | 674 | choicebutton:init_ignore{value=1} 675 | 676 | function choicebutton:create_button(index, value) 677 | 678 | local btn = self:button_class({ 679 | tags = 'choicebutton_button', 680 | choicebutton = self, 681 | parent = self, 682 | iswidget = false, 683 | index = index, 684 | text = type(value) == 'table' and value.text or value, 685 | choicebutton_value = type(value) == 'table' and value.value or value, 686 | align_x = 'stretch', 687 | }, self.button, type(value) == 'table' and value.button or nil) 688 | 689 | function btn:after_sync() 690 | self.choicebutton:sync_layout_for_button(self) 691 | end 692 | 693 | --input/abstract 694 | function btn.before_press(btn) 695 | self:select_button(btn) 696 | end 697 | 698 | --input/keyboard 699 | function btn.before_keypress(btn, key) 700 | if key == 'left' then 701 | self:select_button(self:button_by_index(btn.index - 1), true) 702 | return true 703 | elseif key == 'right' then 704 | self:select_button(self:button_by_index(btn.index + 1), true) 705 | return true 706 | end 707 | end 708 | 709 | return btn 710 | end 711 | 712 | function choicebutton:after_init(t) 713 | for i,v in ipairs(t.option_list) do 714 | local btn = self:create_button(type(v) == 'table' and v.index or i, v) 715 | end 716 | if t.value ~= nil then 717 | self:select_button(self:button_by_value(t.value), false) 718 | end 719 | end 720 | 721 | --demo ----------------------------------------------------------------------- 722 | 723 | if not ... then require('ui0_demo')(function(ui, win) 724 | 725 | win.view.grid_wrap = 10 726 | win.view.item_align_x = 'left' 727 | win.view.grid_min_lines = 2 728 | 729 | local b1 = ui:button{ 730 | id = 'OK', 731 | parent = win, 732 | min_cw = 120, 733 | text = '&OK', 734 | default = true, 735 | } 736 | 737 | local btn = button:subclass'btn' 738 | 739 | local b2 = btn(ui, { 740 | id = 'Disabled', 741 | parent = win, 742 | text = 'Disabled', 743 | enabled = false, 744 | text_align_x = 'right', 745 | }) 746 | 747 | local b3 = btn(ui, { 748 | id = 'Cancel', 749 | parent = win, 750 | text = '&Cancel', 751 | cancel = true, 752 | text_align_x = 'left', 753 | }) 754 | 755 | function b1:gotfocus() print'b1 got focus' end 756 | function b1:lostfocus() print'b1 lost focus' end 757 | function b2:gotfocus() print'b2 got focus' end 758 | function b2:lostfocus() print'b2 lost focus' end 759 | 760 | function b1:pressed() print'b1 pressed' end 761 | function b2:pressed() print'b2 pressed' end 762 | 763 | local cb1 = ui:checkbox{ 764 | id = 'CB1', 765 | parent = win, 766 | min_cw = 200, 767 | label = {text = 'Check me.\nI\'m multiline.'}, 768 | checked = true, 769 | --enabled = false, 770 | } 771 | 772 | local cb2 = ui:checkbox{ 773 | id = 'CB2', 774 | parent = win, 775 | label = {text = 'Check me too', nowrap = true}, 776 | align = 'right', 777 | --enabled = false, 778 | } 779 | 780 | local rb1 = ui:radio{ 781 | id = 'RB1', 782 | parent = win, 783 | label = {text = 'Radio me', nowrap = true}, 784 | checked = true, 785 | radio_group = 1, 786 | --enabled = false, 787 | } 788 | 789 | local rb2 = ui:radio{ 790 | id = 'RB2', 791 | parent = win, 792 | label = {text = 'Radio me too', nowrap = true}, 793 | radio_group = 1, 794 | align = 'right', 795 | --enabled = false, 796 | } 797 | 798 | ui.radiolist{ 799 | parent = win, 800 | option_list = { 801 | 'Option 1', 802 | 'Option 2', 803 | 'Option 3', 804 | }, 805 | value = 'Option 2', 806 | } 807 | 808 | local cb1 = ui:choicebutton{ 809 | id = 'CHOICE', 810 | parent = win, 811 | min_cw = 400, 812 | option_list = { 813 | 'Choose me', 814 | 'No, me!', 815 | {text = 'Me, me, me!', value = 'val3'}, 816 | }, 817 | value = 'val3', 818 | } 819 | for i,b in ipairs(cb1) do 820 | if b.isbutton then 821 | b.id = 'CHOICE'..i 822 | end 823 | end 824 | 825 | end) end 826 | -------------------------------------------------------------------------------- /ui0_calendar.lua: -------------------------------------------------------------------------------- 1 | 2 | --Calendar widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | local push = table.insert 8 | 9 | ui = ui() 10 | local calendar = ui.grid:subclass'calendar' 11 | ui.calendar = calendar 12 | 13 | calendar.col_resize = false 14 | calendar.col_move = false 15 | calendar.cell_select = true 16 | 17 | function calendar:get_time() 18 | return self._time 19 | end 20 | 21 | local function month(time) 22 | local t = os.date('*t', time) 23 | local t0 = os.time(glue.merge({day = 1, hour = 0, min = 0, sec = 0}, t)) 24 | local t1 = os.time(glue.merge({day = 1, hour = 0, min = 0, sec = 0, month = t.month + 1}, t)) - 1 25 | local t0 = os.date('*t', t0) 26 | local t1 = os.date('*t', t1) 27 | t0.days = t1.day 28 | t0.today = t.day 29 | return t0 30 | end 31 | 32 | ui:style('grid_cell :current_month', { 33 | background_color = '#111', 34 | }) 35 | 36 | ui:style('grid_cell :today', { 37 | background_color = '#260', 38 | }) 39 | 40 | function calendar:sync_cell(cell, i, col, val) 41 | if not val then 42 | val = '' 43 | else 44 | cell:settag('today', val.today) 45 | cell:settag('current_month', val.current_month) 46 | val = val.day 47 | end 48 | cell:sync_value(i, col, val) 49 | end 50 | 51 | function calendar:set_time(time) 52 | self._time = time 53 | self.rows = {} 54 | local t = month(time) 55 | local wday = t.wday 56 | local week = 1 57 | local row = {} 58 | push(self.rows, row) 59 | for wday = 1, wday - 1 do 60 | push(row, {current_month = false, day = 'x'}) 61 | end 62 | for day = 1, t.days do 63 | push(row, {current_month = true, day = day, today = day == t.today}) 64 | wday = wday + 1 65 | if wday > 7 then 66 | wday = 1 67 | week = week + 1 68 | row = {} 69 | push(self.rows, row) 70 | end 71 | end 72 | for wday = wday, 7 do 73 | push(row, {current_month = false, day = 'y'}) 74 | end 75 | end 76 | 77 | ui.weekdays_short = ui.weekdays_short 78 | or {'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'} 79 | 80 | function calendar:before_init() 81 | self.cols = {} 82 | for i = 1, 7 do 83 | push(self.cols, { 84 | text = ui.weekdays_short[i], 85 | text_align_x = 'center', 86 | }) 87 | end 88 | end 89 | 90 | function calendar:after_sync() 91 | local w = math.floor(self.cw / 7) 92 | for i = 1, 7 do 93 | self.cols[i].w = w 94 | end 95 | self.row_h = math.floor(self.scroll_pane.rows_pane.rows_layer.ch / self.row_count) 96 | end 97 | 98 | 99 | --demo ----------------------------------------------------------------------- 100 | 101 | if not ... then require('ui0_demo')(function(ui, win) 102 | 103 | local c = ui:calendar{ 104 | x = 10, y = 10, 105 | w = 7 * 30, h = 6 * 30, 106 | parent = win, 107 | time = os.time(), 108 | } 109 | 110 | end) end 111 | -------------------------------------------------------------------------------- /ui0_colorpicker.lua: -------------------------------------------------------------------------------- 1 | 2 | --Color Picker widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | require'ui0_button' 7 | require'ui0_editbox' 8 | local glue = require'glue' 9 | local bitmap = require'bitmap' 10 | local color = require'color' 11 | local cairo = require'cairo' 12 | local lerp = glue.lerp 13 | local clamp = glue.clamp 14 | 15 | --line hit testing (from path2d) --------------------------------------------- 16 | 17 | local epsilon = 1e-10 18 | local function near(x, y) 19 | return abs(x - y) <= epsilon * max(1, abs(x), abs(y)) 20 | end 21 | 22 | --distance between two points squared. 23 | local function distance2(x1, y1, x2, y2) 24 | return (x2-x1)^2 + (y2-y1)^2 25 | end 26 | 27 | --intersect infinite line with its perpendicular from point (x, y) 28 | --return the intersection point. 29 | local function point_line_intersection(x, y, x1, y1, x2, y2) 30 | local dx = x2 - x1 31 | local dy = y2 - y1 32 | local k = dx^2 + dy^2 33 | if near(k, 0) then return x1, y1 end --line has no length 34 | local k = ((x - x1) * dy - (y - y1) * dx) / k 35 | return 36 | x - k * dy, 37 | y + k * dx 38 | end 39 | 40 | --return the time in 0..1 in the line segment (x1, y1, x2, y2) where the 41 | --perpendicular from point (x0, y0) intersects the line. 42 | local function line_hit(x0, y0, x1, y1, x2, y2) 43 | local x, y = point_line_intersection(x0, y0, x1, y1, x2, y2) 44 | local tx = near(x2, x1) and 0 or (x - x1) / (x2 - x1) 45 | local ty = near(y2, y1) and 0 or (y - y1) / (y2 - y1) 46 | if tx < 0 or ty < 0 then 47 | --intersection is outside the segment, closer to the first endpoint 48 | return distance2(x0, y0, x1, y1), x1, y1, 0 49 | elseif tx > 1 or ty > 1 then 50 | --intersection is outside the segment, closer to the second endpoint 51 | return distance2(x0, y0, x2, y2), x2, y2, 1 52 | end 53 | return max(tx, ty) 54 | end 55 | 56 | --hue vertical bar ----------------------------------------------------------- 57 | 58 | local hue_bar = ui.layer:subclass'hue_bar' 59 | ui.hue_bar = hue_bar 60 | 61 | --model 62 | 63 | function hue_bar:get_hue() 64 | return self._hue 65 | end 66 | 67 | function hue_bar:set_hue(hue) 68 | hue = clamp(hue, 0, 360) 69 | local old_hue = self._hue 70 | if old_hue ~= hue then 71 | self._hue = hue 72 | if self:isinstance() then 73 | self:fire('hue_changed', hue, old_hue) 74 | self:invalidate() 75 | end 76 | end 77 | end 78 | 79 | hue_bar.hue = 0 80 | 81 | hue_bar:init_ignore{hue=1} 82 | 83 | function hue_bar:after_init(t) 84 | self._hue = t.hue 85 | end 86 | 87 | --view/hue-bar 88 | 89 | function hue_bar:sync_bar() 90 | if not self._bmp or self._bmp.h ~= self.ch or self._bmp.w ~= self.cw then 91 | self._bmp = bitmap.new(self.cw, self.ch, 'bgra8') 92 | local bmp = self._bmp 93 | local _, setpixel = bitmap.pixel_interface(bmp) 94 | for y = 0, bmp.h-1 do 95 | local hue = lerp(y, 0, bmp.h-1, 0, 360) 96 | local r, g, b = color.convert('rgb', 'hsl', hue, 1, 0.5) 97 | for x = 0, bmp.w-1 do 98 | setpixel(x, y, r * 255, g * 255, b * 255, 255) 99 | end 100 | end 101 | end 102 | end 103 | 104 | function hue_bar:draw_bar(cr) 105 | self:sync_bar() 106 | local sr = cairo.image_surface(self._bmp) 107 | cr:operator'over' 108 | cr:source(sr) 109 | cr:paint() 110 | cr:rgb(0, 0, 0) --release source 111 | sr:free() 112 | end 113 | 114 | function hue_bar:background_visible() 115 | return true 116 | end 117 | 118 | function hue_bar:paint_background(cr) 119 | self:draw_bar(cr) 120 | end 121 | 122 | --view/pointer 123 | 124 | hue_bar.pointer_style = 'sliderule' --sliderule, needle 125 | 126 | function hue_bar:before_draw_content(cr) 127 | local y = glue.round(self.hue / 360 * self.ch) + .5 128 | self['draw_pointer_'..self.pointer_style](self, cr, y) 129 | end 130 | 131 | --view/pointer/needle 132 | 133 | hue_bar.needle_pointer_color = '#0008' 134 | 135 | ui:style('hue_bar :focused', { 136 | needle_pointer_color = '#000', 137 | }) 138 | 139 | function hue_bar:draw_pointer_needle(cr, y) 140 | local w = self.cw 141 | cr:save() 142 | cr:line_width(1) 143 | cr:operator'over' 144 | cr:rgba(self.ui:rgba(self.needle_pointer_color)) 145 | local sw = w / 3 146 | local sh = 1.5 147 | cr:new_path() 148 | cr:move_to(0, y-sh) 149 | cr:line_to(0, y+sh) 150 | cr:line_to(sw, y) 151 | cr:fill() 152 | cr:move_to(w, y-sh) 153 | cr:line_to(w, y+sh) 154 | cr:line_to(w - sw, y) 155 | cr:fill() 156 | cr:restore() 157 | end 158 | 159 | --view/pointer/sliderule 160 | 161 | local rule = ui.layer:subclass'hue_bar_sliderule' 162 | hue_bar.sliderule_class = rule 163 | 164 | rule.activable = false 165 | 166 | rule.h = 8 167 | rule.opacity = .7 168 | rule.border_offset = 1 169 | rule.border_width = 2 170 | rule.border_color = '#fff' 171 | rule.corner_radius = 3 172 | rule.outline_width = 1 173 | rule.outline_color = '#333' 174 | 175 | ui:style('hue_bar :focused > hue_bar_sliderule', { 176 | opacity = 1, 177 | }) 178 | 179 | function rule:before_draw_border(cr) 180 | cr:line_width(self.outline_width) 181 | cr:rgba(self.ui:rgba(self.outline_color)) 182 | self:border_path(cr, -1) 183 | cr:stroke() 184 | self:border_path(cr, 1) 185 | cr:stroke() 186 | end 187 | 188 | function hue_bar:draw_pointer_sliderule(cr, y) 189 | if not self.sliderule or not self.sliderule.islayer then 190 | self.sliderule = self.sliderule_class(self.ui, { 191 | parent = self, 192 | }, self.sliderule) 193 | end 194 | local rule = self.sliderule 195 | rule.y = glue.round(y - rule.h / 2) 196 | rule.w = self.w 197 | end 198 | 199 | --input/mouse 200 | 201 | hue_bar.mousedown_activate = true 202 | 203 | function hue_bar:mousemove(mx, my) 204 | if not self.active then return end 205 | self.hue = lerp(my, 0, self.ch-1, 0, 360) 206 | self:invalidate() 207 | end 208 | 209 | --input/keyboard 210 | 211 | hue_bar.focusable = true 212 | 213 | function hue_bar:keypress(key) 214 | if key == 'down' or key == 'up' 215 | or key == 'pagedown' or key == 'pageup' 216 | or key == 'home' or key == 'end' 217 | then 218 | local delta = 219 | (key:find'down' and 1 or -1) 220 | * (self.ui:key'shift' and .01 or 1) 221 | * (self.ui:key'ctrl' and .1 or 1) 222 | * (key:find'page' and 5 or 1) 223 | * (key == 'home' and 1/0 or 1) 224 | * (key == 'end' and -1/0 or 1) 225 | * 360 226 | * 0.1 227 | self.hue = self.hue + delta 228 | return true 229 | end 230 | end 231 | 232 | --input/wheel 233 | 234 | hue_bar.vscrollable = true 235 | 236 | function hue_bar:mousewheel(pages) 237 | self.hue = self.hue + 238 | -pages / 3 239 | * (self.ui:key'shift' and .01 or 1) 240 | * (self.ui:key'ctrl' and .1 or 1) 241 | * 360 242 | * 0.1 243 | end 244 | 245 | --abstract pick rectangle ---------------------------------------------------- 246 | 247 | local prect = ui.layer:subclass'pick_rectangle' 248 | ui.pick_rectangle = prect 249 | 250 | --model 251 | 252 | function prect:get_a() error'stub' end 253 | function prect:set_a(a) error'stub' end 254 | function prect:get_b() error'stub' end 255 | function prect:set_b(b) error'stub' end 256 | function prect:a_range() error'stub' end 257 | function prect:b_range() error'stub' end 258 | 259 | function prect:ab(x, y) 260 | local a1, a2 = self:a_range() 261 | local b1, b2 = self:b_range() 262 | local a = clamp(lerp(x, 0, self.cw-1, a1, a2), a1, a2) 263 | local b = clamp(lerp(y, 0, self.ch-1, b1, b2), b1, b2) 264 | return a, b 265 | end 266 | 267 | function prect:xy(a, b) 268 | local a1, a2 = self:a_range() 269 | local b1, b2 = self:b_range() 270 | local x = lerp(a, a1, a2, 0, self.cw-1) 271 | local y = lerp(b, b1, b2, 0, self.ch-1) 272 | return x, y 273 | end 274 | 275 | function prect:abrect() 276 | local a0, b0 = self:ab(0, 0) 277 | local a1, b1 = self:ab(self.cw, self.ch) 278 | return a0, b0, a1, b1 279 | end 280 | 281 | function prect:a_range() 282 | local a0, b0, a1, b1 = self:abrect() 283 | return a0, a1 284 | end 285 | 286 | function prect:b_range() 287 | local a0, b0, a1, b1 = self:abrect() 288 | return b0, b1 289 | end 290 | 291 | --view/pointer 292 | 293 | prect.pointer_style = 'cross' --circle, cross 294 | 295 | function prect:before_draw_content(cr) 296 | local cx, cy = self:xy(self.a, self.b) 297 | self['draw_pointer_'..self.pointer_style](self, cr, cx, cy) 298 | end 299 | 300 | --view/pointer/cross 301 | 302 | prect.pointer_cross_opacity = .5 303 | 304 | ui:style('pick_rectangle :focused', { 305 | pointer_cross_opacity = 1, 306 | }) 307 | 308 | function prect:pointer_cross_rgb(x, y) error'stub' end 309 | 310 | function prect:draw_pointer_cross(cr, cx, cy) 311 | local r, g, b = self:pointer_cross_rgb(cx, cy) 312 | cr:save() 313 | cr:rgba(r, g, b, self.pointer_cross_opacity) 314 | cr:operator'over' 315 | cr:line_width(1) 316 | cr:translate(cx, cy) 317 | cr:new_path() 318 | for i=1,4 do 319 | cr:move_to(0, 6) 320 | cr:rel_line_to(-1, 4) 321 | cr:rel_line_to(2, 0) 322 | cr:close_path() 323 | cr:rotate(math.rad(90)) 324 | end 325 | cr:fill_preserve() 326 | cr:stroke() 327 | cr:restore() 328 | end 329 | 330 | --view/pointer/circle 331 | 332 | prect.circle_pointer_color = '#fff8' 333 | prect.circle_pointer_outline_color = '#3338' 334 | prect.circle_pointer_outline_width = 1 335 | prect.circle_pointer_radius = 9 336 | prect.circle_pointer_inner_radius = 6 337 | 338 | ui:style('pick_rectangle :focused', { 339 | circle_pointer_color = '#fff', 340 | circle_pointer_outline_color = '#333', 341 | }) 342 | 343 | function prect:draw_pointer_circle(cr, cx, cy) 344 | cr:save() 345 | cr:rgba(self.ui:rgba(self.circle_pointer_color)) 346 | cr:operator'over' 347 | cr:line_width(self.circle_pointer_outline_width) 348 | cr:fill_rule'even_odd' 349 | cr:new_path() 350 | cr:circle(cx, cy, self.circle_pointer_radius) 351 | cr:circle(cx, cy, self.circle_pointer_inner_radius) 352 | cr:fill_preserve() 353 | cr:rgba(self.ui:rgba(self.circle_pointer_outline_color)) 354 | cr:stroke() 355 | cr:restore() 356 | end 357 | 358 | --input/mouse 359 | 360 | prect.mousedown_activate = true 361 | 362 | function prect:mousemove(mx, my) 363 | if not self.active then return end 364 | self.a, self.b = self:ab(mx, my) 365 | end 366 | 367 | --input/keyboard 368 | 369 | prect.focusable = true 370 | 371 | function prect:keypress(key) 372 | local delta = 373 | (self.ui:key'shift' and .01 or 1) 374 | * (self.ui:key'ctrl' and 0.1 or 1) 375 | * (key:find'page' and 5 or 1) 376 | * (key == 'home' and 1/0 or 1) 377 | * (key == 'end' and -1/0 or 1) 378 | * 0.1 379 | if key == 'down' or key == 'up' or key == 'pagedown' or key == 'pageup' 380 | or key == 'home' or key == 'end' 381 | then 382 | local delta = delta * (key:find'down' and 1 or -1) 383 | self.b = self.b + lerp(delta, 0, 1, self:b_range()) 384 | self:invalidate() 385 | return true 386 | elseif key == 'left' or key == 'right' then 387 | local delta = delta * (key:find'left' and -1 or 1) 388 | self.a = self.a + lerp(delta, 0, 1, self:a_range()) 389 | self:invalidate() 390 | return true 391 | end 392 | end 393 | 394 | --input/wheel 395 | 396 | prect.vscrollable = true 397 | 398 | function prect:mousewheel(pages) 399 | local delta = 400 | -pages / 3 401 | * (self.ui:key'shift' and .01 or 1) 402 | * (self.ui:key'ctrl' and .1 or 1) 403 | * 0.1 404 | self.b = self.b + lerp(delta, 0, 1, self:b_range()) 405 | end 406 | 407 | --saturation/luminance rectangle --------------------------------------------- 408 | 409 | local slrect = prect:subclass'sat_lum_rectangle' 410 | ui.sat_lum_rectangle = slrect 411 | 412 | --model 413 | 414 | slrect.hue = 0 415 | slrect.sat = 0 416 | slrect.lum = 0 417 | 418 | slrect:stored_property'hue' 419 | slrect:stored_property'sat' 420 | slrect:stored_property'lum' 421 | 422 | slrect:track_changes'hue' 423 | slrect:track_changes'sat' 424 | slrect:track_changes'lum' 425 | 426 | function slrect:override_set_hue(inherited, hue) 427 | if inherited(self, hue % 360) then 428 | self:fire'color_changed' 429 | self:invalidate() 430 | end 431 | end 432 | 433 | function slrect:override_set_sat(inherited, sat) 434 | if inherited(self, clamp(sat, 0, 1)) then 435 | self:fire'color_changed' 436 | self:invalidate() 437 | end 438 | end 439 | 440 | function slrect:override_set_lum(inherited, lum) 441 | if inherited(self, clamp(lum, 0, 1)) then 442 | self:fire'color_changed' 443 | self:invalidate() 444 | end 445 | end 446 | 447 | function slrect:hsl() 448 | return self.hue, self.sat, self.lum 449 | end 450 | 451 | function slrect:rgb() 452 | return color.convert('rgb', 'hsl', self:hsl()) 453 | end 454 | 455 | function slrect:get_a() return self.sat end 456 | function slrect:set_a(a) self.sat = a end 457 | function slrect:get_b() return 1-self.lum end 458 | function slrect:set_b(b) self.lum = 1-b end 459 | function slrect:a_range() return 0, 1 end 460 | function slrect:b_range() return 0, 1 end 461 | 462 | slrect:init_ignore{hue=1, sat=1, lum=1} 463 | 464 | function slrect:after_init(t) 465 | self._hue = t.hue 466 | self._sat = t.sat 467 | self._lum = t.lum 468 | end 469 | 470 | --view 471 | 472 | function slrect:draw_colors(cr) 473 | if not self._bmp or self._bmp.h ~= self.ch or self._bmp.w ~= self.cw then 474 | self._bmp = bitmap.new(self.cw, self.ch, 'bgra8') 475 | end 476 | if self._bmp_hue ~= self.hue then 477 | self._bmp_hue = self.hue 478 | local bmp = self._bmp 479 | local _, setpixel = bitmap.pixel_interface(bmp) 480 | local w, h = bmp.w, bmp.h 481 | for y = 0, h-1 do 482 | for x = 0, w-1 do 483 | local sat = lerp(x, 0, w-1, 0, 1) 484 | local lum = lerp(y, 0, h-1, 1, 0) 485 | local r, g, b = color.convert('rgb', 'hsl', self.hue, sat, lum) 486 | setpixel(x, y, r * 255, g * 255, b * 255, 255) 487 | end 488 | end 489 | end 490 | cr:save() 491 | local sr = cairo.image_surface(self._bmp) 492 | cr:operator'over' 493 | cr:source(sr) 494 | cr:paint() 495 | cr:rgb(0, 0, 0) --release source 496 | sr:free() 497 | cr:restore() 498 | end 499 | 500 | function slrect:pointer_cross_rgb(x, y) 501 | local hue = self.hue + 180 502 | local lum = self.lum > 0.5 and 0 or 1 503 | local sat = 1 - self.sat 504 | return color.convert('rgb', 'hsl', hue, sat, lum) 505 | end 506 | 507 | function slrect:before_draw_content(cr) 508 | self:draw_colors(cr) 509 | end 510 | 511 | --saturation / value rectangle ----------------------------------------------- 512 | 513 | local svrect = prect:subclass'sat_val_rectangle' 514 | ui.sat_val_rectangle = svrect 515 | 516 | --model 517 | 518 | svrect.hue = 0 519 | svrect.sat = 0 520 | svrect.val = 0 521 | 522 | svrect:stored_property'hue' 523 | svrect:stored_property'sat' 524 | svrect:stored_property'val' 525 | 526 | svrect:track_changes'hue' 527 | svrect:track_changes'sat' 528 | svrect:track_changes'val' 529 | 530 | function svrect:override_set_hue(inherited, hue) 531 | if inherited(self, hue % 360) then 532 | self:fire'color_changed' 533 | self:invalidate() 534 | end 535 | end 536 | 537 | function svrect:override_set_sat(inherited, sat) 538 | if inherited(self, clamp(sat, 0, 1)) then 539 | self:fire'color_changed' 540 | self:invalidate() 541 | end 542 | end 543 | 544 | function svrect:override_set_val(inherited, val) 545 | if inherited(self, clamp(val, 0, 1)) then 546 | self:fire'color_changed' 547 | self:invalidate() 548 | end 549 | end 550 | 551 | function svrect:hsv() 552 | return self.hue, self.sat, self.val 553 | end 554 | 555 | function svrect:rgb() 556 | return color.convert('rgb', 'hsv', self:hsv()) 557 | end 558 | 559 | function svrect:get_a() return self.sat end 560 | function svrect:set_a(a) self.sat = a end 561 | function svrect:get_b() return 1-self.val end 562 | function svrect:set_b(b) self.val = 1-b end 563 | function svrect:a_range() return 0, 1 end 564 | function svrect:b_range() return 0, 1 end 565 | 566 | svrect:init_ignore{hue=1, sat=1, val=1} 567 | 568 | function svrect:after_init(t) 569 | self._hue = t.hue 570 | self._sat = t.sat 571 | self._val = t.val 572 | end 573 | 574 | --view 575 | 576 | function svrect:draw_colors(cr) 577 | cr:save() 578 | local g1 = cairo.linear_gradient(0, 0, 0, self.ch) 579 | g1:add_color_stop(0, 1, 1, 1, 1) 580 | g1:add_color_stop(1, 0, 0, 0, 1) 581 | local g2 = cairo.linear_gradient(0, 0, self.cw, 0) 582 | local r, g, b = color.convert('rgb', 'hsl', self.hue, 1, .5) 583 | g2:add_color_stop(0, r, g, b, 0) 584 | g2:add_color_stop(1, r, g, b, 1) 585 | cr:operator'over' 586 | cr:new_path() 587 | cr:rectangle(0, 0, self.cw, self.ch) 588 | cr:source(g1) 589 | cr:fill_preserve() 590 | cr:operator'multiply' 591 | cr:source(g2) 592 | cr:fill() 593 | cr:rgb(0, 0, 0) --clear source 594 | g1:free() 595 | g2:free() 596 | cr:restore() 597 | end 598 | 599 | function svrect:pointer_cross_rgb(x, y) 600 | local hue = self.hue + 180 601 | local val = self.val > 0.5 and 0 or 1 602 | local sat = 1 - self.sat 603 | return color.convert('rgb', 'hsv', hue, sat, val) 604 | end 605 | 606 | function svrect:before_draw_content(cr) 607 | self:draw_colors(cr) 608 | end 609 | 610 | --saturation / luminance triangle -------------------------------------------- 611 | 612 | local sltr = slrect:subclass'sat_lum_triangle' 613 | ui.sat_lum_triangle = sltr 614 | 615 | function sltr:override_set_angle(inherited, angle) 616 | if inherited(self, angle % 360) then 617 | self:invalidate() 618 | end 619 | end 620 | 621 | function sltr:get_triangle_radius() 622 | return math.min(self.cw, self.ch) / 2 623 | end 624 | 625 | function sltr:triangle_points() 626 | local r = self.triangle_radius 627 | local a = math.rad(self.hue) 628 | local third = 2/3 * math.pi 629 | local x1 = math.cos(a + 0 * third) * r 630 | local y1 = -math.sin(a + 0 * third) * r 631 | local x2 = math.cos(a + 1 * third) * r 632 | local y2 = -math.sin(a + 1 * third) * r 633 | local x3 = math.cos(a + 2 * third) * r 634 | local y3 = -math.sin(a + 2 * third) * r 635 | return x1, y1, x2, y2, x3, y3 636 | end 637 | 638 | function sltr:xy(a, b) 639 | local s, l = a, 1-b 640 | local hx, hy, sx, sy, vx, vy = self:triangle_points() 641 | local mx = (sx + vx) / 2 642 | local my = (sy + vy) / 2 643 | local a = (1 - 2 * math.abs(l - .5)) * s 644 | local x = self.cx + sx + (vx - sx) * l + (hx - mx) * a 645 | local y = self.cy + sy + (vy - sy) * l + (hy - my) * a 646 | return x, y 647 | end 648 | 649 | function sltr:ab(x, y) 650 | local r = self.triangle_radius 651 | x = x - r 652 | y = y - r 653 | local hx, hy, sx, sy, vx, vy = self:triangle_points() 654 | local bx = (sx + vx) / 2 655 | local by = (sy + vy) / 2 656 | local l = line_hit(x, y, sx, sy, vx, vy) 657 | local t = line_hit(x, y, bx, by, hx, hy) 658 | local s = clamp(t / (2 * (l <= 0.5 and l or (1 - l))), 0, 1) 659 | return s, 1-l 660 | end 661 | 662 | function sltr:draw_colors(cr) 663 | local r = self.triangle_radius 664 | local hx, hy, sx, sy, vx, vy = self:triangle_points() 665 | 666 | cr:save() 667 | 668 | cr:translate(r, r) 669 | 670 | cr:new_path() 671 | cr:move_to(hx, hy) 672 | cr:line_to(sx, sy) 673 | cr:line_to(vx, vy) 674 | cr:close_path() 675 | cr:clip() 676 | 677 | --start from a black triangle 678 | cr:rgba(0, 0, 0, 1) 679 | cr:rectangle(-r, -r, 2*r, 2*r) 680 | cr:operator'over' 681 | cr:fill() 682 | 683 | --hsl(hue, 1, 1) to transparent gradient 684 | local g1 = cairo.linear_gradient(hx, hy, (sx + vx) / 2, (sy + vy) / 2) 685 | local R, G, B = color.convert('rgb', 'hsl', self.hue, 1, .5) 686 | g1:add_color_stop(0, R, G, B, 1) 687 | g1:add_color_stop(1, R, G, B, 0) 688 | cr:operator'over' 689 | cr:source(g1) 690 | cr:rectangle(-r, -r, 2*r, 2*r) 691 | cr:fill() 692 | 693 | --white to transparent gradient 694 | local g2 = cairo.linear_gradient(vx, vy, (hx + sx) / 2, (hy + sy) / 2) 695 | g2:add_color_stop(0, 1, 1, 1, 1) 696 | g2:add_color_stop(1, 1, 1, 1, 0) 697 | cr:operator'lighten' 698 | cr:source(g2) 699 | cr:rectangle(-r, -r, 2*r, 2*r) 700 | cr:fill() 701 | 702 | cr:rgb(0, 0, 0) --release source 703 | g1:free() 704 | g2:free() 705 | 706 | cr:restore() 707 | end 708 | 709 | --color picker --------------------------------------------------------------- 710 | 711 | local picker = ui.layer:subclass'colorpicker' 712 | ui.colorpicker = picker 713 | picker.iswidget = true 714 | 715 | --model 716 | 717 | picker._mode = 'HSL' 718 | 719 | function picker:get_mode() 720 | return self._mode 721 | end 722 | 723 | function picker:set_mode(mode) 724 | self._mode = mode 725 | if self:isinstance() then 726 | local hsl = mode == 'HSL' 727 | self.rectangle = hsl and self.sat_lum_rectangle or self.sat_val_rectangle 728 | 729 | self.sat_lum_rectangle.visible = hsl 730 | self.lum_label.visible = hsl 731 | self.lum_slider.visible = hsl 732 | 733 | self.sat_val_rectangle.visible = not hsl 734 | self.val_label.visible = not hsl 735 | self.val_slider.visible = not hsl 736 | 737 | if hsl then 738 | local h, s, l = color.convert('hsl', 'hsv', self.sat_val_rectangle:hsv()) 739 | self.sat_lum_rectangle.sat = s 740 | self.sat_lum_rectangle.lum = l 741 | else 742 | local h, s, v = color.convert('hsv', 'hsl', self.sat_lum_rectangle:hsl()) 743 | self.sat_val_rectangle.sat = s 744 | self.sat_val_rectangle.val = v 745 | end 746 | 747 | self.mode_button.selected = mode 748 | end 749 | end 750 | 751 | --view 752 | 753 | picker.hue_bar_class = hue_bar 754 | picker.sat_lum_rectangle_class = slrect 755 | picker.sat_val_rectangle_class = svrect 756 | picker.mode_button_class = ui.choicebutton 757 | 758 | function picker:create_hue_bar() 759 | return self.hue_bar_class(self.ui, { 760 | parent = self, 761 | iswidget = false, 762 | picker = self, 763 | hue_changed = function(_, hue) 764 | self.hue_slider.position = hue 765 | end, 766 | }, self.hue_bar) 767 | end 768 | 769 | function picker:create_sat_lum_rectangle() 770 | return self.sat_lum_rectangle_class(self.ui, { 771 | parent = self, 772 | iswidget = false, 773 | picker = self, 774 | visible = false, 775 | sat_changed = function(_, sat) 776 | self.sat_slider.position = sat 777 | end, 778 | lum_changed = function(_, lum) 779 | self.lum_slider.position = lum 780 | end, 781 | color_changed = function() 782 | self:sync_editboxes() 783 | end, 784 | }, self.sat_lum_rectangle) 785 | end 786 | 787 | function picker:create_sat_val_rectangle() 788 | return self.sat_val_rectangle_class(self.ui, { 789 | parent = self, 790 | iswidget = false, 791 | picker = self, 792 | visible = false, 793 | sat_changed = function(_, sat) 794 | self.sat_slider.position = sat 795 | end, 796 | val_changed = function(_, val) 797 | self.val_slider.position = val 798 | end, 799 | color_changed = function() 800 | self:sync_editboxes() 801 | end, 802 | }, self.sat_val_rectangle) 803 | end 804 | 805 | function picker:create_mode_button() 806 | return self.mode_button_class(self.ui, { 807 | parent = self, 808 | iswidget = false, 809 | picker = self, 810 | values = {'HSL', 'HSV'}, 811 | value_selected = function(_, mode) 812 | self.mode = mode 813 | end, 814 | button = {h = 17, font_size = 11}, 815 | button_corner_radius = 5, 816 | w = 70, 817 | }, self.mode_button) 818 | end 819 | 820 | function picker:create_rgb_editbox() 821 | return self.ui:editbox{ 822 | parent = self, 823 | iswidget = false, 824 | picker = self, 825 | } 826 | end 827 | 828 | function picker:sync_editboxes() 829 | if not self.window.cr then return end 830 | local sr = self.rectangle 831 | local re = self.rgb_editbox 832 | local xe = self.hex_editbox 833 | local r, g, b = sr:rgb() 834 | re.text = string.format( 835 | '%d, %d, %d', 836 | r * 255, g * 255, b * 255) 837 | xe.text = color.format('#', 'rgb', r, g, b) 838 | end 839 | 840 | function picker:after_sync() 841 | 842 | local hb = self.hue_bar 843 | local sr = self.rectangle 844 | local mb = self.mode_button 845 | local re = self.rgb_editbox 846 | local xe = self.hex_editbox 847 | local rl = self.rgb_label 848 | local xl = self.hex_label 849 | local hl = self.hue_label 850 | local hs = self.hue_slider 851 | local sl = self.sat_label 852 | local ss = self.sat_slider 853 | local ll = self.lum_label 854 | local ls = self.lum_slider 855 | local vl = self.val_label 856 | local vs = self.val_slider 857 | 858 | local h = 1 859 | local w = self.cw - h - 10 860 | hb.x = w + 10 + 8 861 | hb.w = h 862 | hb.h = self.ch 863 | 864 | local x2 = self.cw - self.hue_bar.w 865 | local w = math.min(self.ch, x2) 866 | local dw = math.max(1, x2 - self.ch) 867 | hb.w = hb.w + dw 868 | hb.x = hb.x - dw 869 | sr.w = w 870 | sr.h = w 871 | 872 | sr.hue = self.hue_bar.hue 873 | 874 | mb.x = 400 875 | 876 | local sx, sy = 14, 4 877 | local x1 = self.cw + sx + sx 878 | local w1 = 30 879 | local h = 30 880 | local x2 = x1 + sx + w1 881 | local w2 = 100 882 | local y = 0 883 | 884 | rl.x = x1 885 | rl.y = y 886 | rl.w = w1 887 | rl.h = h 888 | re.x = x2 889 | re.y = y 890 | re.w = w2 891 | 892 | y = y + h + sy 893 | 894 | xl.x = x1 895 | xl.y = y 896 | xl.w = w1 897 | xl.h = h 898 | xe.x = x2 899 | xe.y = y 900 | xe.w = w2 901 | 902 | y = y + h + sy + 20 903 | 904 | hl.x = x1 905 | hl.y = y 906 | hl.w = w1 907 | hl.h = h 908 | hs.x = x2 909 | hs.y = y 910 | hs.w = w2 911 | hs.h = h 912 | 913 | y = y + h + sy 914 | 915 | sl.x = x1 916 | sl.y = y 917 | sl.w = w1 918 | sl.h = h 919 | ss.x = x2 920 | ss.y = y 921 | ss.w = w2 922 | ss.h = h 923 | 924 | y = y + h + sy 925 | 926 | ll.x = x1 927 | ll.y = y 928 | ll.w = w1 929 | ll.h = h 930 | ls.x = x2 931 | ls.y = y 932 | ls.w = w2 933 | ls.h = h 934 | 935 | vl.x = x1 936 | vl.y = y 937 | vl.w = w1 938 | vl.h = h 939 | vs.x = x2 940 | vs.y = y 941 | vs.w = w2 942 | vs.h = h 943 | end 944 | 945 | --init 946 | 947 | picker:init_ignore{mode=1} 948 | 949 | function picker:after_init(t) 950 | 951 | self.hue_bar = self:create_hue_bar() 952 | 953 | self.sat_lum_rectangle = self:create_sat_lum_rectangle() 954 | self.sat_val_rectangle = self:create_sat_val_rectangle() 955 | 956 | self.mode_button = self:create_mode_button() 957 | 958 | self.rgb_editbox = self:create_rgb_editbox() 959 | self.hex_editbox = self:create_rgb_editbox() 960 | 961 | self.rgb_label = self.ui:layer{text = 'RGB:', parent = self, text_align_x = 'left'} 962 | self.hex_label = self.ui:layer{text = 'HEX:', parent = self, text_align_x = 'left'} 963 | self.hue_label = self.ui:layer{text = 'Hue:', parent = self, text_align_x = 'left'} 964 | self.sat_label = self.ui:layer{text = 'Sat:', parent = self, text_align_x = 'left'} 965 | self.lum_label = self.ui:layer{text = 'Lum:', parent = self, text_align_x = 'left'} 966 | self.val_label = self.ui:layer{text = 'Val:', parent = self, text_align_x = 'left'} 967 | 968 | self.hue_slider = self.ui:slider{ 969 | parent = self, 970 | size = 360, 971 | step = 1/4, 972 | position = self.hue_bar.hue, 973 | position_changed = function(slider, pos) 974 | self.hue_bar.hue = pos 975 | end, 976 | } 977 | self.sat_slider = self.ui:slider{ 978 | parent = self, 979 | size = 1, 980 | step = 0.001, 981 | position = self.sat_lum_rectangle.sat, 982 | position_changed = function(slider, pos) 983 | self.rectangle.sat = pos 984 | end, 985 | } 986 | self.lum_slider = self.ui:slider{ 987 | parent = self, 988 | size = 1, 989 | step = 0.001, 990 | position = self.sat_lum_rectangle.lum, 991 | position_changed = function(slider, pos) 992 | self.sat_lum_rectangle.lum = pos 993 | end, 994 | } 995 | self.val_slider = self.ui:slider{ 996 | parent = self, 997 | size = 1, 998 | step = 0.001, 999 | position = self.sat_val_rectangle.val, 1000 | position_changed = function(slider, pos) 1001 | self.sat_val_rectangle.val = pos 1002 | end, 1003 | } 1004 | 1005 | self.mode = t.mode 1006 | end 1007 | 1008 | --demo ----------------------------------------------------------------------- 1009 | 1010 | if not ... then require('ui0_demo')(function(ui, win) 1011 | 1012 | win.view.background_color = '#222' 1013 | 1014 | local cp = ui:colorpicker{ 1015 | x = 20, y = 20, 1016 | w = 220, h = 200, 1017 | parent = win, 1018 | hue_bar = {hue = 60, tooltip = 'Hue bar'}, 1019 | sat_lum_rectangle = {sat = .7, lum = .3}, --, tooltip = 'Saturation x Luminance square'}, 1020 | sat_val_rectangle = {sat = .7, val = .3}, --, tooltip = 'Saturation x Value square'}, 1021 | --mode = 'HSV', 1022 | --sat_lum_rectangle_class = sltr, 1023 | } 1024 | 1025 | end) end 1026 | 1027 | -------------------------------------------------------------------------------- /ui0_demo.lua: -------------------------------------------------------------------------------- 1 | --go @ luajit -jp=2fi1m1 * 2 | 3 | local ui = require'ui0'() 4 | local Q = require'utf8quot' 5 | local time = require'time' 6 | local glue = require'glue' 7 | 8 | local win = ui:window{ 9 | x = 600, y = 100, cw = 1200, ch = 800, 10 | visible = false, autoquit=true, edgesnapping=false, 11 | --frame=false, transparent=true, 12 | view = { 13 | layout = 'grid', 14 | grid_wrap = 5, 15 | grid_flow = 'y', 16 | padding = 20, 17 | }, 18 | } 19 | function win:keyup(key) if key == 'esc' then self:close() end end 20 | 21 | ui.maxfps = 1/0 22 | 23 | local function fps_function() 24 | local count_per_sec = 2 25 | local frame_count, last_frame_count, last_time = 0, 0 26 | return function() 27 | last_time = last_time or time.clock() 28 | frame_count = frame_count + 1 29 | local time = time.clock() 30 | if time - last_time > 1 / count_per_sec then 31 | last_frame_count, frame_count = frame_count, 0 32 | last_time = time 33 | end 34 | return last_frame_count * count_per_sec 35 | end 36 | end 37 | 38 | local fps = fps_function() 39 | 40 | win.native_window:on('repaint', function(self) 41 | self:title(string.format('%d fps', fps())) 42 | end) 43 | 44 | --keep showing fps in the titlebar every second. 45 | ui:runevery(1, function() 46 | if win.dead then 47 | ui:quit() 48 | else 49 | win:invalidate() 50 | end 51 | end) 52 | 53 | if ... == 'ui0_demo' and not DEMO then --loaded via require() 54 | return function(test) 55 | test(ui, win) 56 | win:show() 57 | ui:run() 58 | ui:free() 59 | end 60 | end 61 | 62 | local function test_window_layer() 63 | ui:style('window_view :hot', { 64 | background_color = '#080808', 65 | transition_background_color = true, 66 | transition_duration = 0.1, 67 | }) 68 | end 69 | 70 | local function test_layers() 71 | 72 | ui:style('window_view', { 73 | background_color = '#fff', 74 | }) 75 | 76 | ui:style('layer1', { 77 | transition_duration = 1, 78 | transition_background_colors = true, 79 | transition_shadow_blur = true, 80 | transition_rotation = true, 81 | }) 82 | 83 | ui:style('layer1 :hot', { 84 | --border_color_left = '#fff', 85 | background_colors = {'#0f0', .5, '#f0f'}, 86 | transition_background_colors = true, 87 | transition_duration_background_colors = 1, 88 | transition_duration = 1, 89 | --transition_ease = 'quad out', 90 | shadow_blur = 40, 91 | transition_shadow_blur = true, 92 | transition_rotation = true, 93 | rotation = 30, 94 | }) 95 | 96 | local layer1 = ui:layer{ 97 | x = 50, 98 | y = 50, 99 | w = 500, 100 | h = 200, 101 | tags = 'layer1', 102 | parent = win, 103 | 104 | --clip_content = true, 105 | --clip_content = false, 106 | clip_content = 'background', 107 | 108 | border_width = 10, 109 | border_color = '#fff2', 110 | 111 | border_color_left = '#f008', 112 | border_color_right = '#ff08', 113 | border_color_top = '#0ff8', 114 | border_color_bottom = '#f0f8', 115 | 116 | --border_width_left = 100, 117 | --border_width_right = 40, 118 | --border_width_top = 10, 119 | --border_width_bottom = 100, 120 | 121 | border_offset = -1, 122 | 123 | corner_radius = 10, 124 | corner_radius_top_left = 10, 125 | corner_radius_top_right = 100, 126 | corner_radius_bottom_right = 50, 127 | corner_radius_bottom_left = 10, 128 | corner_radius_kappa = 1, 129 | 130 | --background_type = 'color', 131 | background_color = '#00f', 132 | 133 | --background_type = 'gradient', 134 | background_type = 'radial_gradient', 135 | background_colors = {'#f00', 1, '#00f'}, 136 | 137 | --background_type = 'image', 138 | background_image = 'media/jpeg/autumn-wallpaper.jpg', 139 | 140 | background_clip_border_offset = 0, 141 | 142 | --linear gradients 143 | background_x1 = 0, 144 | background_y1 = 0, 145 | background_x2 = 0, 146 | background_y2 = 100, 147 | 148 | --radial gradients 149 | background_cx1 = 250, 150 | background_cy1 = 100, 151 | background_r1 = 0, 152 | background_cx2 = 250, 153 | background_cy2 = 100, 154 | background_r2 = 100, 155 | 156 | --background_scale_cx = 150, 157 | --background_x = -800, 158 | --background_y = -800, 159 | background_scale = 1, 160 | background_rotation_cx = 250, 161 | background_rotation_cy = 100, 162 | background_rotation = 10, 163 | 164 | --padding = 20, 165 | --padding_left = 10, 166 | --padding_right = 10, 167 | --padding_top = 10, 168 | --padding_bottom = 10, 169 | 170 | rotation = 0, 171 | rotation_cx = 250, 172 | rotation_cy = 100, 173 | scale = 1.2, 174 | scale_cx = 50, 175 | scale_cy = 50, 176 | 177 | shadow_color = '#f00', 178 | shadow_blur = 1, 179 | shadow_x = 15, 180 | shadow_y = 15, 181 | } 182 | 183 | local layer2 = ui:layer{ 184 | visible = true, 185 | tags = 'layer2', 186 | parent = layer1, 187 | clip_content = false, 188 | x = 10, 189 | y = 10, 190 | w = 200, 191 | h = 200, 192 | border_color = '#0ff', 193 | --border_width = 5, 194 | background_color = '#ff0', 195 | --rotation = 0, 196 | --padding = 20, 197 | } 198 | 199 | --function layer1:draw_border() end 200 | 201 | function layer1:after_draw_content() 202 | do return end 203 | local dr = self.window.dr 204 | local cr = dr.cr 205 | local ox, oy = self:from_origin(0, 0) 206 | cr:translate(ox, oy) 207 | self:border_path(-1) 208 | self:border_path(1) 209 | cr:translate(-ox, -oy) 210 | local rule = cr:fill_rule() 211 | cr:fill_rule'even_odd' 212 | local hit = cr:in_fill(self.mouse_x, self.mouse_y) 213 | print(hit) 214 | dr:fill('#fff', 5) 215 | cr:fill_rule(rule) 216 | end 217 | 218 | ui:style('xlayer :hot', { 219 | background_color = '#0ff', 220 | border_color_left = '#ff0', 221 | transition_duration = 1, 222 | transition_border_color_left = true, 223 | }) 224 | 225 | end 226 | 227 | local function test_css() 228 | 229 | ui:style('*', { 230 | custom_all = 11, 231 | }) 232 | 233 | ui:style('button', { 234 | custom_field = 42, 235 | }) 236 | 237 | ui:style('button b1', { 238 | custom_and = 13, 239 | }) 240 | 241 | ui:style('b1', { 242 | custom_and = 16, --comes later: overwrite (no specificity) 243 | }) 244 | 245 | ui:style('button', { 246 | custom_and = 22, --comes later: overwrite (no specificity) 247 | }) 248 | 249 | ui:style('p1 > p2 > b1', { 250 | custom_parent = 54, --comes later: overwrite (no specificity) 251 | }) 252 | 253 | 254 | --ui:style('*', {transition_speed = 1/0}) 255 | --ui:style('*', {font_name = 'Roboto Condensed', font_weigt = 'bold', font_size = 24}) 256 | --b1:update_styles() 257 | --b2:update_styles() 258 | 259 | local p1 = ui:element{name = 'p1', tags = 'p1'} 260 | local p2 = ui:element{name = 'p2', tags = 'p2', parent = p1} 261 | 262 | local b1 = ui:button{parent = p2, name = 'b1', tags = 'b1', x = 10, y = 10, w = 100, h = 26} 263 | local b2 = ui:button{parent = p2, name = 'b2', tags = 'b2', x = 20, y = 20, w = 100, h = 26} 264 | local sel = ui:selector('p1 > p2 > b1') 265 | assert(sel:selects(b1) == true) 266 | print('b1.custom_all', b1.custom_all) 267 | print('b2.custom_all', b2.custom_all) 268 | print('b1.custom_field', b1.custom_field) 269 | print('b1.custom_and', b1.custom_and) 270 | print('b2.custom_and', b2.custom_and) 271 | --print('b2.custom_and', b2.custom_and) 272 | --ui:style('button', {h = 26}) 273 | 274 | local b1 = ui:button{parent = win, name = 'b1', tags = 'b1', text = 'B1', 275 | x = 10, y = 10, w = 100, h = 26} 276 | local b2 = ui:button{parent = win, name = 'b2', tags = 'b2', text = 'B2', 277 | x = 20, y = 50, w = 100, h = 26} 278 | 279 | b1.z_order = 2 280 | end 281 | 282 | local function test_drag() 283 | 284 | --win.native_window:show() 285 | --win.native_window:frame_rect(nil, 100, nil, 400) 286 | 287 | --local win = app:window{x = 840, y = 500, w = 900, h = 400, visible = false} 288 | --local win = ui:window{native_window = win} 289 | 290 | ui:style('test', { 291 | border_width = 10, 292 | --border_color = '#333', 293 | }) 294 | 295 | ui:style('test :active', { 296 | border_color = '#fff', 297 | }) 298 | 299 | ui:style('test :hot', { 300 | background_color = '#ff0', 301 | }) 302 | 303 | ui:style('test :dragging', { 304 | background_color = '#00f', 305 | }) 306 | 307 | ui:style('test :dropping', { 308 | background_color = '#f0f', 309 | }) 310 | 311 | ui:style('test :drag_source', { 312 | border_color = '#0ff', 313 | }) 314 | 315 | ui:style('test :drop_target', { 316 | border_color = '#ff0', 317 | }) 318 | 319 | ui:style('test :drag_layer', { 320 | border_width = 20, 321 | border_color = '#ccc', 322 | }) 323 | 324 | local layer1 = ui:layer{ 325 | tags = 'layer1 test', 326 | x = 50, y = 50, w = 200, h = 200, 327 | parent = win, 328 | z_order = 1, 329 | background_color = '#f66', 330 | clip_content = false, 331 | rotation_cx = 100, 332 | rotation_cy = 100, 333 | rotation = 80, 334 | } 335 | 336 | local layer2 = ui:layer{ 337 | tags = 'layer2 test', 338 | x = 300, y = 50, w = 200, h = 200, 339 | parent = win, 340 | z_order = 0, 341 | background_color = '#f00', 342 | rotation = 10, 343 | } 344 | 345 | local layer = ui:layer{ 346 | tags = 'drag_layer test', 347 | x = 50, y = 0, w = 100, h = 100, 348 | parent = layer1, 349 | z_parent = win, 350 | z_order = 20, 351 | } 352 | 353 | for _,layer in ipairs{layer1, layer2, layer} do 354 | 355 | layer.drag_threshold = 0 356 | layer.draggable = true 357 | 358 | function layer:start_drag(button, mx, my, area) 359 | print('start_drag ', button, mx, my, area) 360 | return self 361 | end 362 | 363 | function layer:drop(widget, mx, my, area) 364 | print('drop ', widget.id, mx, my, area) 365 | local x, y = self:to_content(widget:to_other(self, 0, 0)) 366 | --widget.parent = self 367 | widget.x = x 368 | widget.y = y 369 | end 370 | 371 | function layer:accept_drop_widget(drop_target, mx, my, area) 372 | local accept = drop_target ~= self 373 | --print('accept_drop_widget', self.id, drop_target.id, mx, my, area, '->', accept) 374 | return accept 375 | end 376 | 377 | function layer:enter_drop_target(drop_target, mx, my, area) 378 | print('enter_drop_target', self.id, drop_target.id, mx, my, area) 379 | end 380 | 381 | function layer:leave_drop_target(drop_target) 382 | print('leave_drop_target', self.id, drop_target.id) 383 | end 384 | 385 | function layer:accept_drag_widget(drag_widget, mx, my, area) 386 | local accept = drag_widget ~= self 387 | --print('accepts_drag_widget', drag_object.id, mx, my, area, '->', accept) 388 | return accept 389 | end 390 | 391 | function layer:after_drag(mx, my) 392 | print('drag ', self.id, mx, my) 393 | --local mx, my = self:to_window(mx, my) 394 | --self.x = mx 395 | --self.y = my 396 | --self:invalidate() 397 | end 398 | 399 | --function layer1:drag(button, mx, my, area) end --stub 400 | --function layer1:drop(drag_object, mx, my, area) end --stub 401 | --function layer1:cancel(drag_object) end --stub 402 | 403 | function layer:mousedown(mx, my, area) 404 | --print('mousedown', time.clock(), self.id, button, mx, my, area) 405 | self.active = true 406 | end 407 | 408 | function layer:mouseup(mx, my, area) 409 | self.active = false 410 | end 411 | --function layer:mousemove(...) print('mousemove', time.clock(), self.id, ...) end 412 | --function layer:mouseup(...) print('mouseup', time.clock(), self.id, ...) end 413 | 414 | end 415 | 416 | win.native_window:show() 417 | end 418 | 419 | local function test_text() 420 | 421 | local layer = ui:layer{ 422 | x = 100, y = 100, 423 | w = 200, h = 200, 424 | text = 'gftjim;\nqTv\nxyZ', 425 | text_color = '#fff', 426 | font_size = 36, 427 | border_width = 1, 428 | border_color = '#fff', 429 | parent = win, 430 | } 431 | 432 | function layer:after_draw_content() 433 | local cr = self.window.cr 434 | cr:rgb(1, 1, 1) 435 | cr:line_width(1) 436 | cr:rectangle(self:text_bbox()) 437 | cr:stroke() 438 | end 439 | 440 | end 441 | 442 | local function test_flexbox_inside_null() 443 | 444 | local parent = ui:layer{ 445 | parent = win, 446 | x = 100, y = 100, 447 | w = 200, h = 200, 448 | border_width = 1, 449 | border_color = '#333', 450 | } 451 | 452 | local textwrap = ui:layer{ 453 | parent = parent, 454 | text = 'Hello World! Hello World! Hello World! Hello World! \nxxxxxxxxxxx\nxxxxxxxxx\nxxxxx\nxxxxxxxxxxxxx', 455 | w = 100, 456 | h = 100, 457 | layout = 'flexbox', 458 | border_width = 10, 459 | padding = 10, 460 | } 461 | end 462 | 463 | local function test_flexbox() 464 | 465 | local flex = ui:layer{ 466 | parent = win, 467 | layout = 'flexbox', 468 | flex_wrap = true, 469 | flex_flow = 'y', 470 | item_align_y = 'center', 471 | align_items_y = 'start', 472 | border_width = 20, 473 | padding = 20, 474 | border_color = '#333', 475 | x = 40, y = 40, 476 | min_cw = win.cw - 120, 477 | min_ch = win.ch - 120, 478 | xx = 0, 479 | style = { 480 | transition_duration = 1, 481 | transition_times = 1/0, 482 | xx = 100, 483 | transition_xx = true, 484 | }, 485 | } 486 | 487 | flex:inherit() 488 | 489 | for i = 1, 50 do 490 | local r = math.random(10) 491 | local b = ui:layer{ 492 | parent = flex, 493 | layout = 'textbox', 494 | border_width = 1, 495 | min_cw = r * 12, 496 | min_ch = r * 6, 497 | break_after = i == 50, 498 | break_before = i == 50, 499 | flex_fr = r, 500 | --font_size = 10 + i * 3, 501 | } 502 | 503 | b:inherit() 504 | end 505 | 506 | function win:client_resized() 507 | flex.min_cw = win.cw - 120 508 | flex.min_ch = win.ch - 120 509 | self:invalidate() 510 | end 511 | 512 | end 513 | 514 | local function test_flexbox_baseline() 515 | 516 | win.view.layout = 'flexbox' 517 | 518 | local flex = ui:layer{ 519 | parent = win, 520 | layout = 'flexbox', 521 | flex_wrap = true, 522 | item_align_y = 'baseline', 523 | align_items_y = 'start', 524 | border_width = 20, 525 | padding = 20, 526 | border_color = '#333', 527 | } 528 | 529 | local c = ui.layer:subclass(nil, { 530 | border_width = 1, 531 | layout = 'textbox', 532 | text_selectable = true, 533 | text_editable = true, 534 | focusable = true, 535 | }) 536 | c(ui, {parent = flex, text = 'Hey there', font_size = 40}) 537 | c(ui, {parent = flex, text = 'Hey there', font_size = 16}) 538 | 539 | end 540 | 541 | local function test_grid_layout() 542 | 543 | local grid = ui:layer{ 544 | parent = win, 545 | 546 | layout = 'grid', 547 | grid_wrap = 5, 548 | grid_flow = 'yrb', 549 | --grid_cols = {10, 1, 1, 5, 10}, 550 | grid_col_gap = 10, 551 | grid_row_gap = 5, 552 | align_items_x = 'space_around', 553 | align_items_y = 'space_around', 554 | 555 | border_width = 20, 556 | padding = 20, 557 | border_color = '#333', 558 | x = 40, y = 40, 559 | min_cw = win.cw - 120, 560 | min_ch = win.ch - 120, 561 | } 562 | 563 | for i = 1, 10 do 564 | local r = math.random(10) 565 | local b = ui:layer{ 566 | parent = grid, 567 | layout = 'textbox', 568 | border_width = 1, 569 | text = i..' '..('xx'):rep(r), 570 | 571 | grid_col_span = i % 2 + 1, 572 | grid_row_span = 2 - i % 2, 573 | } 574 | end 575 | 576 | function win:client_resized() 577 | grid.min_cw = win.cw - 120 578 | grid.min_ch = win.ch - 120 579 | self:invalidate() 580 | end 581 | 582 | end 583 | 584 | local function test_widgets_flex() 585 | 586 | win.view.layout = 'grid' 587 | win.view.grid_flow = 'y' 588 | win.view.padding = 40 589 | win.view.grid_wrap = 3 590 | win.view.grid_col_gap = 20 591 | win.view.grid_row_gap = 20 592 | 593 | local s = Q[[ 594 | Lorem ipsum dolor sit amet, quod oblique vivendum ex sed. Impedit nominavi maluisset sea ut.&ps;Utroque apeirian maluisset cum ut. Nihil appellantur at his, fugit noluisse eu vel, mazim mandamus ex quo.&ls;Mei malis eruditi ne. Movet volumus instructior ea nec. Vel cu minimum molestie atomorum, pro iudico facilisi et, sea elitr partiendo at. An has fugit assum accumsan.&ps;Ne mea nobis scaevola partiendo, sit ei accusamus expetendis. Omnium repudiandae intellegebat ad eos, qui ad erant luptatum, nec an wisi atqui adipiscing. Mei ad ludus semper timeam, ei quas phaedrum liberavisse his, dolorum fierent nominavi an nec. Quod summo novum eam et, ullum choro soluta nec ex. Soleat conceptam pro ut, enim audire definiebas ad nec. Vis an equidem torquatos, at erat voluptatibus eam.]] 595 | 596 | ui:button{ 597 | parent = win, 598 | text = 'Imma button', 599 | } 600 | 601 | ui:checkbox{ 602 | parent = win, 603 | label = {text = 'Check me', nowrap = false}, 604 | checked = true, 605 | } 606 | 607 | ui:choicebutton{ 608 | parent = win, 609 | values = { 610 | 'Choose me', 611 | 'No, me!', 612 | {text = 'Me, me, me!', value = 'val3'}, 613 | }, 614 | button = {nowrap = true}, 615 | selected = 'val3', 616 | } 617 | 618 | ui:radio{ 619 | parent = win, 620 | label = {text = 'Radio me'}, 621 | checked = true, 622 | radio_group = 1, 623 | align = 'right', 624 | } 625 | 626 | ui:slider{ 627 | parent = win, 628 | position = 3, size = 10, 629 | step_labels = {Low = 0, Medium = 5, High = 10}, 630 | step = 2, 631 | } 632 | 633 | ui:tablist{ 634 | parent = win, 635 | tabs = { 636 | {title = {text = 'Tab 1-1'}}, 637 | {title = {text = 'Tab 1-2'}}, 638 | }, 639 | } 640 | 641 | ui:tablist{ 642 | parent = win, 643 | tabs = { 644 | {title = {text = 'Tab 2-1'}}, 645 | {title = {text = 'Tab 2-2'}}, 646 | }, 647 | tabs_side = 'bottom', 648 | tab_corner_radius = 6, 649 | tab_slant = 80, 650 | } 651 | 652 | local fl_class = ui.layer:subclass('focusable_layer', { 653 | clip_content = true, 654 | text_align_x = 'left', 655 | text_align_y = 'top', 656 | text = s, 657 | 658 | text_selectable = true, 659 | }) 660 | 661 | fl_class(ui, { 662 | parent = win, 663 | }) 664 | 665 | fl_class(ui, { 666 | parent = win, 667 | focusable = true, 668 | text_editable = true, 669 | --clip_content = false, 670 | }) 671 | 672 | ui:style('focusable_layer :focused', { 673 | border_width = 1, 674 | border_color = '#fff3', 675 | }) 676 | 677 | ui:scrollbox{ 678 | parent = win, 679 | --auto_w = true, 680 | content = { 681 | w = 500, 682 | h = 600, 683 | { 684 | x = 50, 685 | y = 50, 686 | w = 400, 687 | h = 300, 688 | focusable = true, 689 | clip_content = true, 690 | border_width = 1, 691 | border_color = '#fff3', 692 | text_align_x = 'left', 693 | text_align_y = 'top', 694 | text = s, 695 | text_selectable = true, 696 | text_editable = true, 697 | }, 698 | }, 699 | } 700 | 701 | ui:textarea{ 702 | parent = win, 703 | value = s, 704 | } 705 | 706 | --rtl 707 | ui:add_font_file('media/fonts/amiri-regular.ttf', 'Amiri') 708 | local ta = ui:textarea{ 709 | parent = win, 710 | content = { 711 | }, 712 | }.content 713 | ta.font = 'Amiri,22' 714 | ta.padding_right = 1 715 | ta.border_width = 1 716 | ta.border_color = '#333' 717 | ta.wrapped_space = false 718 | ta.line_spacing = .9 719 | ta.text = 'As-salāmu ʿalaykum! ال [( مف )] اتيح Hello Hello Hello Hello World! 123 السَّلَامُ عَلَيْكُمْ' 720 | 721 | --[[ 722 | ui:editbox{ 723 | parent = win, 724 | } 725 | 726 | ]] 727 | 728 | --[[ 729 | local rows = {} 730 | for i = 1,20 do table.insert(rows, {i, i}) end 731 | ui:grid{ 732 | parent = win, 733 | rows = rows, 734 | cols = { 735 | {text = 'col1', w = 150}, 736 | {text = 'col2', w = 150}, 737 | }, 738 | freeze_col = 2, 739 | --multi_select = true, 740 | --cell_select = true, 741 | --cell_class = ui.editbox, 742 | --editable = true, 743 | } 744 | ]] 745 | 746 | --[[ 747 | ui:dropdown{ 748 | parent = win, 749 | picker = {rows = {'Row 1', 'Row 2', 'Row 3'}}, 750 | } 751 | ]] 752 | 753 | end 754 | 755 | local function test_drag_flexbox() 756 | win.view.grid_flow = 'x' 757 | win.view.grid_gap = 10 758 | local function T(t) 759 | return glue.update({ 760 | layout = 'textbox', 761 | min_ch = math.random(200), 762 | mousedown_activate = true, 763 | draggable = true, 764 | border_width = 1, 765 | background_color = '#111', 766 | tags = 'movable', 767 | mouseup = function(self) self.parent = false end, 768 | }, t) 769 | end 770 | local fb1 = ui:layer{ 771 | tags = 'fb fb1', 772 | parent = win, 773 | layout = 'flexbox', 774 | flex_flow = 'x', 775 | item_align_y = 'center', 776 | border_width = 1, 777 | accept_drag_groups = {[1]=true}, 778 | T{drag_group=1, fr=2, text='1 Hello'}, 779 | T{drag_group=1, fr=1, text='2 Hello again'}, 780 | T{drag_group=1, fr=3, text='3 I am another layer'}, 781 | } 782 | local fb2 = ui:layer{ 783 | tags = 'fb fb2', 784 | parent = win, 785 | layout = 'flexbox', 786 | align_items_y = 'center', 787 | flex_flow = 'y', 788 | border_width = 1, 789 | draggable = true, 790 | drag_group = 2, 791 | accept_drag_groups = {[2]=true}, 792 | T{drag_group=2, fr=1, text='4 Hello'}, 793 | T{drag_group=2, fr=5, text='5 Hello again'}, 794 | T{drag_group=2, fr=2, text='6 Hello'}, 795 | T{drag_group=2, fr=3, text='7 Hello again'}, 796 | } 797 | 798 | ui:style('fb :item_moving > movable', { 799 | transition_y = true, 800 | transition_duration = .5, 801 | --background_color = '#222', 802 | }) 803 | 804 | end 805 | 806 | local function test_resize_window() 807 | win.cw = 200 808 | win.ch = 100 809 | test_drag_flexbox() 810 | end 811 | 812 | local function test_flexbox_speed() 813 | win.cw = 400 814 | win.ch = 800 815 | win.view.layout = false 816 | win.view.padding = 0 817 | 818 | local sb = ui.scrollbox{ 819 | parent = win, 820 | w = 400, h = 800, 821 | auto_w = true, 822 | vscrollbar = {autohide_empty = false}, 823 | hscrollbar = {autohide_empty = false}, 824 | content = { 825 | layout = 'flexbox', 826 | flex_flow = 'y', 827 | align_items_y = 'top', 828 | }, 829 | } 830 | for i=1,1000 do 831 | ui.layer{ 832 | parent = sb.content, 833 | --layout = 'textbox', 834 | border_width_bottom = 1, 835 | min_ch = 20, 836 | --text = 'Row ', 837 | text_align_x = 'auto', 838 | }:inherit() 839 | end 840 | 841 | function win:after_sync() 842 | self:invalidate() 843 | end 844 | end 845 | 846 | --test_css() 847 | --test_layers() 848 | --test_drag() 849 | --test_text() 850 | --test_flexbox() 851 | --test_flexbox_baseline() 852 | --test_grid_layout() 853 | --test_widgets_flex() 854 | --test_drag_flexbox() 855 | --test_resize_window() 856 | test_flexbox_speed() 857 | win:show() 858 | ui:run() 859 | ui:free() 860 | -------------------------------------------------------------------------------- /ui0_demo_bundle.bat: -------------------------------------------------------------------------------- 1 | mgit bundle -M ui_tablist -v -z -w -o ../ui_tablist.exe ^ 2 | -a "cairo pixman png z freetype boxblur dasm_x86" ^ 3 | -m "ui* oo glue tuple box2d easing color boxblur amoeba time bundle gfonts freetype* cairo* libjpeg* fs* winapi winapi/* nw* dynasm* dasm* bitmap* path pp* cbframe* media/fonts/gfonts/apache/opensans/OpenSans-Regular.ttf media/fonts/gfonts/metadata.cache" 4 | 5 | -------------------------------------------------------------------------------- /ui0_dropdown.lua: -------------------------------------------------------------------------------- 1 | --go @ luajit -jp=fi1m1 ui_dropdown.lua 2 | io.stdout:setvbuf'no' 3 | io.stderr:setvbuf'no' 4 | 5 | --Drop-down widget. 6 | --Written by Cosmin Apreutesei. Public Domain. 7 | 8 | local ui = require'ui0' 9 | require'ui0_editbox' 10 | require'ui0_popup' 11 | require'ui0_scrollbox' 12 | local glue = require'glue' 13 | 14 | local dropdown = ui.editbox:subclass'dropdown' 15 | ui.dropdown = dropdown 16 | 17 | dropdown.text_editable = false 18 | dropdown.text_selectable = false 19 | 20 | function dropdown:after_init(t) 21 | self.button = self:create_button() 22 | self.popup = self:create_popup() 23 | self.picker = self:create_picker() 24 | end 25 | 26 | --open/close state ----------------------------------------------------------- 27 | 28 | function dropdown:get_isopen() 29 | return self.popup.visible 30 | end 31 | 32 | function dropdown:set_isopen(open) 33 | if open then 34 | self:open() 35 | else 36 | self:close() 37 | end 38 | end 39 | dropdown:track_changes'isopen' 40 | 41 | function dropdown:open() 42 | self:focus(true) 43 | self.popup:show() 44 | end 45 | 46 | function dropdown:close() 47 | if not self.popup.dead then 48 | self.popup:hide() 49 | end 50 | end 51 | 52 | function dropdown:toggle() 53 | self.isopen = not self.isopen 54 | end 55 | 56 | function dropdown:_opened() 57 | self.button.text = self.button.close_text 58 | self.picker:fire'opened' 59 | self:fire'opened' 60 | end 61 | 62 | function dropdown:_closed() 63 | self.button.text = self.button.open_text 64 | self.picker:fire'closed' 65 | self:fire'closed' 66 | end 67 | 68 | --keyboard interaction 69 | 70 | function dropdown:lostfocus() 71 | self:close() 72 | end 73 | 74 | function dropdown:keydown(key) 75 | return self.picker:fire('keydown', key) 76 | end 77 | 78 | function dropdown:keyup(key) 79 | if key == 'esc' and self.isopen then 80 | self:close() 81 | return true 82 | end 83 | return self.picker:fire('keyup', key) 84 | end 85 | 86 | function dropdown:keypress(key) 87 | if key == 'enter' and not self.isopen then 88 | self:open() 89 | return true 90 | end 91 | return self.picker:fire('keypress', key) 92 | end 93 | 94 | --mouse interaction 95 | 96 | dropdown.mousedown_activate = true 97 | 98 | function dropdown:click() 99 | self:toggle() 100 | end 101 | 102 | --open/close button ---------------------------------------------------------- 103 | 104 | local button = ui.layer:subclass'dropdown_button' 105 | dropdown.button_class = button 106 | 107 | function dropdown:create_button() 108 | return self.button_class(self.ui, { 109 | parent = self, 110 | dropdown = self, 111 | iswidget = false, 112 | }, self.button) 113 | end 114 | 115 | button.activable = false 116 | 117 | button.font = 'IonIcons,16' 118 | button.open_text = '\u{f280}' 119 | button.close_text = '\u{f286}' 120 | button.text_color = '#aaa' 121 | button.text = button.open_text 122 | 123 | ui:style('dropdown :hot > dropdown_button', { 124 | text_color = '#fff', 125 | }) 126 | 127 | function dropdown:after_sync_styles() 128 | self.padding_right = math.floor(self.h * .8) 129 | end 130 | 131 | function dropdown:before_sync_layout_children() 132 | local btn = self.button 133 | btn.h = self.ch 134 | btn.x = self.cw 135 | btn.w = self.pw2 136 | end 137 | 138 | --popup window --------------------------------------------------------------- 139 | 140 | local popup = ui.popup:subclass'dropdown_popup' 141 | dropdown.popup_class = popup 142 | 143 | popup.activable = false 144 | 145 | function dropdown:create_popup(ui, t) 146 | return self.popup_class(self.ui, { 147 | w = 200, h = 200, --required; sync'ed layer 148 | parent = self, 149 | visible = false, 150 | dropdown = self, 151 | }, self.popup) 152 | end 153 | 154 | function popup:after_init() 155 | self:frame_rect(0, self.dropdown.h) 156 | end 157 | 158 | function popup:shown() 159 | self.dropdown:_opened() 160 | end 161 | 162 | function popup:hidden() 163 | --TODO: bug: parent window does not repaint synchronously after child is closed. 164 | self.ui:runafter(0, function() 165 | self.dropdown:_closed() 166 | end) 167 | end 168 | 169 | function popup:override_parent_window_mousedown_autohide(inherited, ...) 170 | if self.dropdown.button.hot or self.dropdown.hot then 171 | --prevent autohide to avoid re-opening the popup by the dropdown. 172 | return 173 | end 174 | inherited(self, ...) 175 | end 176 | 177 | --list picker widget --------------------------------------------------------- 178 | 179 | local list = ui.scrollbox:subclass'dropdown_list' 180 | ui.dropdown_list = list 181 | 182 | list.auto_w = true 183 | list.vscrollbar = {autohide_empty = false} 184 | 185 | list.border_color = '#333' 186 | list.padding_left = 1 187 | list.border_width_left = 1 188 | list.border_width_right = 1 189 | list.border_width_bottom = 1 190 | 191 | local item = ui.layer:subclass'dropdown_item' 192 | list.item_class = item 193 | 194 | item.layout = 'textbox' 195 | item.text_align_x = 'auto' 196 | 197 | item.padding_left = 6 198 | 199 | ui:style('dropdown_item :hot', { 200 | background_color = '#222', 201 | }) 202 | 203 | ui:style('dropdown_item :selected', { 204 | background_color = '#226', 205 | }) 206 | 207 | --init 208 | 209 | function list:create_item(i, t) 210 | local value = type(t) == 'string' and i or t.value 211 | local text = type(t) == 'string' and t or t.text or value 212 | local t = type(t) == 'table' and t or nil 213 | local item = self.item_class({ 214 | parent = self.content, 215 | text = text, 216 | index = i, 217 | item_value = value, 218 | picker = self, 219 | dropdown = self.dropdown, 220 | select = self.item_select, 221 | unselect = self.item_unselect, 222 | }, t) 223 | item:inherit() 224 | self.by_value[value] = i 225 | item:on('mousedown', self.item_mousedown) 226 | return item 227 | end 228 | local time = require'time' 229 | function list:set_options(t) 230 | if not t then return end 231 | self.by_value = {} --{value -> item_index} 232 | local t0 = time.clock() 233 | for i,t in ipairs(t) do 234 | self:create_item(i, t) 235 | end 236 | print((time.clock() - t0) * 1000, 'ms to create', #t, 'items') 237 | end 238 | 239 | list:init_ignore{options=1} 240 | 241 | function list:after_init(t) 242 | local ct = self.content 243 | ct.layout = 'flexbox' 244 | ct.flex_flow = 'y' 245 | ct.dropdown = self.dropdown 246 | ct.picker = self 247 | function ct:mouseup() 248 | self.active = false 249 | local item = self.picker.selected_item 250 | if item then 251 | self.dropdown.value = item.item_value 252 | self.dropdown:close() 253 | end 254 | end 255 | function ct:mousemove(mx, my) 256 | local item = self:hit_test_children(mx, my, 'activate') 257 | if item and item.item_value then 258 | item:select() 259 | end 260 | end 261 | self.options = t.options 262 | end 263 | 264 | --sync'ing 265 | 266 | function dropdown:before_sync_layout_children() 267 | if self.picker.w ~= self.w then 268 | self.picker.w = self.w 269 | self.popup:sync() 270 | self.picker.ch = math.min(self.picker.content.h, self.w * 1.5) 271 | self.popup:client_size(self.picker:size()) 272 | self.popup:invalidate() 273 | end 274 | end 275 | 276 | --item selection 277 | 278 | function list.item_select(self) --self is the item! 279 | local sel_item = self.picker.selected_item 280 | if sel_item == self then return end 281 | if sel_item then 282 | sel_item:unselect() 283 | end 284 | self:make_visible() 285 | self:settag(':selected', true) 286 | self.picker.selected_item = self 287 | end 288 | 289 | function list.item_unselect(self) --self is the item! 290 | self.picker.selected_item = false 291 | self:settag(':selected', false) 292 | end 293 | 294 | --mouse interaction 295 | 296 | function list.item_mousedown(self) --self is the item! 297 | self.parent.active = true 298 | end 299 | 300 | --keyboard interaction 301 | 302 | function list:next_page_item(from_item, pages) 303 | local ct = self.content 304 | local last_index = pages > 0 and #ct or 1 305 | local step = pages > 0 and 1 or -1 306 | local h = self.view.ch 307 | local y = 0 308 | local from_index = from_item and from_item.index or last_index 309 | for i = from_index, last_index, step do 310 | y = y + ct[i].h 311 | if y > h then 312 | return ct[i] 313 | end 314 | end 315 | return ct[last_index] 316 | end 317 | 318 | function list:keypress(key) 319 | if key == 'up' or key == 'down' 320 | or key == 'pageup' or key == 'pagedown' 321 | or key == 'home' or key == 'end' 322 | or key == 'enter' 323 | then 324 | local hot_item = self.ui.hot_widget 325 | local item = self.selected_item 326 | or (hot_item and hot_item.picker and hot_item.index and hot_item) 327 | local ct = self.content 328 | if key == 'down' then 329 | if not item then 330 | item = ct[1] 331 | else 332 | item = ct[item.index + 1] 333 | end 334 | elseif key == 'up' then 335 | item = item and ct[item.index - 1] or ct[1] 336 | elseif key:find'page' then 337 | item = self:next_page_item(item, key == 'pagedown' and 1 or -1) 338 | elseif key == 'home' then 339 | item = ct[1] 340 | elseif key == 'end' then 341 | item = ct[#ct] 342 | end 343 | if item then 344 | item:select() 345 | if key == 'enter' or not self.dropdown.isopen then 346 | self.dropdown.value = item.item_value 347 | self.dropdown:close() 348 | end 349 | return true 350 | end 351 | end 352 | end 353 | 354 | --dropdown interface 355 | 356 | function list:pick_value(value) 357 | local index = self.by_value[value] 358 | if not index then return end 359 | local item = self.content[index] 360 | item:select() 361 | return true, index 362 | end 363 | 364 | function list:picked_value_text() 365 | local item = self.selected_item 366 | return item and item.text 367 | end 368 | 369 | function list:opened() 370 | if self.selected_item then 371 | self.selected_item:make_visible() 372 | end 373 | end 374 | 375 | --picker widget -------------------------------------------------------------- 376 | 377 | dropdown.picker_class = list 378 | dropdown.picker_classname = false --by-name override 379 | 380 | function dropdown:create_picker() 381 | 382 | local class = 383 | self.picker and (self.picker.class or self.picker.classname) 384 | or self.picker_class 385 | or self.ui[self.picker_classname] 386 | 387 | local picker = class(self.ui, { 388 | parent = self.popup, 389 | dropdown = self, 390 | }, self.picker) 391 | 392 | return picker 393 | end 394 | 395 | --data binding --------------------------------------------------------------- 396 | 397 | --allow setting and typing values outside of the picker's range. 398 | dropdown.allow_any_value = false 399 | 400 | dropdown:init_ignore{value=1} 401 | 402 | function dropdown:after_init(t) 403 | self.value = t.value 404 | end 405 | 406 | function dropdown:value_changed(val) 407 | self.text = self.picker:picked_value_text() or self:value_text(val) 408 | end 409 | 410 | function dropdown:validate_value(val, old_val) 411 | local picked, picked_val = self.picker:pick_value(val) 412 | if picked then 413 | return picked_val 414 | elseif self.allow_any_value then 415 | return val 416 | else 417 | return old_val 418 | end 419 | end 420 | 421 | --demo ----------------------------------------------------------------------- 422 | 423 | if not ... then require('ui0_demo')(function(ui, win) 424 | 425 | win.x = 500 426 | win.w = 300 427 | win.h = 900 428 | 429 | local dropdown1 = ui:dropdown{ 430 | parent = win, 431 | picker = { 432 | options = { 433 | 'Apples', 'Oranges', 'Bananas', 434 | 'Burgers', 'Cheese', 'Fries', 435 | 'Peppers', 'Onions', 'Olives', 436 | 'Pumpkins', 'Eggplants', 'Cauliflower', 437 | 'Butter', 'Coconut Oil', 'Olive Oil', 'Sunflower Oil', 438 | 'Zucchinis', 'Squash', 439 | 'Lettuce', 'Spinach', 440 | 'I\'m hungry', 441 | } 442 | }, 443 | --picker = {rows = {'Row 1', 'Row 2', 'Row 3', {}}}, 444 | value = 20, 445 | --value = 'Some invalid value', 446 | allow_any_value = true, 447 | } 448 | 449 | local t = {} 450 | for i = 1, 1000 do 451 | t[i] = 'Row' --'Row '..i 452 | end 453 | local dropdown2 = ui:dropdown{ 454 | parent = win, 455 | picker = {options = t}, 456 | value = 2, 457 | } 458 | 459 | function win:after_sync() 460 | self:invalidate() 461 | end 462 | 463 | end) end 464 | -------------------------------------------------------------------------------- /ui0_editbox.lua: -------------------------------------------------------------------------------- 1 | --go @ luajit ui_editbox.lua 2 | 3 | --Edit Box widget based on tr. 4 | --Written by Cosmin Apreutesei. Public Domain. 5 | 6 | local ui = require'ui0' 7 | local tr = require'tr0' 8 | local glue = require'glue' 9 | local box2d = require'box2d' 10 | 11 | local push = table.insert 12 | local pop = table.remove 13 | clamp = glue.clamp 14 | snap = glue.snap 15 | 16 | local editbox = ui.layer:subclass'editbox' 17 | ui.editbox = editbox 18 | editbox.iswidget = true 19 | 20 | --config / behavior 21 | 22 | editbox.focusable = true 23 | editbox.text_selectable = true 24 | editbox.text_editable = true 25 | editbox.nowrap = true 26 | editbox.text_multiline = false 27 | 28 | --config / geometry 29 | 30 | editbox.text_align_x = 'auto' 31 | editbox.align_y = 'center' 32 | editbox.min_ch = 24 33 | editbox.w = 180 34 | editbox.h = 24 35 | 36 | --styles 37 | 38 | editbox.tags = 'standalone' 39 | 40 | ui:style('editbox standalone', { 41 | border_width_bottom = 1, 42 | transition_border_color = true, 43 | transition_duration_border_color = .5, 44 | }) 45 | 46 | ui:style('editbox standalone :focused', { 47 | border_color = '#fff', 48 | }) 49 | 50 | ui:style('editbox standalone :hot', { 51 | border_color = '#fff', 52 | }) 53 | 54 | --animation 55 | 56 | ui:style('editbox standalone, editbox standalone :hot', { 57 | transition_border_color = true, 58 | transition_duration_border_color = .5, 59 | }) 60 | 61 | --cue layer ------------------------------------------------------------------ 62 | 63 | editbox.show_cue_when_focused = false 64 | 65 | ui:style('editbox > cue_layer', { 66 | text_color = '#666', 67 | }) 68 | 69 | function editbox:get_cue() 70 | return self.cue_layer.text 71 | end 72 | function editbox:set_cue(s) 73 | self.cue_layer.text = s 74 | end 75 | 76 | editbox.cue_layer_class = ui.layer 77 | 78 | editbox:init_ignore{cue=1} 79 | 80 | function editbox:create_cue_layer() 81 | local cue_layer = self.cue_layer_class(self.ui, { 82 | tags = 'cue_layer', 83 | parent = self, 84 | editbox = self, 85 | activable = false, 86 | nowrap = true, 87 | }, self.cue_layer) 88 | 89 | function cue_layer:before_sync_layout() 90 | local ed = self.editbox 91 | self.visible = 92 | (not ed.show_cue_when_focused or ed.focused) 93 | and ed.text_len == 0 94 | if self.visible then 95 | self.text_align_x = ed.text_align_x 96 | self.text_align_y = ed.text_align_y 97 | self.w = ed.cw 98 | self.h = ed.ch 99 | end 100 | end 101 | 102 | return cue_layer 103 | end 104 | 105 | function editbox:after_init(t) 106 | self.cue_layer = self:create_cue_layer() 107 | self.cue = t.cue 108 | end 109 | 110 | --password masking ----------------------------------------------------------- 111 | 112 | --Password masking works by drawing fixed-width dots in place of actual 113 | --characters. Because cursor placement and hit-testing must continue 114 | --to work over these markers, we have to translate from "text space" (where 115 | --the original cursor positions are) to "mask space" (where the fixed-width 116 | --visual cursor positons are) in order to draw the cursor and the selection 117 | --rectangles. We also need to translate back to text space for hit-testing. 118 | 119 | editbox.password = false 120 | 121 | function editbox:override_caret_rect(inherited) 122 | local x, y, w, h = inherited(self) 123 | if self.password then 124 | x, y = self:text_to_mask(x, y) 125 | w = self.insert_mode and self:password_char_advance_x() or 1 126 | if self.text_selection.cursor2:rtl() then 127 | x = x - w 128 | end 129 | x = snap(x) 130 | y = snap(y) 131 | end 132 | return x, y, w, h 133 | end 134 | 135 | function editbox:override_draw_selection_rect(inherited, x, y, w, h, cr) 136 | local x1, y1 = self:text_to_mask(x, y) 137 | local x2, y2 = self:text_to_mask(x + w, y + h) 138 | inherited(self, x1, y1, x2-x1, y2-y1, cr) 139 | end 140 | 141 | --compute the text-space to mask-space mappings on each text sync. 142 | function editbox:sync_password_mask() 143 | if not self.text_selection then return end 144 | local segs = self.text_selection.segments 145 | if segs.lines.pw_cursor_is then return end 146 | segs.lines.pw_cursor_is = {} 147 | segs.lines.pw_cursor_xs = {} 148 | local i = 0 149 | for _,x in segs:cursor_xs() do 150 | segs.lines.pw_cursor_is[snap(x, 1/256)] = i 151 | segs.lines.pw_cursor_xs[i] = x 152 | i = i + 1 153 | end 154 | end 155 | 156 | function editbox:password_char_advance_x() 157 | --TODO: maybe use the min(w, h) of the "M" char here? 158 | return self.text_selection.segments.text_runs[1].font_size * .75 159 | end 160 | 161 | --convert "text space" cursor coordinates to "mask space" coordinates. 162 | --NOTE: input must be an exact cursor position. 163 | function editbox:text_to_mask(x, y) 164 | if self.password then 165 | local segs = self.text_selection.segments 166 | local line_x = segs:line_pos(1) 167 | local i = segs.lines.pw_cursor_is[snap(x - line_x, 1/256)] 168 | x = line_x + i * self:password_char_advance_x() 169 | end 170 | return x, y 171 | end 172 | 173 | --convert "mask space" coordinates to "text space" coordinates. 174 | --NOTE: input can be arbitrary but output is snapped to a cursor position. 175 | function editbox:mask_to_text(x, y) 176 | if self.password then 177 | local segs = self:sync_text_shape() 178 | local line_x = segs:line_pos(1) 179 | local w = self:password_char_advance_x() 180 | local i = snap(x - line_x, w) / w 181 | local i = clamp(i, 0, #segs.lines.pw_cursor_xs) 182 | x = line_x + segs.lines.pw_cursor_xs[i] 183 | end 184 | return x, y 185 | end 186 | 187 | function editbox:draw_password_char(cr, i, w, h) 188 | cr:new_path() 189 | cr:circle(w / 2, h / 2, math.min(w, h) * .3) 190 | cr:rgba(self.ui:rgba(self.text_color)) 191 | cr:fill() 192 | end 193 | 194 | function editbox:draw_password_mask(cr) 195 | local w = self:password_char_advance_x() 196 | local h = self.ch 197 | local segs = self.text_selection.segments 198 | local x = segs:line_pos(1) 199 | cr:save() 200 | cr:translate(x, 0) 201 | for i = 0, #segs.lines.pw_cursor_xs-1 do 202 | self:draw_password_char(cr, i, w, h) 203 | cr:translate(w, 0) 204 | end 205 | cr:restore() 206 | end 207 | 208 | function editbox:override_draw_text(inherited, cr) 209 | if self.password then 210 | self:draw_password_mask(cr) 211 | else 212 | inherited(self, cr) 213 | end 214 | end 215 | 216 | function editbox:before_sync_text_align() 217 | if self.password then 218 | self.text_align_x = 'left' 219 | end 220 | end 221 | 222 | function editbox:after_sync_text_align() 223 | if self.password then 224 | self:sync_password_mask() 225 | end 226 | end 227 | 228 | --password eye button -------------------------------------------------------- 229 | 230 | ui:style('editbox_eye_button', { 231 | text_color = '#aaa', 232 | }) 233 | ui:style('editbox_eye_button :hot', { 234 | text_color = '#fff', 235 | }) 236 | 237 | function editbox:after_init() 238 | if self.password then 239 | local no_eye = '\u{f2e8}' 240 | local eye = '\u{f2e9}' 241 | self.eye_button = self.ui:layer({ 242 | parent = self, 243 | tags = 'editbox_eye_button', 244 | font = 'Ionicons,16', 245 | text = no_eye, 246 | cursor = 'hand', 247 | click = function(btn) 248 | self.password = not self.password 249 | btn.text = self.password and no_eye or eye 250 | self:invalidate() 251 | end, 252 | }, self.eye_button) 253 | self.padding_right = 20 254 | end 255 | end 256 | 257 | function editbox:before_sync_layout_children() 258 | if self.password then 259 | local eye = self.eye_button 260 | eye.x = self.w - 10 261 | eye.y = self.h / 2 262 | end 263 | end 264 | 265 | --special text clipping ------------------------------------------------------ 266 | 267 | --allow fonts with long stems to overflow the text box on the y-axis. 268 | editbox.text_overflow_y = 4 269 | 270 | --clip the left & right sides of the box without clipping the top & bottom. 271 | function editbox:text_clip_rect() 272 | local ph = self.text_overflow_y 273 | return 0, -ph, self.cw, self.ch + 2 * ph 274 | end 275 | 276 | function editbox:override_draw_content(inherited, cr) 277 | self:draw_children(cr) 278 | cr:save() 279 | cr:rectangle(self:text_clip_rect()) 280 | cr:clip() 281 | self:draw_text_selection(cr) 282 | self:draw_text(cr) 283 | self:draw_caret(cr) 284 | cr:restore() 285 | end 286 | 287 | function editbox:override_hit_test_text(inherited, x, y, reason) 288 | if not box2d.hit(x, y, self:text_clip_rect()) then 289 | return 290 | end 291 | return inherited(self, x, y, reason) 292 | end 293 | 294 | function editbox:override_make_visible_caret(inherited) 295 | local segs = self.text_segments 296 | if not segs then return end 297 | local lines = segs.lines 298 | local y = lines.y 299 | inherited(self) 300 | lines.y = y 301 | end 302 | 303 | --demo ----------------------------------------------------------------------- 304 | 305 | if not ... then require('ui0_demo')(function(ui, win) 306 | 307 | win.x = 500 308 | win.w = 300 309 | win.h = 900 310 | 311 | win.view.layout = 'flexbox' 312 | win.view.flex_flow = 'y' 313 | win.view.item_align_y = 'top' 314 | 315 | ui:add_font_file('media/fonts/FSEX300.ttf', 'fixedsys') 316 | ui:add_font_file('media/fonts/amiri-regular.ttf', 'Amiri') 317 | 318 | local cue = 'Type text here...' 319 | local s = 'abcd efgh ijkl mnop qrst uvw xyz 0123 4567 8901 2345' 320 | 321 | win.view.accepts_drag_groups = {true} 322 | 323 | --defaults all-around. 324 | ui:editbox{ 325 | parent = win, 326 | text = 'Hello World!', 327 | cue = cue, 328 | --mousedown_activate = true, 329 | } 330 | 331 | --maxlen: truncate initial text. prevent editing past maxlen. 332 | ui:editbox{ 333 | parent = win, 334 | text = 'Hello World!', 335 | maxlen = 5, 336 | cue = cue, 337 | } 338 | 339 | --right align 340 | ui:editbox{ 341 | parent = win, 342 | text = 'Hello World!', 343 | text_align_x = 'right', 344 | cue = cue, 345 | } 346 | 347 | --center align 348 | ui:editbox{ 349 | parent = win, 350 | text = 'Hello World!', 351 | text_align_x = 'center', 352 | cue = cue, 353 | } 354 | 355 | --scrolling, left align 356 | ui:editbox{ 357 | parent = win, 358 | text = s, 359 | cue = cue, 360 | } 361 | 362 | --scrolling, right align 363 | ui:editbox{ 364 | parent = win, 365 | text = s, 366 | text_align_x = 'right', 367 | cue = cue, 368 | } 369 | 370 | --scrolling, center align 371 | ui:editbox{ 372 | parent = win, 373 | text = s, 374 | text_align_x = 'center', 375 | cue = cue, 376 | } 377 | 378 | --invalid font 379 | ui:editbox{ 380 | parent = win, 381 | font = 'Invalid Font,20', 382 | text = s, 383 | cue = cue, 384 | } 385 | 386 | --rtl, align=auto 387 | ui:editbox{ 388 | parent = win, 389 | font = 'Amiri,20', 390 | text = 'السَّلَامُ عَلَيْكُمْ', 391 | cue = cue, 392 | } 393 | 394 | --password, scrolling, left align (the only alignment supported) 395 | ui:editbox{ 396 | parent = win, 397 | text = 'peekaboo', 398 | password = true, 399 | } 400 | 401 | end) end 402 | -------------------------------------------------------------------------------- /ui0_layout_editor.lua: -------------------------------------------------------------------------------- 1 | 2 | --UI Layout Editor widgets. 3 | --Written by Cosmin Apreutesei. 4 | 5 | if not ... then require'pfglab3_app'; return end 6 | 7 | local ui = require'ui0' 8 | local box2d = require'box2d' 9 | 10 | --direct manipulation widget editor 11 | 12 | local editor = ui.layer:subclass'editor' 13 | ui.editor = editor 14 | 15 | --keep the editor layer on top of all other layers. 16 | function ui.layer:override_add_layer(inherited, layer, index) 17 | inherited(self, layer, index) 18 | if self == self.window.view and self.window.editor then 19 | self:move_layer(self.window.editor, 1/0) 20 | end 21 | end 22 | 23 | function editor:before_set_window() 24 | if self.window then 25 | self.window.editor = nil 26 | end 27 | end 28 | 29 | function editor:after_set_window(win) 30 | if win then 31 | win.editor = self 32 | end 33 | end 34 | 35 | function editor:after_init() 36 | self.window.editor = self 37 | end 38 | 39 | --drawing 40 | 41 | --editor.background_color = '#3338' 42 | 43 | function editor:before_sync() 44 | self.w = self.window.view.cw 45 | self.h = self.window.view.ch 46 | end 47 | 48 | function editor:dot(cr, x, y, w, h) 49 | w = w or 4 50 | h = h or 4 51 | cr:new_path() 52 | cr:rectangle(x - w/2, y - h/2, w, h) 53 | cr:fill() 54 | end 55 | 56 | function editor:walk_layer(cr, layer, func, ...) 57 | if not layer.visible then return end 58 | cr:save() 59 | cr:matrix(layer:cr_abs_matrix(cr)) 60 | func(self, cr, layer, ...) 61 | self:walk_children(cr, layer, func, ...) 62 | cr:restore() 63 | end 64 | 65 | function editor:walk_children(cr, layer, func, ...) 66 | local cx, cy = layer:padding_pos() 67 | cr:translate(cx, cy) 68 | for i = 1, #layer do 69 | self:walk_layer(cr, layer[i], func, ...) 70 | end 71 | cr:translate(-cx, -cy) 72 | end 73 | 74 | function editor:draw_target(cr, layer) 75 | if not layer.visible then return false end 76 | if not layer.iswidget then return false end 77 | cr:rgba(self.ui:rgba'#888') 78 | 79 | self:dot(cr, 0, 0) 80 | self:dot(cr, 0, layer.h) 81 | self:dot(cr, layer.w, 0) 82 | self:dot(cr, layer.w, layer.h) 83 | 84 | cr:new_path() 85 | cr:rectangle(0, 0, layer.w, layer.h) 86 | cr:line_width(1) 87 | if layer == self.hot_widget then 88 | cr:stroke_preserve() 89 | cr:rgba(self.ui:rgba'#f936') 90 | cr:fill() 91 | else 92 | cr:stroke() 93 | end 94 | end 95 | 96 | function editor:before_draw_content(cr) 97 | self:walk_layer(cr, self.window.view, self.draw_target) 98 | end 99 | 100 | --hit testing 101 | 102 | function editor:hit_test_target_children(cr, layer, x, y) 103 | for i = #layer, 1, -1 do 104 | if self:hit_test_target(cr, layer[i], x, y) then 105 | return true 106 | end 107 | end 108 | end 109 | 110 | function editor:hit_test_target(cr, layer, x, y) 111 | if layer == self then return end 112 | if not layer.visible then return end 113 | if not (layer.iswidget or layer == layer.window.view) then return end 114 | local x, y = layer:from_parent(x, y) 115 | if self:hit_test_target_children(cr, layer, x, y) then 116 | return true 117 | elseif box2d.hit(x, y, 0, 0, layer.cw, layer.ch) then 118 | self.hot_widget = layer 119 | self:invalidate() 120 | return true 121 | end 122 | end 123 | 124 | function editor:hit_test(x, y, reason) 125 | if not self.visible then return end 126 | if reason ~= 'activate' then return end 127 | self.hot_widget = false 128 | if self:hit_test_target(self.window.cr, self.window.view, x, y) then 129 | return self, 'widget' 130 | end 131 | end 132 | 133 | function editor:click() 134 | if not self.hot_widget then return end 135 | self.visible = false 136 | end 137 | 138 | return editor 139 | -------------------------------------------------------------------------------- /ui0_layout_editor_app.lua: -------------------------------------------------------------------------------- 1 | 2 | --UI Layout Editor app. 3 | --Written by Cosmin Apreutesei. 4 | 5 | local ui = require'ui0' 6 | 7 | ui:window{ 8 | w = 800, 9 | h = 500, 10 | } 11 | 12 | ui:run() 13 | -------------------------------------------------------------------------------- /ui0_list.lua: -------------------------------------------------------------------------------- 1 | 2 | --Editable flexbox widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | 8 | local list = ui.layer:subclass'list' 9 | ui.list = list 10 | list.iswidget = true 11 | 12 | list.layout = 'flexbox' 13 | 14 | 15 | if not ... then require('ui0_demo')(function(ui, win) 16 | 17 | ui:list{ 18 | parent = win, 19 | border_width = 1, 20 | x = 100, y = 100, 21 | {border_width = 1, min_cw = 100, min_ch = 100}, 22 | {border_width = 1, min_cw = 50, min_ch = 100}, 23 | {border_width = 1, min_cw = 100, min_ch = 100}, 24 | } 25 | 26 | end) end 27 | -------------------------------------------------------------------------------- /ui0_menu.lua: -------------------------------------------------------------------------------- 1 | 2 | --Menu Bar & Menu widgets. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | 7 | --menu item ------------------------------------------------------------------ 8 | 9 | ui.menuitem = ui.layer:subclass'menuitem' 10 | ui.menuitem.h = 20 11 | ui.menuitem.padding_left = 10 12 | ui.menuitem.align = 'left' 13 | ui.menuitem.border_offset = 1 14 | 15 | function ui.menuitem:after_init() 16 | self:on('mouseenter', function(self, mx, my) 17 | --self. 18 | end) 19 | self:on('mouseleave', function(self) 20 | 21 | end) 22 | self:on('mousedown', function(self) 23 | --if button = 24 | end) 25 | end 26 | 27 | --menu bar ------------------------------------------------------------------- 28 | 29 | ui.menubar = ui.layer:subclass'menubar' 30 | ui.menubar.iswidget = true 31 | 32 | --function ui.menubar: 33 | 34 | --menu ----------------------------------------------------------------------- 35 | 36 | ui.menu = ui.layer:subclass'menu' 37 | ui.menu.h = 0 38 | ui.menu.border_offset = 1 39 | 40 | function ui.menu:item_h(mi) 41 | return (select(4, mi:border_rect(1))) 42 | end 43 | 44 | function ui.menu:item_y(mi) 45 | assert(mi.parent == self) 46 | local y = 0 47 | for _,item in ipairs(self) do 48 | if item.ismenuitem then 49 | if item == mi then 50 | return y 51 | else 52 | y = y + self:item_h(item) - item.border_width_bottom 53 | end 54 | end 55 | end 56 | end 57 | 58 | function ui.menu:after_add_layer(mi) 59 | if not mi.ismenuitem then return end 60 | mi.x = 0 61 | mi.w = self.w 62 | mi.y = self:item_y(mi) 63 | local prev_mi = self[#self - 1] 64 | if prev_mi then 65 | prev_mi.border_color_bottom = '#0000' 66 | mi.border_color_top = self.border_color_top 67 | mi.border_color_bottom = '#0000' 68 | end 69 | mi.border_color_bottom = '#0000' 70 | mi.border_width = self.border_width_top 71 | self.h = self.h + self:item_h(mi) - mi.border_width_bottom 72 | end 73 | 74 | --popup menu ----------------------------------------------------------------- 75 | 76 | ui.popupmenu = ui.menu:subclass'popupmenu' 77 | 78 | --demo ----------------------------------------------------------------------- 79 | 80 | if not ... then require('ui0_demo')(function(ui, win) 81 | 82 | ui:style('menuitem', { 83 | background_color = '#000', 84 | border_color = '#ff0', 85 | }) 86 | 87 | ui:style('menuitem :hot', { 88 | background_color = '#333', 89 | }) 90 | 91 | local menu = ui:menu{ 92 | parent = win, 93 | x = 10, y = 10, w = 100, 94 | border_width = 1, 95 | border_color = '#fff', 96 | padding = 10, 97 | border_width = 10, 98 | --clip_content = false, 99 | } 100 | 101 | for i=1,5 do 102 | local mi = ui:menuitem{ 103 | parent = menu, 104 | text = 'Menu '..i, 105 | padding = 10, 106 | h = 50, 107 | } 108 | end 109 | 110 | end) end 111 | -------------------------------------------------------------------------------- /ui0_popup.lua: -------------------------------------------------------------------------------- 1 | 2 | --Pop-up Window. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | 8 | local popup = ui.window:subclass'popup' 9 | ui.popup = popup 10 | 11 | popup.frame = false 12 | popup.closeable = false 13 | popup.moveable = false 14 | popup.resizeable = false 15 | popup.maximizable = false 16 | popup.fullscreenable = false 17 | popup.activable = false 18 | 19 | --autohide property: hide when clicking outside of the popup 20 | 21 | popup.autohide = true 22 | 23 | function popup:parent_window_mousedown_autohide(win, button, mx, my) 24 | self:hide() 25 | end 26 | 27 | function popup:parent_window_deactivated_autohide(win) 28 | self:hide() 29 | end 30 | 31 | function popup:after_set_parent(parent) 32 | parent.window:on({'deactivated', self}, function(win) 33 | if self.ui and self.autohide and not self.activable and self.visible then 34 | self:parent_window_deactivated_autohide(win) 35 | end 36 | end) 37 | parent.window:on({'mousedown', self}, function(win, button, mx, my) 38 | if self.ui and self.autohide and self.visible then 39 | self:parent_window_mousedown_autohide(button, mx, my) 40 | --TODO: find out why this needs to be async. 41 | self.ui:runafter(0, function() 42 | parent:invalidate() 43 | end) 44 | end 45 | end) 46 | local x, y = self:frame_rect() 47 | self:frame_rect(x, y) 48 | end 49 | 50 | if not ... then require('ui0_demo')(function(ui, win) 51 | 52 | ui:style('window_view :hot', { 53 | background_color = '#fff', 54 | transition_background_color = true, 55 | transition_duration = 1, 56 | }) 57 | 58 | ui:style('popup > window_view', { 59 | background_color = '#333', 60 | }) 61 | 62 | ui:style('popup > window_view :hot', { 63 | background_color = '#ff0', 64 | transition_background_color = true, 65 | transition_duration = 1, 66 | }) 67 | 68 | local popup_class = ui.popup:subclass'p' 69 | 70 | local popup = popup_class(ui, { 71 | x = 10, y = 10, 72 | w = 300, 73 | h = 600, 74 | parent = win, 75 | visible = false 76 | }) 77 | 78 | function win :keypress(key) print('win ', 'keypress', key) 79 | if key == 'P' then 80 | popup.visible = true 81 | end 82 | end 83 | function popup:keypress(key) print('popup', 'keypress', key) end 84 | --function win :mousemove(mx, my) print('win ', 'move', mx, my) end 85 | --function popup:mousemove(mx, my) print('popup', 'move', mx, my) end 86 | --function win :mouseleave() print('win ', 'leave') end 87 | --function popup:mouseleave() print('popup', 'leave') end 88 | --function win :mouseenter(mx, my) print('win ', 'enter', mx, my) end 89 | --function popup:mouseenter(mx, my) print('popup', 'enter', mx, my) end 90 | 91 | end) end 92 | -------------------------------------------------------------------------------- /ui0_progressbar.lua: -------------------------------------------------------------------------------- 1 | 2 | --Progress Bar widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | 7 | local progressbar = ui.layer:subclass'progressbar' 8 | ui.progressbar = progressbar 9 | progressbar.iswidget = true 10 | 11 | progressbar.progress = 0 12 | 13 | --progressbar.layout = 'textbox' 14 | progressbar.h = 24 15 | progressbar.text_operator = 'xor' 16 | progressbar.border_width = 1 17 | progressbar.border_color = '#333' 18 | 19 | function progressbar:format_text(progress) 20 | return string.format('%d%%', progress * 100) 21 | end 22 | 23 | function progressbar:after_sync() 24 | self.text = self:format_text(self.progress) 25 | end 26 | 27 | local bar = ui.layer:subclass'progressbar_bar' 28 | progressbar.bar_class = bar 29 | 30 | bar.background_color = '#fff' 31 | 32 | function progressbar:create_bar() 33 | return self.bar_class(self.ui, { 34 | parent = self, 35 | iswidget = false, 36 | progressbar = self, 37 | }, self.bar) 38 | end 39 | 40 | function bar:after_sync() 41 | local pb = self.progressbar 42 | self.h = pb.ch 43 | self.w = pb.cw * pb.progress 44 | end 45 | 46 | function progressbar:after_init() 47 | self.bar = self:create_bar() 48 | end 49 | 50 | if not ... then require('ui0_demo')(function(ui, win) 51 | 52 | local b1 = ui:progressbar{ 53 | parent = win, 54 | x = 100, y = 100, w = 200, 55 | progress = .48, 56 | text_color = '#f0f', 57 | bar = { 58 | background_color = '#ff0', 59 | }, 60 | } 61 | 62 | end) end 63 | -------------------------------------------------------------------------------- /ui0_scrollbox.lua: -------------------------------------------------------------------------------- 1 | 2 | --Scrollbar and Scrollbox Widgets. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local box2d = require'box2d' 7 | local glue = require'glue' 8 | 9 | local noop = glue.noop 10 | local clamp = glue.clamp 11 | local lerp = glue.lerp 12 | 13 | local scrollbar = ui.layer:subclass'scrollbar' 14 | ui.scrollbar = scrollbar 15 | scrollbar.iswidget = true 16 | 17 | local grip = ui.layer:subclass'scrollbar_grip' 18 | scrollbar.grip_class = grip 19 | 20 | --default geometry 21 | 22 | scrollbar.w = 12 23 | scrollbar.h = 12 24 | grip.min_w = 20 25 | 26 | ui:style('scrollbar autohide, scrollbar autohide > scrollbar_grip', { 27 | corner_radius = 100, 28 | }) 29 | 30 | --default colors 31 | 32 | scrollbar.background_color = '#222' 33 | grip.background_color = '#999' 34 | 35 | ui:style('scrollbar_grip :hot', { 36 | background_color = '#bbb', 37 | }) 38 | 39 | ui:style('scrollbar_grip :active', { 40 | background_color = '#fff', 41 | }) 42 | 43 | --default initial state 44 | 45 | scrollbar.content_length = 0 46 | scrollbar.view_length = 0 47 | scrollbar.offset = 0 --in 0..content_length range 48 | 49 | --default behavior 50 | 51 | scrollbar.vertical = true --scrollbar is rotated 90deg to make it vertical 52 | scrollbar.step = false --no snapping 53 | scrollbar.autohide = false --hide when mouse is not near the scrollbar 54 | scrollbar.autohide_empty = true --hide when content is smaller than the view 55 | scrollbar.autohide_distance = 20 --distance around the scrollbar 56 | scrollbar.click_scroll_length = 300 57 | --^how much to scroll when clicking on the track (area around the grip) 58 | 59 | --fade animation 60 | 61 | scrollbar.opacity = 0 --prevent fade out on init 62 | 63 | ui:style('scrollbar', { 64 | opacity = 1, 65 | transition_opacity = false, 66 | }) 67 | 68 | --fade out 69 | ui:style('scrollbar autohide', { 70 | opacity = 0, 71 | transition_opacity = true, 72 | transition_delay_opacity = .5, 73 | transition_duration_opacity = 1, 74 | transition_blend_opacity = 'wait', 75 | }) 76 | 77 | ui:style('scrollbar :empty', { 78 | opacity = 0, 79 | transition_opacity = false, 80 | }) 81 | 82 | --fade in 83 | ui:style('scrollbar autohide :near', { 84 | opacity = .5, 85 | transition_opacity = true, 86 | transition_delay_opacity = 0, 87 | transition_duration_opacity = .5, 88 | transition_blend_opacity = 'replace', 89 | }) 90 | 91 | --smooth scrolling 92 | 93 | ui:style('scrollbar', { 94 | transition_offset = true, 95 | transition_duration_offset = .2, 96 | }) 97 | 98 | --vertical property and tags 99 | 100 | scrollbar:stored_property'vertical' 101 | function scrollbar:after_set_vertical(vertical) 102 | self:settag('vertical', vertical) 103 | self:settag('horizontal', not vertical) 104 | end 105 | 106 | --grip geometry 107 | 108 | local function snap_offset(i, step) 109 | return step and i - i % step or i 110 | end 111 | 112 | local function grip_offset(x, bar_w, grip_w, content_length, view_length, step) 113 | local offset = x / (bar_w - grip_w) * (content_length - view_length) 114 | local offset = snap_offset(offset, step) 115 | return offset ~= offset and 0 or offset 116 | end 117 | 118 | local function grip_segment(content_length, view_length, offset, bar_w, min_w) 119 | local w = clamp(bar_w * view_length / content_length, min_w, bar_w) 120 | local x = offset * (bar_w - w) / (content_length - view_length) 121 | local x = clamp(x, 0, bar_w - w) 122 | return x, w 123 | end 124 | 125 | function scrollbar:grip_rect() 126 | local x, w = grip_segment( 127 | self.content_length, self.view_length, self.offset, 128 | self.cw, self.grip.min_w 129 | ) 130 | local y, h = 0, self.ch 131 | return x, y, w, h 132 | end 133 | 134 | function scrollbar:grip_offset() 135 | return grip_offset(self.grip.x, self.cw, self.grip.w, 136 | self.content_length, self.view_length, self.step) 137 | end 138 | 139 | function scrollbar:create_grip() 140 | local grip = self:grip_class(self.grip) 141 | function grip.drag(grip, dx, dy) 142 | grip.x = clamp(0, grip.x + dx, self.cw - grip.w) 143 | self:transition('offset', self:grip_offset(), 0) 144 | end 145 | 146 | return grip 147 | end 148 | 149 | --scroll state 150 | 151 | scrollbar:stored_property'content_length' 152 | scrollbar:stored_property'view_length' 153 | scrollbar:stored_property'offset' 154 | 155 | function scrollbar:clamp_and_snap_offset(offset) 156 | local max_offset = self.content_length - self.view_length 157 | offset = clamp(offset, 0, math.max(max_offset, 0)) 158 | return snap_offset(offset, self.step) 159 | end 160 | 161 | function scrollbar:set_offset(offset) 162 | local old_offset = self._offset 163 | offset = self:clamp_and_snap_offset(offset) 164 | self._offset = offset 165 | self:settag(':empty', self:empty(), true) 166 | if offset ~= old_offset then 167 | self:fire('offset_changed', offset, old_offset) 168 | end 169 | end 170 | 171 | function scrollbar:after_set_content_length() self.offset = self.offset end 172 | function scrollbar:after_set_view_length() self.offset = self.offset end 173 | 174 | function scrollbar:reset(content_length, view_length, offset) 175 | self._content_length = content_length 176 | self._view_length = view_length 177 | self.offset = offset 178 | end 179 | 180 | function scrollbar:empty() 181 | return self.content_length <= self.view_length 182 | end 183 | 184 | scrollbar:init_ignore{content_length=1, view_length=1, offset=1} 185 | 186 | function scrollbar:after_init(t) 187 | self:reset(t.content_length, t.view_length, t.offset) 188 | self.grip = self:create_grip() 189 | end 190 | 191 | --visibility state 192 | 193 | function scrollbar:check_visible(...) 194 | return self.visible 195 | and (not self.autohide_empty or not self:empty()) 196 | and (not self.autohide or self:check_visible_autohide(...)) 197 | end 198 | 199 | --scroll API 200 | 201 | function scrollbar:scroll_to(offset, duration) 202 | if self:check_visible() == 'hit_test' then 203 | self:settag(':near', true) 204 | self:sync() 205 | self:settag(':near', false) 206 | end 207 | offset = self:clamp_and_snap_offset(offset) 208 | --^we want to animate the clamped length! 209 | self:transition('offset', offset, duration) 210 | end 211 | 212 | function scrollbar:scroll_to_view(x, w, duration) 213 | local sx = self:end_value'offset' 214 | local sw = self.view_length 215 | self:scroll_to(clamp(sx, x + w - sw, x), duration) 216 | end 217 | 218 | function scrollbar:scroll(delta, duration) 219 | self:scroll_to(self:end_value'offset' + delta, duration) 220 | end 221 | 222 | function scrollbar:scroll_pages(pages, duration) 223 | self:scroll(self.view_length * (pages or 1), duration) 224 | end 225 | 226 | --mouse interaction: grip dragging 227 | 228 | grip.mousedown_activate = true 229 | grip.draggable = true 230 | 231 | --mouse interaction: clicking on the track 232 | 233 | scrollbar.mousedown_activate = true 234 | 235 | --TODO: mousepress or mousehold 236 | function scrollbar:mousedown(mx, my) 237 | local delta = self.click_scroll_length * (mx < self.grip.x and -1 or 1) 238 | self:transition('offset', self:end_value'offset' + delta) 239 | end 240 | 241 | --autohide feature 242 | 243 | scrollbar:stored_property'autohide' 244 | function scrollbar:after_set_autohide(autohide) 245 | self:settag('autohide', autohide) 246 | end 247 | 248 | function scrollbar:hit_test_near(mx, my) --mx,my in window space 249 | if not mx then 250 | return 'hit_test' 251 | end 252 | mx, my = self:from_window(mx, my) 253 | return box2d.hit(mx, my, 254 | box2d.offset(self.autohide_distance, self:client_rect())) 255 | end 256 | 257 | function scrollbar:check_visible_autohide(mx, my) 258 | return self.grip.active 259 | or (not self.ui.active_widget and self:hit_test_near(mx, my)) 260 | end 261 | 262 | function scrollbar:after_set_parent() 263 | if not self.window then return end 264 | self.window:on({'mousemove', self}, function(win, mx, my) 265 | self:settag(':near', self:check_visible(mx, my)) 266 | end) 267 | self.window:on({'mouseleave', self}, function(win) 268 | local visible = self:check_visible() 269 | if visible == 'hit_test' then visible = false end 270 | self:settag(':near', visible) 271 | end) 272 | end 273 | 274 | --drawing: rotate matrix for vertical scrollbar 275 | 276 | function scrollbar:override_rel_matrix(inherited) 277 | local mt = inherited(self) 278 | if self._vertical then 279 | mt:rotate(math.rad(90)):translate(0, -self.h) 280 | end 281 | return mt 282 | end 283 | 284 | --drawing: sync grip geometry; sync :near tag. 285 | 286 | function scrollbar:before_sync_layout_children() 287 | local g = self.grip 288 | g.x, g.y, g.w, g.h = self:grip_rect() 289 | local visible = self:check_visible() 290 | if visible ~= 'hit_test' then 291 | self:settag(':near', visible, true) 292 | end 293 | end 294 | 295 | --scrollbox ------------------------------------------------------------------ 296 | 297 | local scrollbox = ui.layer:subclass'scrollbox' 298 | ui.scrollbox = scrollbox 299 | scrollbox.iswidget = true 300 | 301 | scrollbox.view_class = ui.layer 302 | scrollbox.content_class = ui.layer 303 | scrollbox.vscrollbar_class = scrollbar 304 | scrollbox.hscrollbar_class = scrollbar 305 | 306 | function scrollbox:after_init(t) 307 | 308 | self.vscrollbar = self:vscrollbar_class({ 309 | tags = 'vscrollbar', 310 | scrollbox = self, 311 | vertical = true, 312 | iswidget = false, 313 | }, self.scrollbar, self.vscrollbar) 314 | 315 | self.hscrollbar = self:hscrollbar_class({ 316 | tags = 'hscrollbar', 317 | scrollbox = self, 318 | vertical = false, 319 | iswidget = false, 320 | }, self.scrollbar, self.hscrollbar) 321 | 322 | --make autohide scrollbars to show and hide in sync. 323 | --TODO: remove the brk anti-recursion barrier hack. 324 | local vs = self.vscrollbar 325 | local hs = self.hscrollbar 326 | function vs:override_check_visible_autohide(inherited, mx, my, brk) 327 | return inherited(self, mx, my) 328 | or (not brk and hs.autohide and hs:check_visible(mx, my, true)) 329 | end 330 | function hs:override_check_visible_autohide(inherited, mx, my, brk) 331 | return inherited(self, mx, my) 332 | or (not brk and vs.autohide and vs:check_visible(mx, my, true)) 333 | end 334 | 335 | --NOTE: the view is created last so it is freed first, so that the 336 | --content can still access the scrollbox on its dying breath! 337 | self.view = self:view_class({ 338 | tags = 'scrollbox_view', 339 | clip_content = 'background', --we want to pad the content, but not clip it 340 | sync_layout = noop, --prevent auto-sync'ing content's layout 341 | }, self.view) 342 | 343 | if not self.content or not self.content.islayer then 344 | self.content = self.content_class(self.ui, { 345 | tags = 'scrollbox_content', 346 | parent = self.view, 347 | }, self.content) 348 | elseif self.content then 349 | self.content.parent = self.view 350 | end 351 | 352 | end 353 | 354 | --mouse interaction: wheel scrolling 355 | 356 | scrollbox.vscrollable = true 357 | scrollbox.hscrollable = true 358 | scrollbox.wheel_scroll_length = 50 --pixels per scroll wheel notch 359 | 360 | function scrollbox:mousewheel(delta) 361 | self.vscrollbar:scroll(-delta * self.wheel_scroll_length) 362 | end 363 | 364 | --drawing 365 | 366 | scrollbox:forward_properties('view', 'view_', { 367 | padding=1, 368 | padding_left=1, 369 | padding_right=1, 370 | padding_top=1, 371 | padding_bottom=1, 372 | }) 373 | 374 | --stretch content to the view size to avoid scrolling on that dimension. 375 | scrollbox.auto_h = false 376 | scrollbox.auto_w = false 377 | 378 | function scrollbox:sync_layout_children() 379 | 380 | local vs = self.vscrollbar 381 | local hs = self.hscrollbar 382 | local view = self.view 383 | local content = self.content 384 | content.parent = view 385 | 386 | local w, h = self:client_size() 387 | 388 | local vs_margin = vs.margin or 0 389 | local hs_margin = hs.margin or 0 390 | 391 | local vs_overlap = vs.autohide or vs.overlap or not vs.visible 392 | local hs_overlap = hs.autohide or hs.overlap or not hs.visible 393 | 394 | local sw = vs.h + vs_margin 395 | local sh = hs.h + hs_margin 396 | 397 | --for `auto_w`, lay out the content with `min_w` set to view's cw, and then 398 | --get its size. if the content overflows vertically, another layout pass 399 | --is necessary, this time with a smaller `min_w`, making room for the 400 | --needed vertical scrollbar, under the assumption that the content will 401 | --still overflow vertically under the smaller `min_w`. the same logic 402 | --applies symmetrically for `auto_h`. 403 | local cw0 = self.auto_w and w - ((vs_overlap or vs.autohide_empty) and 0 or sw) 404 | local ch0 = self.auto_h and h - ((hs_overlap or hs.autohide_empty) and 0 or sh) 405 | 406 | if cw0 and ch0 then 407 | self.ui:warn'both auto_w and auto_h specified. auto_h ignored.' 408 | ch0 = nil 409 | end 410 | 411 | ::reflow:: 412 | 413 | if cw0 or ch0 then 414 | content:sync_layout_separate_axes(cw0 and 'xy' or 'yx', cw0, ch0) 415 | else 416 | content:sync_layout() 417 | end 418 | 419 | local cw, ch = content:size() 420 | 421 | --compute view dimensions by deciding which scrollbar is either hidden 422 | --or is overlapping the view box so it takes no space of its own. 423 | local vs_nospace, hs_nospace 424 | 425 | local vs_nospace = vs_overlap 426 | or (vs.autohide_empty and ch <= h and (ch <= h - sh or 'depends')) 427 | 428 | local hs_nospace = hs_overlap 429 | or (hs.autohide_empty and cw <= w and (cw <= w - sw or 'depends')) 430 | 431 | if (vs_nospace == 'depends' and not hs_nospace) 432 | or (hs_nospace == 'depends' and not vs_nospace) 433 | then 434 | vs_nospace = false 435 | hs_nospace = false 436 | end 437 | 438 | view.w = w - (vs_nospace and 0 or sw) 439 | view.h = h - (hs_nospace and 0 or sh) 440 | 441 | --if the view's `cw` is smaller than the preliminary `w` on which content 442 | --reflowing was based on for `auto_w`, then do it again with the real `cw`. 443 | --the same applies for `ch` for `auto_h`. 444 | if cw0 and view.cw < cw0 then 445 | cw0 = view.cw 446 | goto reflow 447 | elseif ch0 and view.ch < ch0 then 448 | ch0 = view.ch 449 | goto reflow 450 | end 451 | 452 | --reset the scrollbars state. 453 | hs:reset(cw, view.cw, hs.offset) 454 | vs:reset(ch, view.ch, vs.offset) 455 | 456 | --scroll the content layer. 457 | content.x = -hs.offset * content.w / cw -- content.pw1 458 | content.y = -vs.offset * content.h / ch -- content.ph1 459 | 460 | --compute scrollbar dimensions. 461 | vs.w = view.h - 2 * vs_margin --.w is its height! 462 | hs.w = view.w - 2 * hs_margin 463 | 464 | --check which scrollbars are visible and actually overlapping the view. 465 | --NOTE: scrollbars state must already be set here since we call `empty()`. 466 | local hs_overlapping = hs.visible and hs_overlap 467 | and (not hs.autohide_empty or not hs:empty()) 468 | 469 | local vs_overlapping = vs.visible and vs_overlap 470 | and (not vs.autohide_empty or not vs:empty()) 471 | 472 | --shorten the ends of scrollbars so they don't overlap each other. 473 | vs.w = vs.w - (vs_overlap and hs_overlapping and sh or 0) 474 | hs.w = hs.w - (hs_overlap and vs_overlapping and sw or 0) 475 | 476 | --compute scrollbar positions. 477 | vs.x = view.w - (vs_nospace and sw or 0) 478 | hs.y = view.h - (hs_nospace and sh or 0) 479 | vs.y = vs_margin 480 | hs.x = hs_margin 481 | 482 | for _,layer in ipairs(self) do 483 | if layer ~= content then 484 | layer:sync_layout() --recurse 485 | end 486 | end 487 | end 488 | 489 | --scroll API 490 | 491 | --x, y is in content's content space. 492 | function scrollbox:scroll_to_view(x, y, w, h) 493 | x, y = self.content:from_content(x, y) 494 | self.hscrollbar:scroll_to_view(x, w) 495 | self.vscrollbar:scroll_to_view(y, h) 496 | end 497 | 498 | --x, y, w, h is in own content space. 499 | function scrollbox:make_visible(x, y, w, h) 500 | x, y = self:to_other(self.content, x, y) 501 | self:scroll_to_view(x, y, w, h) 502 | end 503 | 504 | --multi-line editbox --------------------------------------------------------- 505 | 506 | local textarea = scrollbox:subclass'textarea' 507 | ui.textarea = textarea 508 | 509 | textarea.tags = 'standalone' 510 | 511 | textarea.auto_w = true 512 | textarea.view_padding_left = 0 513 | textarea.view_padding_right = 6 514 | 515 | local editbox = ui.layer:subclass'textarea_content' 516 | textarea.content_class = editbox 517 | 518 | editbox.layout = 'textbox' 519 | editbox.text_align_x = 'auto' 520 | editbox.text_align_y = 'top' 521 | editbox.focusable = true 522 | editbox.text_selectable = true 523 | editbox.text_editable = true 524 | editbox.clip_content = false 525 | 526 | function textarea:get_value() return self.editbox.value end 527 | function textarea:set_value(val) self.editbox.value = val end 528 | 529 | textarea:init_ignore{value=1} 530 | 531 | function textarea:after_init(t) 532 | self.editbox = self.content 533 | self.value = t.value 534 | end 535 | 536 | --demo ----------------------------------------------------------------------- 537 | 538 | if not ... then require('ui0_demo')(function(ui, win) 539 | 540 | win.view.item_align_x = 'center' 541 | win.view.item_align_y = 'center' 542 | 543 | ui:style('scrollbox', { 544 | border_width = 1, 545 | border_color = '#f00', 546 | }) 547 | 548 | local function mkcontent(w, h) 549 | return ui:layer{ 550 | w = w or 2000, 551 | h = h or 32000, 552 | border_width = 20, 553 | border_color = '#ff0', 554 | background_type = 'gradient', 555 | background_colors = {'#ff0', 0.5, '#00f'}, 556 | background_x2 = 100, 557 | background_y2 = 100, 558 | background_extend = 'repeat', 559 | } 560 | end 561 | 562 | local s = [[ 563 | Lorem ipsum dolor sit amet, quod oblique vivendum ex sed. Impedit nominavi maluisset sea ut. Utroque apeirian maluisset cum ut. Nihil appellantur at his, fugit noluisse eu vel, mazim mandamus ex quo. 564 | 565 | Mei malis eruditi ne. Movet volumus instructior ea nec. Vel cu minimum molestie atomorum, pro iudico facilisi et, sea elitr partiendo at. An has fugit assum accumsan.]] 566 | 567 | --not autohide, custom bar metrics 568 | ui:scrollbox{ 569 | parent = win, 570 | min_cw = 180, min_ch = 180, 571 | content = mkcontent(), 572 | vscrollbar = {h = 20, margin = 20}, 573 | hscrollbar = {h = 30, margin = 10}, 574 | } 575 | 576 | --overlap, custom bar metrics 577 | ui:scrollbox{ 578 | parent = win, 579 | min_cw = 180, min_ch = 180, 580 | content = mkcontent(), 581 | vscrollbar = {h = 20, margin = 20}, 582 | hscrollbar = {h = 30, margin = 10}, 583 | scrollbar = {overlap = true}, 584 | } 585 | 586 | --not autohide, autohide_empty vertical 587 | ui:scrollbox{ 588 | parent = win, 589 | min_cw = 180, min_ch = 180, 590 | content = mkcontent(nil, 165), 591 | } 592 | 593 | --not autohide, autohide_empty horizontal 594 | ui:scrollbox{ 595 | parent = win, 596 | min_cw = 180, min_ch = 180, 597 | content = mkcontent(165), 598 | autohide = true, 599 | } 600 | 601 | --not autohide, autohide_empty horizontal -> vertical 602 | ui:scrollbox{ 603 | parent = win, 604 | min_cw = 180, min_ch = 180, 605 | content = mkcontent(185, 175), 606 | autohide = true, 607 | } 608 | 609 | --not autohide, autohide_empty vertical -> horizontal 610 | ui:scrollbox{ 611 | parent = win, 612 | min_cw = 180, min_ch = 180, 613 | content = mkcontent(175, 185), 614 | autohide = true, 615 | } 616 | 617 | --autohide_empty case 618 | ui:scrollbox{ 619 | parent = win, 620 | min_cw = 180, min_ch = 180, 621 | content = mkcontent(180, 180), 622 | } 623 | 624 | --autohide 625 | ui:scrollbox{ 626 | parent = win, 627 | min_cw = 180, min_ch = 180, 628 | content = mkcontent(), 629 | scrollbar = { 630 | autohide = true, 631 | }, 632 | } 633 | 634 | --autohide, autohide_empty vertical 635 | ui:scrollbox{ 636 | parent = win, 637 | min_cw = 180, min_ch = 180, 638 | content = mkcontent(nil, 175), 639 | scrollbar = { 640 | autohide = true, 641 | } 642 | } 643 | 644 | --autohide, autohide_empty horizontal 645 | ui:scrollbox{ 646 | parent = win, 647 | min_cw = 180, min_ch = 180, 648 | content = mkcontent(175), 649 | scrollbar = { 650 | autohide = true, 651 | } 652 | } 653 | 654 | --autohide horizontal only 655 | ui:scrollbox{ 656 | parent = win, 657 | min_cw = 180, min_ch = 180, 658 | content = mkcontent(175), 659 | hscrollbar = { 660 | autohide = true, 661 | } 662 | } 663 | 664 | --auto_w 665 | ui:scrollbox{ 666 | parent = win, 667 | min_cw = 180, min_ch = 180, 668 | auto_w = true, 669 | content = { 670 | layout = 'textbox', 671 | text_align_x = 'left', 672 | text_align_y = 'top', 673 | text = s, 674 | }, 675 | } 676 | 677 | --[[ 678 | ui:textarea{ 679 | parent = win, 680 | min_cw = 180, min_ch = 180, 681 | value = s, 682 | } 683 | ]] 684 | 685 | end) end 686 | -------------------------------------------------------------------------------- /ui0_slider.lua: -------------------------------------------------------------------------------- 1 | 2 | --Slider widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | 8 | local snap = glue.snap 9 | local clamp = glue.clamp 10 | local lerp = glue.lerp 11 | 12 | local slider = ui.layer:subclass'slider' 13 | ui.slider = slider 14 | slider.iswidget = true 15 | 16 | local track = ui.layer:subclass'slider_track' 17 | local fill = ui.layer:subclass'slider_fill' 18 | local pin = ui.layer:subclass'slider_pin' 19 | local marker = ui.layer:subclass'slider_marker' 20 | local tip = ui.layer:subclass'slider_tip' 21 | local step_label = ui.layer:subclass'slider_step_label' 22 | 23 | slider.track_class = track 24 | slider.fill_class = fill 25 | slider.pin_class = pin 26 | slider.marker_class = marker 27 | slider.tip_class = tip 28 | slider.step_label_class = step_label 29 | 30 | slider.focusable = true 31 | slider.min_ch = 24 32 | slider.w = 180 33 | slider.h = 24 34 | 35 | slider._min_position = 0 36 | slider._max_position = false --overrides size 37 | slider._position = 0 38 | slider._progress = false --overrides position 39 | slider.step_start = 0 40 | slider.step = false --no stepping 41 | slider.step_labels = false --{label = value, ...} 42 | 43 | slider.snap_to_labels = true --...if there are any 44 | slider.step_line_h = 5 45 | slider.step_line_color = '#fff' --false to disable 46 | slider.key_nav_speed = 0.1 --constant 10% speed on left/right keys 47 | slider.smooth_dragging = true --pin stays under the mouse while dragging 48 | slider.phantom_dragging = true --drag a secondary translucent pin 49 | 50 | track.activable = false 51 | track.h = 8 52 | track.corner_radius = 5 53 | track.border_width = 1 54 | track.border_color = '#999' 55 | track.clip_content = true --clip the fill 56 | 57 | ui:style('slider :focused > slider_track', { 58 | border_color = '#fff', 59 | shadow_blur = 1, 60 | shadow_color = '#999', 61 | background_color = '#040404' --to cover the shadow 62 | }) 63 | 64 | fill.activable = false 65 | fill.h = 10 66 | fill.background_color = '#999' 67 | 68 | ui:style('slider :focused > slider_fill', { 69 | background_color = '#ccc', 70 | }) 71 | 72 | pin.w = 16 73 | pin.h = 16 74 | pin.corner_radius = 8 75 | pin.border_width = 1 76 | pin.border_color = '#000' 77 | pin.background_color = '#999' 78 | 79 | ui:style('slider_pin', { 80 | transition_duration = .2, 81 | transition_cx = true, 82 | transition_border_color = true, 83 | transition_background_color = true, 84 | }) 85 | 86 | ui:style('slider :focused > slider_pin', { 87 | background_color = '#fff', 88 | }) 89 | 90 | ui:style('slider_drag_pin', { 91 | opacity = 0, 92 | }) 93 | 94 | ui:style('slider_drag_pin :dragging', { 95 | opacity = .5, 96 | }) 97 | 98 | marker.w = 4 99 | marker.h = 4 100 | marker.corner_radius = 2 101 | marker.background_color = '#fff' 102 | marker.background_operator = 'difference' 103 | marker.opacity = 0 104 | 105 | ui:style('slider_marker :visible', { 106 | opacity = 1, 107 | }) 108 | 109 | tip.y = -8 110 | tip.format = '%g' 111 | tip.border_width = 0 112 | tip.border_color = '#fff' 113 | tip.border_offset = 1 114 | tip.font_size = 11 115 | 116 | tip.opacity = 0 117 | 118 | ui:style('slider_tip', { 119 | --fade-out 120 | transition_opacity = true, 121 | transition_duration_opacity = .5, 122 | transition_delay_opacity = 1.5, 123 | transition_blend_opacity = 'wait', 124 | }) 125 | 126 | ui:style('slider_tip :visible', { 127 | --fade-in 128 | opacity = 1, 129 | transition_opacity = true, 130 | transition_delay_opacity = 0, 131 | transition_blend_opacity = 'replace', 132 | }) 133 | 134 | step_label.font_size = 10 135 | 136 | --pin position 137 | 138 | function pin:cx_range() 139 | local r = self.slider.track.corner_radius 140 | return r, self.slider.cw - r 141 | end 142 | 143 | function pin:progress_at_cx(cx) 144 | local cx1, cx2 = self:cx_range() 145 | return lerp(cx, cx1, cx2, 0, 1) 146 | end 147 | 148 | function pin:cx_at_progress(progress) 149 | local cx1, cx2 = self:cx_range() 150 | return lerp(progress, 0, 1, cx1, cx2) 151 | end 152 | 153 | function pin:position_at_cx(cx) 154 | local cx1, cx2 = self:cx_range() 155 | return lerp(cx, cx1, cx2, self.slider:position_range()) 156 | end 157 | 158 | function pin:cx_at_position(pos) 159 | local p1, p2 = self.slider:position_range() 160 | return lerp(pos, p1, p2, self:cx_range()) 161 | end 162 | 163 | function pin:get_progress() 164 | return self:progress_at_cx(self.cx) 165 | end 166 | 167 | function pin:get_position() 168 | return self:position_at_cx(self.cx) 169 | end 170 | 171 | function pin:move(cx) 172 | local duration = not self.animate and 0 or nil 173 | --NOTE: snapx() is to prevent transition() to invalidate() because the 174 | --values are not the same, x,w being snapped by the layout. 175 | local cx = self:snapcx(cx) 176 | if cx ~= self.cx then 177 | self:transition('cx', cx, duration) 178 | if self.animate then 179 | self.slider.tip:settag(':visible', true, true) 180 | end 181 | end 182 | end 183 | 184 | function pin:set_position(pos) 185 | self:move(self:cx_at_position(pos)) 186 | end 187 | 188 | function pin:set_progress(progress) 189 | self:move(self:cx_at_progress(progress)) 190 | end 191 | 192 | --sync'ing 193 | 194 | function slider:create_track() 195 | return self.track_class(self.ui, { 196 | slider = self, 197 | parent = self, 198 | }, self.track) 199 | end 200 | 201 | function slider:create_fill() 202 | return self.fill_class(self.ui, { 203 | slider = self, 204 | parent = self.track, 205 | }, self.fill) 206 | end 207 | 208 | function slider:create_pin() 209 | return self.pin_class(self.ui, { 210 | slider = self, 211 | parent = self, 212 | }, self.pin) 213 | end 214 | 215 | function slider:create_drag_pin(pin_fields) 216 | return self.pin_class(self.ui, { 217 | tags = 'slider_drag_pin', 218 | slider = self, 219 | parent = self, 220 | }, self.drag_pin, pin_fields) 221 | end 222 | 223 | function slider:create_marker() 224 | return self.marker_class(self.ui, { 225 | slider = self, 226 | parent = self, 227 | }, self.marker) 228 | end 229 | 230 | function slider:create_tip() 231 | return self.tip_class(self.ui, { 232 | slider = self, 233 | parent = self.pin, 234 | }, self.tip) 235 | end 236 | 237 | function slider:create_step_label(text, position) 238 | return self.step_label_class(self.ui, { 239 | slider = self, 240 | parent = self, 241 | text = text, 242 | position = position, 243 | }, self.step_label) 244 | end 245 | 246 | function slider:before_sync_layout_children() 247 | local s = self.track 248 | local f = self.fill 249 | local p = self.pin 250 | local dp = self.drag_pin 251 | local m = self.marker 252 | local t = self.tip 253 | 254 | s.x = 0 255 | s.cy = self.h / 2 256 | s.w = self.cw 257 | 258 | p.cy = self.h / 2 259 | dp.y = p.y 260 | 261 | local dragging = p.dragging or dp.dragging 262 | if not p:transitioning'cx' and not dragging and not self.active then 263 | p.progress = self.progress 264 | if not dragging then 265 | t:settag(':visible', false, true) 266 | end 267 | end 268 | 269 | f.h = s.h 270 | f.w = p.cx 271 | 272 | m.cy = self.h / 2 273 | m.cx = self.pin:cx_at_position(self.position) 274 | m:settag(':visible', dragging or self.active, true) 275 | 276 | t.x = p.w / 2 277 | 278 | if self.step_labels then 279 | local h = s.y + math.floor(s.h - (self:step_lines_visible() and 0 or 10)) 280 | for _,l in ipairs(self) do 281 | if l.tags.slider_step_label then 282 | if l.progress then 283 | l.x = self.pin:cx_at_progress(l.progress) 284 | elseif l.position then 285 | l.x = self.pin:cx_at_position(l.position) 286 | end 287 | l.y = h 288 | l.w = 200 289 | l.x = l.x - l.w / 2 290 | l.h = 26 291 | end 292 | end 293 | end 294 | end 295 | 296 | function slider:step_lines_visible() 297 | return self.step and self.step_line_color 298 | and self.cw / (self.size / self.step) >= 5 299 | end 300 | 301 | function slider:step_line_path(cr, cx, h) 302 | cr:move_to(cx, h) 303 | cr:rel_line_to(0, self.step_line_h) 304 | end 305 | 306 | function slider:draw_step_lines(cr) 307 | if not self:step_lines_visible() then return end 308 | cr:rgba(self.ui:rgba(self.step_line_color)) 309 | cr:line_width(1) 310 | cr:new_path() 311 | local s = self.track 312 | local h = s.y + math.floor(s.h) + 4 313 | local p1, p2 = self:position_range() 314 | local sp1 = self:snap_position(p1 - self.step) 315 | local sp2 = self:snap_position(p2 + self.step) 316 | for pos = sp1, sp2, self.step do 317 | pos = clamp(pos, p1, p2) 318 | self:step_line_path(cr, self.pin:cx_at_position(pos), h) 319 | end 320 | if self.step_labels and self.snap_to_labels then 321 | for text, pos in pairs(self.step_labels) do 322 | if type(text) == 'number' then 323 | pos, text = text, pos 324 | end 325 | self:step_line_path(cr, self.pin:cx_at_position(pos), h) 326 | end 327 | end 328 | cr:stroke() 329 | end 330 | 331 | function slider:after_draw_content(cr) 332 | self:draw_step_lines(cr) 333 | end 334 | 335 | --input 336 | 337 | function slider:_drag_pin() 338 | return (self.phantom_dragging and not self.smooth_dragging) 339 | and self.drag_pin or self.pin 340 | end 341 | 342 | 343 | pin.mousedown_activate = true 344 | pin.draggable = true 345 | 346 | function pin:mousedown() 347 | self.slider:focus() 348 | end 349 | 350 | function pin:start_drag() 351 | self.slider.tip:settag(':visible', true) 352 | return self.slider:_drag_pin() 353 | end 354 | 355 | function pin:drag(dx) 356 | local cx1, cx2 = self:cx_range() 357 | local cxsize = cx2 - cx1 358 | local cx = self.x + dx + self.w / 2 359 | if self.ui:key'ctrl' then 360 | cx = snap(cx - cx1, .1 * cxsize) + cx1 361 | elseif self.ui:key'shift' then 362 | cx = snap(cx - cx1, .01 * cxsize) + cx1 363 | end 364 | local cx = clamp(cx, cx1, cx2) 365 | self.slider.position = self:position_at_cx(cx) 366 | if self.slider.phantom_dragging or self.slider.smooth_dragging then 367 | self:move(cx) --grab the drag-pin instantly, cancelling any animation 368 | if self.slider.phantom_dragging and self.slider.smooth_dragging then 369 | self.slider.pin:move(cx) 370 | end 371 | end 372 | end 373 | 374 | function pin:end_drag() 375 | --move the pin to the final position animated. 376 | self.animate = true 377 | self.position = self.slider.position 378 | self.animate = false 379 | end 380 | 381 | slider.drag_threshold = 1 --don't grab the pin right away 382 | slider.mousedown_activate = true 383 | slider.draggable = true 384 | 385 | function slider:mousedown(mx) 386 | local position = self:nearest_position(self.pin:position_at_cx(mx)) 387 | if position == self.position then 388 | --early-grab the pin otherwise it wouldn't move at all 389 | self.pin.animate = true 390 | self.pin:move(mx) 391 | self.pin.animate = false 392 | else 393 | --move the pin to the final position animated as if clicked 394 | self.pin.animate = true 395 | self.position = position 396 | self.pin.animate = false 397 | end 398 | end 399 | 400 | function slider:mouseup() 401 | --move the pin to the final position animated upon mouse release, 402 | --the pin can be in a non-final position either because of an eary-grab 403 | --or when smooth_dragging is enabled. 404 | self.pin.animate = true 405 | self.pin.position = self.position 406 | self.pin.animate = false 407 | end 408 | 409 | --NOTE: returns pin so pin:drag() is called, but pin:end_drag() is not called! 410 | function slider:start_drag(_, mx) 411 | local drag_pin = self:_drag_pin() 412 | return drag_pin, drag_pin.w / 2, 0 413 | end 414 | 415 | function slider:keypress(key) 416 | self.pin.animate = true 417 | if key == 'left' or key == 'up' or key == 'pageup' 418 | or key == 'right' or key == 'down' or key == 'pagedown' 419 | then 420 | local pos = self.position 421 | local dir = (key == 'left' or key:find'up') and -1 or 1 422 | local progress_delta = 423 | (self.ui:key'shift' and 0.01 or 1) 424 | * (self.ui:key'ctrl' and 0.1 or 1) 425 | * ((key == 'up' or key == 'down') and 0.1 or 1) 426 | * (key:find'page' and 5 or 1) 427 | * self.key_nav_speed --constant speed 428 | * dir 429 | self.position = self:position_at_progress_offset(nil, progress_delta) 430 | return true 431 | elseif key == 'home' then 432 | self.progress = 0 433 | return true 434 | elseif key == 'end' then 435 | self.progress = 1 436 | return true 437 | elseif key == 'enter' or key == 'space' then 438 | self.tip:settag(':visible', true) 439 | self.tip:sync_styles() 440 | return true 441 | end 442 | self.pin.animate = false 443 | end 444 | 445 | slider.vscrollable = true 446 | 447 | function slider:mousewheel(pages) 448 | --move the pin to the final position animated as if clicked 449 | self.pin.animate = true 450 | local progress_delta = 451 | -pages / 3 452 | * (self.ui:key'shift' and 0.01 or 1) 453 | * (self.ui:key'ctrl' and 0.1 or 1) 454 | * self.key_nav_speed 455 | self.position = self:position_at_progress_offset(nil, progress_delta) 456 | self.pin.animate = false 457 | end 458 | 459 | --state 460 | 461 | function slider:snap_position(pos) 462 | local s0 = self.step_start 463 | return snap(pos - s0, self.step) + s0 464 | end 465 | 466 | local function next_pos(pos, best_pos, ref_pos) 467 | return pos > ref_pos and (not best_pos or pos < best_pos) 468 | end 469 | local function prev_pos(pos, best_pos, ref_pos) 470 | return pos < ref_pos and (not best_pos or pos > best_pos) 471 | end 472 | local function nearest_pos(pos, best_pos, ref_pos) 473 | return not best_pos or math.abs(pos - ref_pos) < math.abs(best_pos - ref_pos) 474 | end 475 | function slider:nearest_position(ref_pos, dir) 476 | ref_pos = ref_pos or self.position 477 | dir = dir or 0 478 | local choose = 479 | dir > 0 and next_pos 480 | or dir < 0 and prev_pos 481 | or nearest_pos 482 | local best_pos 483 | if self.snap_to_labels and self.step_labels then 484 | for label, pos in pairs(self.step_labels) do 485 | if choose(pos, best_pos, ref_pos) then 486 | best_pos = pos 487 | end 488 | end 489 | end 490 | if self.step then 491 | ref_pos = clamp(ref_pos, self:position_range()) 492 | local sp1 = self:snap_position(ref_pos - 2 * self.step) 493 | local sp2 = self:snap_position(ref_pos + 2 * self.step) 494 | for pos = sp1, sp2, self.step do 495 | if choose(pos, best_pos, ref_pos) then 496 | best_pos = pos 497 | end 498 | end 499 | end 500 | return clamp(best_pos or ref_pos, self:position_range()) 501 | end 502 | 503 | function slider:position_at_position_offset(ref_pos, delta) 504 | ref_pos = ref_pos or self.position 505 | local target_pos = ref_pos + delta 506 | local pos = self:nearest_position(target_pos) 507 | if pos == ref_pos then --nearest-to-target pos is the ref pos 508 | pos = self:nearest_position(target_pos, delta) 509 | end 510 | return pos 511 | end 512 | 513 | function slider:position_at_progress_offset(ref_pos, delta) 514 | return self:position_at_position_offset(ref_pos, delta * self.size) 515 | end 516 | 517 | function slider:get_progress() 518 | if self:isinstance() then 519 | local p1, p2 = self:position_range() 520 | return lerp(self.position, p1, p2, 0, 1) 521 | else 522 | return self._progress 523 | end 524 | end 525 | 526 | function slider:set_progress(progress) 527 | if self:isinstance() then 528 | self.position = lerp(progress, 0, 1, self:position_range()) 529 | else 530 | self._progress = progress 531 | end 532 | end 533 | 534 | function slider:position_range() 535 | return self.min_position, self.max_position 536 | end 537 | 538 | function slider:get_min_position() return self._min_position end 539 | function slider:get_max_position() return self._max_position end 540 | 541 | function slider:get_size() 542 | return self.max_position - self.min_position 543 | end 544 | 545 | function slider:set_size(size) 546 | self.max_position = self.min_position + size 547 | end 548 | 549 | function slider:set_min_position(pos) 550 | self._min_position = pos 551 | self.position = self.position --clamp it 552 | end 553 | 554 | function slider:set_max_position(pos) 555 | self._max_position = pos 556 | self.position = self.position --clamp it 557 | end 558 | 559 | function slider:get_position() 560 | return self._position 561 | end 562 | 563 | function slider:set_position(pos) 564 | self._position = pos 565 | if self:isinstance() then 566 | self._position = self:nearest_position(pos) 567 | self.pin.position = self._position 568 | local text = string.format(self.tip.format, self._position) 569 | self.tip:transition('text', text) 570 | end 571 | end 572 | slider:track_changes'position' 573 | 574 | slider:init_ignore{min_position=1, max_position=1, size=1, position=1, progress=1} 575 | 576 | function slider:after_init(t) 577 | local pin_fields = self.pin 578 | self.track = self:create_track() 579 | self.fill = self:create_fill() 580 | self.marker = self:create_marker() 581 | self.pin = self:create_pin() 582 | self.drag_pin = self:create_drag_pin(pin_fields) 583 | self.tip = self:create_tip() 584 | if self.step_labels then 585 | for text, pos in pairs(self.step_labels) do 586 | if type(text) == 'number' then 587 | pos, text = text, pos 588 | end 589 | self:create_step_label(text, pos) 590 | end 591 | end 592 | self._min_position = t.min_position 593 | self._max_position = t.max_position or (self._min_position + t.size) 594 | assert(self.min_position) 595 | assert(self.max_position) 596 | if t.progress then 597 | self._position = lerp(t.progress, 0, 1, self:position_range()) 598 | else 599 | self._position = t.position 600 | end 601 | self._position = self:nearest_position(self._position) 602 | end 603 | 604 | --toggle-button -------------------------------------------------------------- 605 | 606 | local toggle = slider:subclass'toggle' 607 | ui.toggle = toggle 608 | 609 | toggle.step = 1 610 | toggle.size = 1 611 | toggle.w = 26 612 | toggle.min_cw = 26 613 | toggle.step_line_color = false 614 | toggle.tip = {visible = false} 615 | toggle.marker = {visible = false} 616 | 617 | ui:style('toggle :on > slider_pin', { 618 | background_color = '#fff', 619 | }) 620 | 621 | ui:style('toggle :on > slider_fill', { 622 | background_color = '#fff', 623 | }) 624 | 625 | function toggle:after_set_value(on) 626 | self.position = on and 1 or 0 627 | self:settag(':on', on) 628 | self:fire(on and 'option_enabled' or 'option_disabled') 629 | end 630 | toggle:track_changes'value' 631 | 632 | function toggle:after_set_position() 633 | self.value = self.position == 1 634 | end 635 | 636 | function toggle:after_init() 637 | self.pin.drag_threshold = 1 --don't grab the pin right away 638 | self.pin:on('mouseup', function(pin) 639 | if pin.dragging then return end 640 | pin.animate = true 641 | self.position = 1 - self.position 642 | pin.animate = false 643 | end) 644 | end 645 | 646 | --demo ----------------------------------------------------------------------- 647 | 648 | if not ... then require('ui0_demo')(function(ui, win) 649 | 650 | print(win.view.layout) 651 | win.view.item_align_x = 'left' 652 | 653 | ui:slider{ 654 | min_cw = 600, parent = win, 655 | position = 3, size = 10, 656 | step_labels = {Low = 0, Medium = 5, High = 10}, 657 | --pin = {style = {transition_duration = 2}}, 658 | step = 2, 659 | --snap_to_labels = false, 660 | } 661 | 662 | ui:slider{ 663 | min_cw = 200, parent = win, 664 | position = 0, 665 | min_position = 1.3, 666 | max_position = 8.3, 667 | step_start = .5, 668 | step = 2, 669 | } 670 | 671 | ui:slider{ 672 | min_cw = 200, parent = win, 673 | progress = .3, 674 | size = 1, 675 | } 676 | 677 | ui:toggle{ 678 | parent = win, 679 | option_changed = function(self, enabled) 680 | print(enabled and 'enabled' or 'disabled') 681 | end, 682 | } 683 | 684 | end) end 685 | -------------------------------------------------------------------------------- /ui0_tablist.lua: -------------------------------------------------------------------------------- 1 | 2 | --Tab and Tablist widgets. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | local box2d = require'box2d' 8 | require'ui0_button' 9 | 10 | local indexof = glue.indexof 11 | local clamp = glue.clamp 12 | local round = glue.round 13 | 14 | --tab ------------------------------------------------------------------------ 15 | 16 | local tab = ui.layer:subclass'tab' 17 | ui.tab = tab 18 | tab.iswidget = true 19 | 20 | --tablist property: removing the selected tab selects the previous tab. 21 | --adding a tab selects the tab based on its stored selected state. 22 | 23 | tab.tablist = false 24 | tab:stored_property'tablist' 25 | function tab:before_set_tablist() 26 | if self.tablist then 27 | self.tablist:_remove_tab(self) 28 | end 29 | end 30 | function tab:after_set_tablist() 31 | if self.tablist then 32 | self.parent = self.tablist 33 | self.tablist:_add_tab(self, self._index) 34 | self.selected = self._selected 35 | end 36 | end 37 | tab:nochange_barrier'tablist' 38 | 39 | --index property: get/set a tab's positional index. 40 | 41 | tab._index = 1/0 --add to the tablist tail 42 | 43 | function tab:get_index() 44 | return self.tablist and self.tablist:tab_index(self) or self._index 45 | end 46 | 47 | function tab:set_index(index) 48 | if self.tablist then 49 | self.tablist:_move_tab(self, index) 50 | else 51 | self._index = index 52 | end 53 | end 54 | 55 | --visible state: hiding the selected tab selects the previous tab. 56 | 57 | tab._visible = tab.visible 58 | 59 | function tab:get_visible(visible) 60 | return self._visible 61 | end 62 | 63 | function tab:set_visible(visible) 64 | if self.tablist then 65 | if not visible then 66 | local prev_tab = self.tablist:prev_tab(self) 67 | if prev_tab then 68 | prev_tab:select() 69 | else 70 | self:unselect() 71 | end 72 | end 73 | self._visible = visible 74 | self.tablist:sync_tabs() 75 | end 76 | end 77 | tab:nochange_barrier'visible' 78 | 79 | --close() method for decoupling visibility from closing. 80 | 81 | function tab:close() 82 | if not self.closeable then return end 83 | if self:fire'closing' ~= false then 84 | self.visible = false 85 | self:fire'closed' 86 | end 87 | end 88 | 89 | --selected property: selecting a tab unselects the previously selected tab. 90 | 91 | tab._selected = false 92 | 93 | function tab:get_selected() 94 | if self.tablist then 95 | return self.tablist.selected_tab == self 96 | else 97 | return self._selected 98 | end 99 | end 100 | 101 | function tab:set_selected(selected) 102 | if selected then 103 | self:select() 104 | else 105 | self:unselect() 106 | end 107 | end 108 | 109 | function tab:select() 110 | if not self.tablist then 111 | self._selected = true 112 | return 113 | elseif not self.visible or not self.enabled then 114 | return 115 | end 116 | local stab = self.tablist.selected_tab 117 | if stab == self then return end 118 | if stab then 119 | stab:unselect() 120 | end 121 | self:settag(':selected', true) 122 | self:to_front() 123 | self:fire'tab_selected' 124 | self.parent:fire('tab_selected', self) 125 | self.tablist._selected_tab = self 126 | end 127 | 128 | function tab:unselect() 129 | if not self.tablist then 130 | self._selected = false 131 | return 132 | end 133 | local stab = self.tablist.selected_tab 134 | if stab ~= self then return end 135 | stab:settag(':selected', false) 136 | stab:fire'tab_unselected' 137 | self.tablist:fire('tab_unselected', stab) 138 | self.tablist._selected_tab = false 139 | end 140 | 141 | --init 142 | 143 | tab:init_ignore{tablist=1, visible=1, selected=1} 144 | 145 | function tab:after_init(t) 146 | if t.tablist then 147 | self.tablist = t.tablist 148 | end 149 | self.visible = t.visible 150 | self.selected = t.selected 151 | end 152 | 153 | function tab:before_free() 154 | self.tablist = false 155 | end 156 | 157 | --mouse interaction: drag & drop 158 | 159 | tab.mousedown_activate = true 160 | tab.draggable = true 161 | 162 | function tab:activated() 163 | self:select() 164 | end 165 | 166 | function tab:start_drag(button, mx, my, area) 167 | if area ~= 'header' then return end 168 | self.origin_tablist = self.tablist 169 | self.origin_tab_x = self.tab_x 170 | self.origin_tab_h = self.tab_h 171 | self.origin_index = self.index 172 | return self 173 | end 174 | 175 | function tab:drag(dx, dy) 176 | if self.tablist then 177 | local x = self.origin_tab_x + dx 178 | local vi = self.tablist:tab_visual_index_by_pos(x) 179 | self.index = self.tablist:tab_index_by_visual_index(vi) 180 | self:transition('tab_x', self.tablist:clamp_tab_pos(x), 0) 181 | self:transition('tab_w', self.tablist.live_tab_w) 182 | local x, w = self:snapxw(0, self.tablist.w) 183 | local y = self.tablist.tabs_side == 'top' and self.tab_h or 0 184 | local y, h = self:snapyh(y, self.tablist.ch - self.tablist.tab_h) 185 | self:transition('x', x, 0) 186 | self:transition('y', y, 0) 187 | self:transition('w', w, 0) 188 | self:transition('h', h, 0) 189 | else 190 | local x = self:snapx(self.x + dx) 191 | local y = self:snapy(self.y + dy) 192 | self:transition('tab_x', self.origin_tab_x, 0) 193 | self:transition('tab_h', self.origin_tab_h, 0) 194 | self:transition('x', x, 0) 195 | self:transition('y', y, 0) 196 | end 197 | end 198 | 199 | tab.draggable_outside = true --can be dragged out of the original tablist 200 | 201 | function tab:accept_drop_widget(widget) 202 | return self.draggable_outside or widget == self.origin_tablist 203 | end 204 | 205 | function tab:enter_drop_target(tablist) 206 | if not tab.draggable_outside and tablist ~= self.origin_tablist then 207 | return 208 | end 209 | self.tablist = tablist 210 | self:select() 211 | end 212 | 213 | function tab:leave_drop_target(tablist) 214 | if not tab.draggable_outside and tablist == self.origin_tablist then 215 | return 216 | end 217 | self.tablist = false 218 | self.parent = tablist.window 219 | self:to_front() 220 | end 221 | 222 | function tab:ended_dragging() 223 | if self.origin_tablist then 224 | self.x, self.y = self:to_other(self.origin_tablist, 0, 0) 225 | self.tablist = self.origin_tablist 226 | self.index = self.origin_index 227 | self:select() 228 | end 229 | self.origin_tablist = false 230 | self.origin_tab_x = false 231 | self.origin_index = false 232 | self.tablist:sync_tabs() 233 | end 234 | 235 | --mouse interaction: close-on-doubleclick 236 | 237 | tab.max_click_chain = 2 238 | 239 | function tab:doubleclick() 240 | self:close() 241 | end 242 | 243 | --keyboard interaction 244 | 245 | tab.focusable = true 246 | 247 | function tab:keypress(key) 248 | if key == 'enter' or key == 'space' then 249 | self:select() 250 | return true 251 | elseif key == 'left' or key == 'right' then 252 | local next_tab = self.tablist:next_tab(self, 253 | key == 'right' and 'next_index' or 'prev_index') 254 | if next_tab then 255 | next_tab:focus() 256 | end 257 | return true 258 | end 259 | end 260 | 261 | --drawing & hit-testing 262 | 263 | tab.clip_content = 'background' --TODO: find a way to set this to true 264 | tab.border_width = 1 265 | tab.border_color = '#222' 266 | tab.background_color = '#111' 267 | tab.padding = 5 268 | tab.corner_radius = 5 269 | 270 | ui:style('tab', { 271 | transition_tab_x = true, 272 | transition_tab_w = true, 273 | transition_duration_tab_x = .2, 274 | transition_duration_tab_w = .2, 275 | transition_x = true, 276 | transition_y = true, 277 | transition_w = true, 278 | transition_h = true, 279 | transition_duration_x = .5, 280 | transition_duration_y = .5, 281 | transition_duration_w = .5, 282 | transition_duration_h = .5, 283 | }) 284 | 285 | ui:style('tab :hot', { 286 | background_color = '#181818', 287 | transition_background_color = true, 288 | transition_duration = .2, 289 | }) 290 | 291 | ui:style('tab :selected', { 292 | background_color = '#222', 293 | border_color = '#333', 294 | transition_duration = 0, 295 | }) 296 | 297 | ui:style('tab :focused', { 298 | border_color = '#666', 299 | }) 300 | 301 | ui:style('tab :dragging', { 302 | opacity = .5, 303 | }) 304 | 305 | ui:style('tab :dropping', { 306 | opacity = 1, 307 | }) 308 | 309 | function tab:slant_widths() 310 | local h = self.tab_h 311 | local tablist = self.tablist or self.origin_tablist 312 | local s = tablist.tab_slant 313 | local sl = math.rad(tablist.tab_slant_left or s) 314 | local sr = math.rad(tablist.tab_slant_right or s) 315 | local wl = h / math.tan(sl) 316 | local wr = h / math.tan(sr) 317 | return wl, wr, sl, sr 318 | end 319 | 320 | local sides = {'top', 'right', 'bottom', 'left'} 321 | function tab:border_line_to(cr, x0, y0, q) 322 | local tablist = self.tablist or self.origin_tablist 323 | local side = q and sides[q] or tablist.tabs_side 324 | if side ~= tablist.tabs_side then return end 325 | local w, h = self.tab_w, self.tab_h 326 | local wl, wr, sl, sr = self:slant_widths() 327 | local r = tablist.tab_corner_radius 328 | local x = self.tab_x 329 | local x1 = x + wl 330 | local x2 = x + w - wr 331 | local x3 = x + w 332 | local x4 = x + 0 333 | local y = y0 334 | local y1 = y - h 335 | local y2 = y - h 336 | local y3 = y + 0 337 | local y4 = y + 0 338 | cr:save() 339 | if side == 'top' then 340 | --nothing 341 | elseif side == 'bottom' then 342 | cr:rotate_around(x + w / 2, y, math.pi) 343 | elseif side == 'left' then 344 | --TODO: 345 | elseif side == 'right' then 346 | --TODO: 347 | else 348 | self:warn('invalid tabs_side: %s', side) 349 | end 350 | if r > 0 then 351 | local q = math.pi/2 352 | local sl = q - sl 353 | local sr = q - sr 354 | local xl = math.tan(sl / 2) * r 355 | local xr = math.tan(sr / 2) * r 356 | cr:arc_negative(x4-r+xl, y4-r, r, q*1 , q*0+sl) 357 | cr:arc (x1+r-xl, y1+r, r, q*2+sl, q*3 ) 358 | cr:arc (x2-r+xr, y2+r, r, q*3 , q*4-sr) 359 | cr:arc_negative(x3+r-xr, y3-r, r, q*2-sr, q*1 ) 360 | else 361 | cr:line_to(x4, y4) 362 | cr:line_to(x1, y1) 363 | cr:line_to(x2, y2) 364 | cr:line_to(x3, y3) 365 | end 366 | cr:restore() 367 | end 368 | 369 | function tab:override_hit_test(inherited, x, y, ...) 370 | local widget, area = inherited(self, x, y, ...) 371 | if widget == self and area == 'background' and self.tablist then 372 | if box2d.hit(x, y, self.tablist:header_rect()) then 373 | return self, 'header' 374 | end 375 | end 376 | return widget, area 377 | end 378 | 379 | function tab:get_occluded() 380 | return not ( 381 | not self.tablist --dragging 382 | or self.selected --selected 383 | or self.tablist.foreground_fixed_tab == self --underneath dragging 384 | ) 385 | end 386 | 387 | --skip drawing the hidden part of the tab (notably it's background which can 388 | --be large) if we know for sure that the tab contents are entirely occluded. 389 | function tab:override_border_path(inherited, cr, ...) 390 | if not self.occluded then 391 | return inherited(self, cr, ...) 392 | end 393 | local x1, y1, w, h = self:border_rect(...) 394 | local x2, y2 = x1 + w, y1 + h 395 | local side = (self.tablist or self.origin_tablist).tabs_side 396 | if side == 'bottom' then 397 | y1 = y2 398 | elseif side == 'right' then 399 | --TODO: 400 | elseif side == 'left' then 401 | --TODO: 402 | end 403 | self:border_line_to(cr, x1, y1) 404 | cr:close_path() 405 | end 406 | 407 | --skip drawing the tab's contents if we know that they are entirely occluded. 408 | function tab:draw_children(cr) 409 | local occluded = self.occluded 410 | for i = 1, #self do 411 | local layer = self[i] 412 | if not occluded or layer == self.title or layer == self.close_button then 413 | layer:draw(cr) 414 | end 415 | end 416 | end 417 | 418 | --title 419 | 420 | local title = ui.layer:subclass'tab_title' 421 | tab.title_class = title 422 | 423 | title.text_align_x = 'left' 424 | title.padding_left = 2 425 | title.padding_right = 2 426 | title.text_color = '#ccc' 427 | title.nowrap = true 428 | title.activable = false 429 | title.clip_content = true 430 | 431 | ui:style('tab_title :focused', { 432 | font_weight = 'bold', 433 | }) 434 | 435 | function tab:create_title(title) 436 | return self.title_class(self.ui, { 437 | parent = self, 438 | iswidget = false, 439 | tab = self, 440 | }, self.title, title) 441 | end 442 | 443 | function tab:after_init() 444 | self.title = self:create_title() 445 | end 446 | 447 | function tab:before_sync_layout_children() 448 | local tablist = self.tablist or self.origin_tablist 449 | local t = self.title 450 | local wl, wr = self:slant_widths() 451 | local p = self.padding 452 | t.x = self.tab_x - (self.padding_left or p) + wl + tablist.title_margin 453 | if tablist.tabs_side == 'top' then 454 | t.y = self:snapy(-self.tab_h - (self.padding_top or p)) 455 | elseif tablist.tabs_side == 'bottom' then 456 | t.y = self:snapy(self.h - (self.padding_bottom or p)) 457 | end 458 | t.w = self.close_button.x - t.x 459 | t.h = self.tab_h 460 | end 461 | 462 | --close button 463 | 464 | tab.closeable = true --show close button and receive 'closing' event 465 | 466 | local xbutton = ui.button:subclass'tab_close_button' 467 | tab.close_button_class = xbutton 468 | 469 | xbutton.font = 'Ionicons,13' 470 | xbutton.text = '\u{f2c0}' 471 | xbutton.layout = false 472 | xbutton.w = 14 473 | xbutton.h = 14 474 | xbutton.padding_left = 0 475 | xbutton.padding_right = 0 476 | xbutton.padding_top = 0 477 | xbutton.padding_bottom = 0 478 | xbutton.corner_radius = 100 479 | xbutton.corner_radius_kappa = 1 480 | xbutton.focusable = false 481 | xbutton.border_width = 0 482 | xbutton.background_color = false 483 | xbutton.text_color = '#999' 484 | 485 | ui:style([[ 486 | tab_close_button, 487 | tab_close_button :hot, 488 | tab_close_button !:enabled 489 | ]], { 490 | background_color = false, 491 | transition_background_color = false, 492 | }) 493 | 494 | ui:style('tab_close_button :hot', { 495 | text_color = '#ddd', 496 | background_color = '#a00', 497 | }) 498 | 499 | ui:style('tab_close_button :over', { 500 | text_color = '#fff', 501 | }) 502 | 503 | function xbutton:pressed() 504 | self.tab:close() 505 | end 506 | 507 | function tab:create_close_button(button) 508 | return self.close_button_class(self.ui, { 509 | parent = self, 510 | iswidget = false, 511 | tab = self, 512 | }, self.close_button, button) 513 | end 514 | 515 | function tab:after_init() 516 | self.close_button = self:create_close_button() 517 | end 518 | 519 | function tab:before_sync_layout_children() 520 | local xb = self.close_button 521 | local wl, wr = self:slant_widths() 522 | local p = self.padding 523 | xb.x = self.tab_x + self.tab_w - xb.w - wr 524 | - (self.padding_left or p) 525 | - (self.tablist or self.origin_tablist).close_button_margin 526 | local side = (self.tablist or self.origin_tablist).tabs_side 527 | if side == 'top' then 528 | xb.cy = -math.ceil(self.tab_h / 2) - (self.padding_top or p) 529 | elseif side == 'bottom' then 530 | xb.cy = self.h + math.ceil(self.tab_h / 2) - (self.padding_bottom or p) 531 | end 532 | xb.visible = self.closeable 533 | end 534 | 535 | --tablist -------------------------------------------------------------------- 536 | 537 | local tablist = ui.layer:subclass'tablist' 538 | ui.tablist = tablist 539 | 540 | --tabs list 541 | 542 | function tablist:tab_index(tab) 543 | return indexof(tab, self.tabs) 544 | end 545 | 546 | function tablist:clamped_tab_index(index, add) 547 | return clamp(index, 1, math.max(1, #self.tabs + (add and 1 or 0))) 548 | end 549 | 550 | function tablist:_add_tab(tab, index) 551 | index = self:clamped_tab_index(index, true) 552 | table.insert(self.tabs, index, tab) 553 | self:sync_tabs() 554 | end 555 | 556 | function tablist:_remove_tab(tab) 557 | local select_tab = tab.visible and tab.selected and self:prev_tab(tab) 558 | tab:unselect() 559 | table.remove(self.tabs, self:tab_index(tab)) 560 | self:sync_tabs() 561 | if select_tab then 562 | select_tab.selected = true 563 | end 564 | end 565 | 566 | function ui.layer:_move_tab(tab, index) 567 | local old_index = self:tab_index(tab) 568 | local new_index = self:clamped_tab_index(index) 569 | if old_index ~= new_index then 570 | table.remove(self.tabs, old_index) 571 | table.insert(self.tabs, new_index, tab) 572 | self:sync_tabs() 573 | end 574 | end 575 | 576 | tablist.tab_class = tab 577 | 578 | function tablist:tab(tab) 579 | if not tab.istab then 580 | local class = tab.class or self.tab_class 581 | assert(class.istab) 582 | tab = class(self.ui, self[class], tab, {tablist = self}) 583 | end 584 | return tab 585 | end 586 | 587 | tablist:init_ignore{tabs = 1} 588 | 589 | function tablist:after_init(t) 590 | self.tabs = {} --{tab1,...} 591 | if t.tabs then 592 | for _,tab in ipairs(t.tabs) do 593 | self:tab(tab) 594 | end 595 | end 596 | if not self.selected_tab then 597 | if self.selected_tab_index then 598 | self.selected_tab = self.tabs[self.selected_tab_index] 599 | else 600 | self.selected_tab = self:next_tab(nil, 'next_index') 601 | end 602 | end 603 | end 604 | 605 | --selected tab state 606 | 607 | tablist._selected_tab = false 608 | 609 | function tablist:get_selected_tab() 610 | return self._selected_tab 611 | end 612 | 613 | function tablist:set_selected_tab(tab) 614 | if tab then 615 | assert(tab.tablist == self) 616 | tab:select() 617 | elseif self.selected_tab then 618 | self.selected_tab:unselect() 619 | end 620 | end 621 | 622 | --foreground tab that is either the selected tab or the tab underneath 623 | --the selected tab, if the selected tab is not yet in place (moving). 624 | function tablist:get_foreground_fixed_tab() 625 | local stab = self.selected_tab 626 | local moving_tab = stab 627 | and (stab:end_value'x' ~= stab.x 628 | or stab:end_value'y' ~= stab.y) 629 | and stab 630 | if stab and not moving_tab then 631 | return stab 632 | end 633 | for i = #self, 1, -1 do 634 | local tab = self[i] 635 | if tab.istab and tab.visible and tab ~= moving_tab then 636 | return tab 637 | end 638 | end 639 | end 640 | 641 | --visible tabs list 642 | 643 | function tablist:visible_tab_count() 644 | local n = 0 645 | for _,tab in ipairs(self.tabs) do 646 | if tab.visible then n = n + 1 end 647 | end 648 | return n 649 | end 650 | 651 | tablist.last_selected_order = false 652 | 653 | --modes: next_index, prev_index, next_layer_index, prev_layer_index. 654 | function tablist:next_tab(from_tab, mode, rotate, include_dragging) 655 | if mode == nil then 656 | mode = true 657 | end 658 | if type(mode) == 'boolean' then 659 | if self.last_selected_order then 660 | mode = not mode 661 | end 662 | mode = (mode and 'next' or 'prev') 663 | .. (self.last_selected_order and '_layer' or '') .. '_index' 664 | end 665 | 666 | local forward = mode:find'next' 667 | local tabs = mode:find'layer' and self or self.tabs 668 | 669 | local i0, i1, step = 1, #tabs, 1 670 | if not forward then 671 | i0, i1, step = i1, i0, -step 672 | end 673 | if from_tab then 674 | local index_field = tabs == self and 'layer_index' or 'index' 675 | i0 = from_tab[index_field] + (forward and 1 or -1) 676 | end 677 | for i = i0, i1, step do 678 | local tab = tabs[i] 679 | if tab.istab and tab.visible and tab.enabled 680 | and (include_dragging or not tab.dragging) 681 | then 682 | return tab 683 | end 684 | end 685 | if rotate then 686 | return self:next_tab(nil, mode) 687 | end 688 | end 689 | 690 | function tablist:prev_tab(tab) 691 | local stab = self.selected_tab 692 | local prev_tab 693 | if stab == tab then 694 | local is_first = (stab == self:next_tab(nil, 'next_index', nil, true)) 695 | local mode = self.last_selected_order and 'prev_layer_index' 696 | or (is_first and 'next_index' or 'prev_index') 697 | prev_tab = self:next_tab(tab, mode, nil, true) 698 | end 699 | return prev_tab 700 | end 701 | 702 | --keyboard interaction 703 | 704 | tablist.main_tablist = true --responds to tab/ctrl+tab globally 705 | 706 | function tablist:after_init() 707 | self.window:on({'keypress', self}, function(win, key) 708 | if self.main_tablist then 709 | if key == 'tab' and self.ui:key'ctrl' then 710 | local shift = self.ui:key'shift' 711 | local tab = self:next_tab(self.selected_tab, not shift, true) 712 | if tab then 713 | tab.selected = true 714 | end 715 | return true 716 | elseif self.selected_tab and key == 'W' and self.ui:key'ctrl' then 717 | self.selected_tab:close() 718 | return true 719 | end 720 | end 721 | end) 722 | end 723 | 724 | --drag & drop 725 | 726 | tablist.tablist_group = false 727 | 728 | function tablist:accept_drag_widget(widget, mx, my, area) 729 | if widget.istab then 730 | local group = widget.origin_tablist.tablist_group 731 | if not group or group == self.tablist_group then 732 | return not mx or area == 'header' 733 | end 734 | end 735 | end 736 | 737 | function tablist:drop(widget, mx, my, area) 738 | widget.origin_tablist = false 739 | end 740 | 741 | --drawing & hit-testing 742 | 743 | tablist.w = 400 744 | tablist.h = 400 745 | tablist.tab_h = 26 746 | tablist.tab_w = 150 747 | tablist.min_tab_w = 10 748 | tablist.tab_spacing = -10 749 | tablist.tab_slant = 70 --degrees 750 | tablist.tab_slant_left = false 751 | tablist.tab_slant_right = false 752 | tablist.tab_corner_radius = 0 753 | tablist.tabs_padding = 10 754 | tablist.tabs_padding_left = false 755 | tablist.tabs_padding_right = false 756 | tablist.tabs_side = 'top' --top, bottom, left, right 757 | 758 | tablist.title_margin = 4 759 | tablist.close_button_margin = 4 760 | 761 | function tablist:clamp_tab_pos(x) 762 | local p = self.tabs_padding 763 | return clamp(x, 764 | self.tabs_padding_left or p, 765 | self.w - self.live_tab_w - (self.tabs_padding_right or p)) 766 | end 767 | 768 | function tablist:sync_live_tab_w() 769 | local n = self:visible_tab_count() 770 | local w = self.w 771 | - (self.tabs_padding_left or self.tabs_padding) 772 | - (self.tabs_padding_right or self.tabs_padding) 773 | + self.tab_spacing * n 774 | local tw = math.min(self.tab_w + self.tab_spacing, math.floor(w / n)) 775 | local s = self.tab_slant 776 | local sl = self.tab_slant_left or s 777 | local sr = self.tab_slant_right or s 778 | local wl = self.tab_h / math.tan(math.rad(sl)) 779 | local wr = self.tab_h / math.tan(math.rad(sr)) 780 | local min_tw = wl + wr + self.min_tab_w 781 | self.live_tab_w = math.max(tw, min_tw) 782 | end 783 | 784 | function tablist:tab_pos_by_visual_index(index) 785 | return (self.tabs_padding_left or self.tabs_padding) + 786 | (index - 1) * (self.live_tab_w + self.tab_spacing) 787 | end 788 | 789 | function tablist:tab_visual_index_by_pos(x) 790 | local x = x - (self.tabs_padding_left or self.tabs_padding) 791 | return round(x / (self.live_tab_w + self.tab_spacing)) + 1 792 | end 793 | 794 | function tablist:tab_index_by_visual_index(vi) 795 | vi = math.max(1, vi) 796 | local vi1 = 1 797 | for i,tab in ipairs(self.tabs) do 798 | if tab.visible then 799 | if vi1 == vi then 800 | return i 801 | end 802 | vi1 = vi1 + 1 803 | end 804 | end 805 | return #self.tabs 806 | end 807 | 808 | function tablist:sync_tabs(...) 809 | self:sync_live_tab_w() 810 | local tab_w = self.live_tab_w 811 | local vi = 1 812 | local side = self.tabs_side 813 | for i,tab in ipairs(self.tabs) do 814 | if tab.visible then 815 | local x, w = self:snapxw(0, self.w) 816 | local y, h = self:snapyh( 817 | side == 'top' and self.tab_h or 0, 818 | self.h - self.tab_h) 819 | if not tab.dragging then 820 | tab:transition('tab_x', self:tab_pos_by_visual_index(vi), ...) 821 | tab:transition('tab_w', tab_w, ...) 822 | tab:transition('x', x, ...) 823 | tab:transition('y', y, ...) 824 | end 825 | tab:transition('w', w, ...) 826 | tab:transition('h', h, ...) 827 | tab:transition('tab_h', self.tab_h, ...) 828 | vi = vi + 1 829 | tab:sync_transitions() 830 | end 831 | end 832 | end 833 | 834 | function tablist:after_sync_layout() 835 | self:sync_tabs(0, nil, nil, nil, nil, 'replace_value') 836 | end 837 | 838 | function tablist:header_rect() --in content space 839 | local side = self.tabs_side 840 | if side == 'top' then 841 | return 0, 0, self.cw, self.tab_h 842 | elseif side == 'bottom' then 843 | return 0, self.ch - self.tab_h, self.cw, self.ch 844 | end 845 | end 846 | 847 | function tablist:override_hit_test(inherited, x, y, ...) --in parent content space 848 | local widget, area = inherited(self, x, y, ...) 849 | if widget == self and area == 'background' then 850 | local x, y = self:from_parent_to_box(x, y) 851 | local x, y = self:to_content(x, y) 852 | if box2d.hit(x, y, self:header_rect()) then 853 | return self, 'header' 854 | end 855 | end 856 | return widget, area 857 | end 858 | 859 | --demo ----------------------------------------------------------------------- 860 | 861 | if not ... then require('ui0_demo')(function(ui, win) 862 | 863 | local color = require'color' 864 | 865 | win.view.grid_flow = 'x' 866 | win.view.grid_gap = 10 867 | win.view.grid_wrap = 2 868 | win.view.grid_min_lines = 2 869 | 870 | local w = (win.view.cw - 10) / 2 871 | local h = win.view.ch 872 | 873 | local tl1 = { 874 | tab_slant = 80, 875 | tab_corner_radius = 10, 876 | tabs_padding = 20, 877 | tab_h = 36, 878 | w = w, h = h, 879 | parent = win, 880 | tabs = {}, 881 | } 882 | 883 | local tl2 = { 884 | x = w + 10, w = w, h = h / 2, 885 | tab_slant_right = 80, 886 | parent = win, 887 | tabs = {}, 888 | tabs_side = 'bottom', 889 | } 890 | 891 | for i = 1, 10 do 892 | local visible = i ~= 3 and i ~= 8 893 | local enabled = i ~= 4 and i ~= 7 894 | local selected = i == 1 or i == 2 895 | local layer_index = 1 896 | local closeable = i ~= 5 897 | 898 | local tl = i % 2 == 0 and tl1 or tl2 899 | 900 | table.insert(tl.tabs, { 901 | tags = 'tab'..i, 902 | --index = 1, 903 | layer_index = layer_index, 904 | style = { 905 | font_slant = 'normal', 906 | }, 907 | title = {text = 'Tab '..i}, 908 | visible = visible, 909 | --selected = selected, 910 | enabled = enabled, 911 | closeable = closeable, 912 | closed = function(self) 913 | self:free() 914 | end, 915 | }) 916 | 917 | end 918 | 919 | tl1 = ui:tablist(tl1) 920 | tl2 = ui:tablist(tl2) 921 | 922 | end) end 923 | -------------------------------------------------------------------------------- /ui0_todo.txt: -------------------------------------------------------------------------------- 1 | PRIORITY --------------------------------------------------------------------- 2 | 3 | - REVIEW: dropdown! 4 | - REWRITE: colorpicker with grid/flexbox. 5 | 6 | LOW-LEVEL -------------------------------------------------------------------- 7 | 8 | - finish libpng 9 | - revive imagefile (libjpeg, libpng, bmp) 10 | - revive sg_* 11 | 12 | NW --------------------------------------------------------------------------- 13 | 14 | - BUG parent doesn't get repainted right after closing a child popup. 15 | - FEAT get a window's children in z-order so we can forward mouse wheel events 16 | to non-activable children. 17 | 18 | TR --------------------------------------------------------------------------- 19 | 20 | - tr: store paragraph base direction for all paragraphs. 21 | - text_run.align_x <- override for all enclosed lines/paragraphs. 22 | 23 | UI --------------------------------------------------------------------------- 24 | 25 | - design bug: widget module autoloading vs css decl. order conflict. 26 | - design bug: allow freeing self inside events (with a double-free barrier). 27 | 28 | EDITBOX ---------------------------------------------------------------------- 29 | 30 | - mask: 31 | - select/navigate text with the mask or without 32 | - 0 digit required 33 | - 9 digit or space, optional 34 | - # digit or space or +/- 35 | - L a-zA-Z 36 | 37 | - eye_icon for password mask. 38 | 39 | - make the caret a layer so it can be styled. 40 | --make selection rectangles layers so they can be styled. 41 | --drag & drop selection in/out of the editor and between editboxes. 42 | 43 | - IME integration. 44 | 45 | DROPDOWN --------------------------------------------------------------------- 46 | 47 | - maskedit dropdown with calendar. 48 | 49 | - autocomplete mode: while typing, the pickup grid filters the results 50 | and the editbox contain the rest of the text selected. 51 | 52 | TABLIST ---------------------------------------------------------------------- 53 | 54 | - vertical left/right 55 | 56 | BUTTON ----------------------------------------------------------------------- 57 | 58 | - profiles: text, outlined, contained, toggle 59 | 60 | - icon, icon_align left/right 61 | 62 | CHECKBOX --------------------------------------------------------------------- 63 | 64 | - tristate 65 | 66 | - children, indent 67 | 68 | SLIDER ----------------------------------------------------------------------- 69 | 70 | - label and/or numeric editbox on the left side or right side. 71 | 72 | CALENDAR --------------------------------------------------------------------- 73 | 74 | - review/add a date-math lib to luapower 75 | 76 | - make week start day configurable (ui.week_start_day = 'Mo' / 'Su') 77 | - make sunday column movable but only to position 1 or 7 78 | - add month & year above with left-right nav buttons 79 | - change month by keyboard left-right page-up/down navigation 80 | - change start week (scroll weeks vertically) by keyboard up/down navigation 81 | - scroll with scroll wheel too 82 | - alternate cell colors on consecutive months 83 | - change title to reflect month-in-view and surrounding months 84 | - we're always viewing 2 or 3 months 85 | - multi-cell select restricted to consecutive days 86 | - make it work with vertical scrolling as well 87 | - allow multiple restricted multi-cell selections 88 | - left bar with week-of-the-year number 89 | 90 | COLOR PICKER ----------------------------------------------------------------- 91 | 92 | - editboxes: hsL 93 | - parse the text and change the display accordingly 94 | - display chosen color + complementary hues 95 | - color history / select color from history 96 | - history name so we can have diff. histories depending on usage context 97 | - color dropper tool from anywhere on the desktop 98 | - hue wheel with configurable granularity + lum/sat triangle 99 | - hue/sat square with lum ramp instead of sat/lum with hue ramp 100 | 101 | MENU BAR --------------------------------------------------------------------- 102 | 103 | - 104 | 105 | MENU ------------------------------------------------------------------------- 106 | 107 | - 108 | 109 | POPUP MENU ------------------------------------------------------------------- 110 | 111 | - 112 | 113 | LINEAR CALENDAR -------------------------------------------------------------- 114 | 115 | - 116 | 117 | GRID ------------------------------------------------------------------------- 118 | 119 | 120 | - cell navigation: go to next/prev row on left-right nav 121 | - cell navigation: tab to advance cell 122 | 123 | --ctrl+page-up/page-down navigation based on fixed pages 124 | 125 | - multiple row move 126 | - row move with animation 127 | 128 | - col %-size 129 | 130 | - cell/col border collapse option 131 | 132 | - cell formatting: format-string/class/function 133 | 134 | - tooltip display on hover for clipped cells 135 | 136 | - editable 137 | - immediate mode (click) or click-click mode 138 | - tab goes to next cell 139 | - right/left goes to next/prev cell 140 | - automatic cell advancing 141 | - cell advancing with tab 142 | 143 | - tree-column 144 | - expand/collapse-all nodes option 145 | - row moving to another parent via drag & drop 146 | 147 | - col colapse/show 148 | 149 | - col shrink/expand-to-widest-row option on double-clicking between columns 150 | 151 | 152 | LATER/DATASET 153 | - sorting 154 | - sort by multiple columns 155 | ? client-side sorting with collation 156 | - filtering 157 | - quick filter (i.e. by value list) 158 | - search-in-column filter 159 | - result can be highlighting or hiding 160 | - filter by range 161 | - grouping: 162 | - group-by hierarchy 163 | - group-by multiple fields 164 | - expand/collapse all children 165 | - hide/show grouping header 166 | - invert selection 167 | - row moving to another group via drag&drop 168 | 169 | 170 | LATER/HARD 171 | - column bands 172 | - summary row 173 | - save/load grid configurations 174 | 175 | LATER/NEVER 176 | - auto row height based on wrapped text 177 | - multi-line cell text with word-wrapping 178 | - cell auto-ellipsis 179 | - vertical grid with bands 180 | 181 | 182 | DOCKING TABLIST -------------------------------------------------------------- 183 | 184 | - define docking layers and auto-accept tabs on the same docking_group. 185 | - drag tab to dock sides to split a dock tablist horizontally or vertically. 186 | 187 | - later: move tab outside its window to wrap in a popup. 188 | - later: move a popup over the window to dock it back. 189 | 190 | SETTINGS WIDGET -------------------------------------------------------------- 191 | 192 | - group options by category and show a scroll-following list of categories on the side. 193 | - filter/highlight options which have changed from default and show the default. 194 | - option type definitions for booleans, numbers, strings, multiple options etc. 195 | - boolean -> toggle, checkbox 196 | - number + range + step -> editbox, slider 197 | - single option -> choicebutton, slider, dropdown, radio buttons, grid with checkbox column 198 | - multiple options -> checkboxes 199 | - multiple options grouped -> checkbox tree 200 | - text -> editbox, multiline editbox 201 | - records -> grid, grid + CRUD, 202 | - color -> color picker 203 | - color scheme (i.e. list of colors) -> little wizard 204 | - embelishments: 205 | - description 206 | - warning popup when changing the option 207 | - 208 | - image 209 | - buttons 210 | - use an object with r/w properties to get/set the settings. 211 | - cascading hierarchy of options files: 212 | - "project" options file (look into dir and all parent dirs). 213 | - "home" options file (look in all HOME locations -- put that in fs or path). 214 | - other custom option files. 215 | - show options file hierarchy and select which file to affect changes to. 216 | 217 | 218 | RTL -------------------------------------------------------------------------- 219 | 220 | - menu bar right alignment and reverse item order 221 | - tablist right alignment and reverse item order 222 | - menu right alignment 223 | - button bar right alignment and mirror specific mirrorable icons 224 | - status bar reverse item order 225 | - use auto-reversible hierarchical flow layouts to layout: 226 | - label + actionable pairs, eg: 227 | - label + editbox combinations 228 | - icon + title + x-button on tablist items 229 | - a row of buttons 230 | - entire sections of the UI 231 | 232 | 233 | BETTER TAB-ORDER ALGORITHM --------------------------------------------------- 234 | 235 | --TODO: make a new tab-order-assigning algorithm based on horizontal and 236 | --vertical overlap between widgets and the vertical/horizontal distance 237 | --between them. 238 | 239 | --TIPS: make a weighted DAG from inspecting all positiblities 240 | --(that's O(n^2) btw) and then sort the nodes at each level based on the 241 | --weights and walk the graph in toplogical+weighted order. 242 | 243 | --[[ 244 | local function overlap( 245 | ax1, ay1, ax2, ay2, 246 | bx1, by1, bx2, by2, 247 | t1, t2 248 | ) 249 | local overlap_v = math.min(ay2, by2) - math.max(ay1, by1) 250 | local max_overlap_v = math.min(ay2 - ay1, by2 - by1) 251 | local distance_h = bx1 - ax2 252 | 253 | print( 254 | t1.id or t1.parent.id, 255 | t2.id or t2.parent.id, 256 | string.format('%.2f\t%.2f', overlap_v, distance_h), 257 | not (overlap_v < 0 or distance_h < 0) and '!!!' or '' 258 | ) 259 | 260 | if overlap_v < 0 or distance_h < 0 then 261 | return 0 262 | end 263 | assert(max_overlap_v > 0) 264 | 265 | return (overlap_v / max_overlap_v) / distance_h 266 | end 267 | 268 | print() 269 | print() 270 | print() 271 | 272 | if self.iswindow_view then 273 | for i,t in ipairs(t) do 274 | local x1, y1 = t:to_window(0, 0) 275 | local x2, y2 = t:to_window(t.w, t.h) 276 | print( 277 | t.id or t.parent.id, 278 | x1, y1 279 | ) 280 | end 281 | end 282 | 283 | ]] 284 | 285 | -------------------------------------------------------------------------------- /ui0_zoomcalendar.lua: -------------------------------------------------------------------------------- 1 | 2 | --Linear Zoom Calendar widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui0' 6 | local glue = require'glue' 7 | local lerp = glue.lerp 8 | local clamp = glue.clamp 9 | 10 | local cal = ui.layer:subclass'zoomcalendar' 11 | ui.zoomcalendar = cal 12 | 13 | cal.background_color = '#111' 14 | 15 | function cal:before_draw_content(cr) 16 | cr:save() 17 | cr:scale(1) 18 | 19 | cr:restore() 20 | end 21 | 22 | --demo ----------------------------------------------------------------------- 23 | 24 | if not ... then require('ui0_demo')(function(ui, win) 25 | 26 | local cal = ui:zoomcalendar{ 27 | x = 20, y = 20, 28 | w = 400, h = 100, 29 | parent = win, 30 | } 31 | 32 | end) end 33 | 34 | --------------------------------------------------------------------------------