├── ui.lua ├── ui.md ├── ui_button.lua ├── ui_calendar.lua ├── ui_colorpicker.lua ├── ui_demo.lua ├── ui_demo1.lua ├── ui_demo_bundle.bat ├── ui_dropdown.lua ├── ui_editbox.lua ├── ui_grid.lua ├── ui_layout_editor.lua ├── ui_layout_editor_app.lua ├── ui_list.lua ├── ui_menu.lua ├── ui_popup.lua ├── ui_progressbar.lua ├── ui_scrollbox.lua ├── ui_slider.lua ├── ui_tablist.lua ├── ui_todo.txt └── ui_zoomcalendar.lua /ui_button.lua: -------------------------------------------------------------------------------- 1 | 2 | --Button widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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.padding_left = 8 18 | button.padding_right = 8 19 | button.padding_top = 2 20 | button.padding_bottom = 2 21 | button.line_spacing = .9 22 | 23 | button.background_color = '#444' 24 | button.border_color = '#888' 25 | 26 | button.default = false 27 | button.cancel = false 28 | button.tags = 'standalone' 29 | 30 | ui:style('button', { 31 | transition_background_color = true, 32 | transition_border_color = true, 33 | transition_duration_background_color = .5, 34 | transition_duration_border_color = .5, 35 | }) 36 | 37 | ui:style('button :default', { 38 | background_color = '#092', 39 | }) 40 | 41 | ui:style('button :hot', { 42 | background_color = '#999', 43 | border_color = '#999', 44 | text_color = '#000', 45 | }) 46 | 47 | ui:style('button :default :hot', { 48 | background_color = '#3e3', 49 | }) 50 | 51 | ui:style('button !:enabled', { 52 | background_color = '#222', 53 | border_color = '#444', 54 | text_color = '#666', 55 | transition_duration = 0, 56 | }) 57 | 58 | ui:style('button :active :over', { 59 | background_color = '#fff', 60 | text_color = '#000', 61 | shadow_blur = 2, 62 | shadow_color = '#666', 63 | transition_duration = 0.2, 64 | }) 65 | 66 | ui:style('button :default :active :over', { 67 | background_color = '#9f7', 68 | }) 69 | 70 | ui:style('button :focused', { 71 | border_color = '#fff', 72 | border_width = 1, 73 | shadow_blur = 3, 74 | shadow_color = '#666', 75 | }) 76 | 77 | --button style profiles 78 | 79 | button.profile = false 80 | 81 | ui:style('button profile=text', { 82 | background_type = false, 83 | border_width = 0, 84 | text_color = '#999', 85 | }) 86 | 87 | ui:style('button profile=text :hot', { 88 | text_color = '#ccc', 89 | }) 90 | 91 | ui:style('button profile=text :focused', { 92 | shadow_blur = 2, 93 | shadow_color = '#111', 94 | text_color = '#ccc', 95 | }) 96 | 97 | ui:style('button profile=text :active :over', { 98 | text_color = '#fff', 99 | shadow_blur = 2, 100 | shadow_color = '#111', 101 | }) 102 | 103 | button:stored_property'profile' 104 | function button:set_profile(new_profile, old_profile) 105 | if old_profile then self:settag('profile='..old_profile, false) end 106 | if new_profile then self:settag('profile='..new_profile, true) end 107 | end 108 | button:nochange_barrier'profile' --gives `old_profile` arg 109 | 110 | function button:press() 111 | if self:fire'pressed' ~= nil then 112 | return 113 | end 114 | if self.default then 115 | self.window:close'default' 116 | elseif self.cancel then 117 | self.window:close'cancel' 118 | end 119 | end 120 | 121 | function button:override_set_text(inherited, s) 122 | if not inherited(self, s) then return end 123 | if s == '' or not s then s = false end 124 | if not s then 125 | self._text = false 126 | self.underline_pos = false 127 | self.underline_text = false 128 | else 129 | s = s:gsub('&&', '\0') --TODO: hack 130 | local pos, key = s:match'()&(.)' 131 | self._text = s:gsub('&', ''):gsub('%z', '&') 132 | if key then 133 | self._key_from_text = key:upper() 134 | self.underline_pos = pos 135 | self.underline_text = key 136 | end 137 | end 138 | end 139 | 140 | button:stored_property('key', function(self, key) 141 | self.underline_pos = false 142 | end) 143 | 144 | function button:mousedown() 145 | if self.active_by_key then return end 146 | self.active = true 147 | end 148 | 149 | function button:mousemove(mx, my) 150 | if self.active_by_key then return end 151 | local mx, my = self:to_parent(mx, my) 152 | self:settag(':over', self.active and self:hit_test(mx, my, 'activate') == self) 153 | end 154 | 155 | function button:mouseup() 156 | if self.active_by_key then return end 157 | self.active = false 158 | if self.tags[':over'] then 159 | self:press() 160 | end 161 | end 162 | 163 | function button:activate_by_key(key) 164 | if self.active_by_key then return end 165 | self.active = true 166 | self.active_by_key = key 167 | self:settag(':over', true) 168 | return true 169 | end 170 | 171 | function button:keydown(key) 172 | if self.active_by_key then return end 173 | if key == 'enter' or key == 'space' then 174 | return self:activate_by_key(key) 175 | end 176 | end 177 | 178 | function button:keyup(key) 179 | if not self.active_by_key then return end 180 | local press = key == self.active_by_key 181 | if press or key == 'esc' then 182 | self:settag(':over', false) 183 | if press then 184 | self:press() 185 | if not self.ui then --window closed 186 | return true 187 | end 188 | end 189 | self.active = false 190 | self.active_by_key = false 191 | return true 192 | end 193 | end 194 | 195 | --default & cancel properties/tags 196 | 197 | button:stored_property('default', function(self, default) 198 | self:settag(':default', default) 199 | end) 200 | 201 | button:stored_property('cancel', function(self, cancel) 202 | self:settag(':cancel', cancel) 203 | end) 204 | 205 | function button:after_set_window(win) 206 | if not win then return end 207 | win:on({'keydown', self}, function(win, key) 208 | local self_key = self.key or self._key_from_text 209 | if self_key and self.ui:key(self_key) then 210 | return self:activate_by_key(key) 211 | elseif self.default and key == 'enter' then 212 | return self:activate_by_key(key) 213 | elseif self.cancel and key == 'esc' then 214 | return self:activate_by_key(key) 215 | end 216 | end) 217 | 218 | --if the button is not focusable, we need to catch keyups globally too 219 | win:on({'keyup', self}, function(win, key) 220 | return self:keyup(key) 221 | end) 222 | end 223 | 224 | --drawing 225 | 226 | function button:before_draw_text(cr) 227 | if not self:text_visible() then return end 228 | if not self.underline_pos then return end 229 | do return end --TODO: use the future hi-level text API to draw the underline 230 | --measure 231 | local x, y, w, h = self:text_bounding_box() --sets font 232 | local line_w = self.window:text_size(self.underline_text, self.text_multiline) 233 | local s = self.text:sub(1, self.underline_pos - 1) 234 | local line_pos = self.window:text_size(s, self.text_multiline) 235 | --draw 236 | cr:rgba(self.ui:rgba(self.text_color)) 237 | cr:line_width(1) 238 | cr:move_to(x + line_pos + 1, math.floor(y + h + 2) + .5) 239 | cr:rel_line_to(line_w - 2, 0) 240 | cr:stroke() 241 | end 242 | 243 | --checkbox ------------------------------------------------------------------- 244 | 245 | local checkbox = ui.layer:subclass'checkbox' 246 | ui.checkbox = checkbox 247 | 248 | checkbox.layout = 'flexbox' 249 | checkbox.item_align_y = 'baseline' 250 | checkbox.align_items_y = 'center' 251 | checkbox.align_y = 'center' 252 | 253 | --checked property 254 | 255 | checkbox.checked = false 256 | checkbox:stored_property('checked', function(self, checked) 257 | self.button.text = self.checked 258 | and self.button.text_checked 259 | or self.button.text_unchecked 260 | self:settag(':checked', checked) 261 | self:fire(checked and 'was_checked' or 'was_unchecked') 262 | end) 263 | function checkbox:override_set_checked(inherited, checked) 264 | if self:canset_checked(checked) then 265 | return inherited(self, checked) 266 | end 267 | end 268 | checkbox:track_changes'checked' 269 | 270 | function checkbox:canset_checked(checked) 271 | return 272 | self:fire(checked and 'checking' or 'unchecking') ~= false 273 | and self:fire('value_changing', self:value_for(checked), self.value) ~= false 274 | end 275 | 276 | function checkbox:toggle() 277 | self.checked = not self.checked 278 | end 279 | 280 | --value property 281 | 282 | checkbox.checked_value = true 283 | checkbox.unchecked_value = false 284 | 285 | function checkbox:value_for(checked) 286 | if checked then 287 | return self.checked_value 288 | else 289 | return self.unchecked_value 290 | end 291 | end 292 | 293 | function checkbox:checked_for(val) 294 | return val == self.checked_value 295 | end 296 | 297 | function checkbox:get_value() 298 | return self:value_for(self.checked) 299 | end 300 | 301 | function checkbox:set_value(val) 302 | self.checked = self:checked_for(val) 303 | end 304 | 305 | --align property 306 | 307 | checkbox.align = 'left' 308 | 309 | checkbox:stored_property('align', function(self, align) 310 | if align == 'right' then 311 | self.button:to_front() 312 | else 313 | self.button:to_back() 314 | end 315 | end) 316 | 317 | --check button 318 | 319 | local cbutton = ui.button:subclass'checkbox_button' 320 | checkbox.button_class = cbutton 321 | 322 | cbutton.font = 'Ionicons,16' 323 | cbutton.text_checked = '\u{f2bc}' 324 | cbutton.text_unchecked = '' 325 | 326 | cbutton.align_y = false 327 | cbutton.fr = 0 328 | cbutton.layout = false 329 | cbutton.min_cw = 16 330 | cbutton.min_ch = 16 331 | cbutton.padding_top = 0 332 | cbutton.padding_bottom = 0 333 | cbutton.padding_left = 0 334 | cbutton.padding_right = 0 335 | 336 | ui:style('checkbox_button :hot', { 337 | text_color = '#fff', 338 | background_color = '#777', 339 | }) 340 | 341 | ui:style('checkbox_button :active :over', { 342 | background_color = '#888', 343 | }) 344 | 345 | function cbutton:override_hit_test(inherited, mx, my, reason) 346 | local widget, area = inherited(self, mx, my, reason) 347 | if not widget then 348 | self:validate() 349 | local lbl = self.checkbox.label 350 | widget, area = lbl.super.super.hit_test(lbl, mx, my, reason) 351 | if widget then 352 | return self, 'label' 353 | end 354 | end 355 | return widget, area 356 | end 357 | 358 | function cbutton:checkbox_press() 359 | self.checkbox:toggle() 360 | end 361 | 362 | function cbutton:before_press() 363 | self:checkbox_press() 364 | end 365 | 366 | function checkbox:create_button() 367 | return self.button_class(self.ui, { 368 | parent = self, 369 | checkbox = self, 370 | iswidget = false, 371 | }, self.button) 372 | end 373 | 374 | --label 375 | 376 | local clabel = ui.layer:subclass'checkbox_label' 377 | checkbox.label_class = clabel 378 | 379 | clabel.layout = 'textbox' 380 | clabel.line_spacing = .6 381 | 382 | function clabel:hit_test(mx, my, reason) end --cbutton does it for us 383 | 384 | function clabel:after_sync_styles() 385 | local align = self.checkbox.align 386 | self.text_align_x = align 387 | local padding = self.checkbox.button.min_ch / 2 388 | self.padding_left = align == 'left' and padding or 0 389 | self.padding_right = align == 'right' and padding or 0 390 | end 391 | 392 | function checkbox:create_label() 393 | return self.label_class(self.ui, { 394 | parent = self, 395 | checkbox = self, 396 | iswidget = false, 397 | }, self.label) 398 | end 399 | 400 | checkbox:init_ignore{align=1, checked=1} 401 | 402 | function checkbox:after_init(t) 403 | self.button = self:create_button() 404 | self.label = self:create_label() 405 | self.align = t.align 406 | self.button:settag('standalone', false) 407 | self._checked = t --force setting of checked property 408 | self.checked = t.checked 409 | end 410 | 411 | --radio button --------------------------------------------------------------- 412 | 413 | local radio = ui.checkbox:subclass'radio' 414 | ui.radio = radio 415 | 416 | radio.radio_group = 'default' 417 | radio.item_align_y = 'center' 418 | 419 | radio:init_ignore{checked=1} 420 | 421 | function radio:override_canset_checked(inherited, checked) 422 | if not inherited(self, checked) then --refused 423 | return 424 | end 425 | if not checked then 426 | return true 427 | end 428 | --find the first radio button with the same group and uncheck it. 429 | local unchecked = self.window.view:each_child(function(rb) 430 | if rb.isradio 431 | and rb ~= self 432 | and rb.radio_group == self.radio_group 433 | and rb.checked 434 | then 435 | rb.checked = false 436 | return not rb.checked --unchecking allowed or refused 437 | end 438 | end) 439 | if unchecked == nil then --none found to uncheck 440 | unchecked = true 441 | end 442 | return unchecked 443 | end 444 | 445 | local rbutton = ui.checkbox.button_class:subclass'radio_button' 446 | radio.button_class = rbutton 447 | 448 | rbutton.padding_left = 0 449 | rbutton.padding_right = 0 450 | 451 | function rbutton:after_sync_styles() 452 | self.corner_radius = self.w 453 | end 454 | 455 | function rbutton:draw_text() end 456 | 457 | rbutton.circle_radius = 0 458 | 459 | ui:style('radio_button', { 460 | transition_circle_radius = true, 461 | transition_duration_circle_radius = .2, 462 | }) 463 | 464 | ui:style('radio :checked > radio_button', { 465 | circle_radius = .45, 466 | transition_duration_circle_radius = .2, 467 | }) 468 | 469 | function rbutton:before_draw_content(cr) 470 | local r = glue.lerp(self.circle_radius, 0, 1, 0, self.cw / 2) 471 | if r <= 0 then return end 472 | cr:circle(self.cw / 2, self.ch / 2, r) 473 | cr:rgba(self.ui:rgba(self.text_color)) 474 | cr:fill() 475 | end 476 | 477 | function rbutton:checkbox_press() 478 | self.checkbox.checked = true 479 | end 480 | 481 | --radio button list ---------------------------------------------------------- 482 | 483 | local rblist = ui.layer:subclass'radio_list' 484 | ui.radiolist = rblist 485 | 486 | --config 487 | rblist.radio_class = ui.radio 488 | rblist.align_x = 'stretch' 489 | rblist.layout = 'flexbox' 490 | rblist.flex_flow = 'y' 491 | 492 | --features 493 | rblist.option_list = false --{{value=, text=}, ...} 494 | rblist.options = false --{value->text} 495 | rblist.none_checked_value = false 496 | 497 | --value property 498 | 499 | function rblist:get_checked_button() 500 | return self.radios[self.value] 501 | end 502 | 503 | rblist.get_value = false 504 | rblist:stored_property'value' 505 | function rblist:set_value(val) 506 | local rb = self.radios[val] 507 | if rb then 508 | rb.checked = true 509 | else 510 | if self.checked_button then 511 | self.checked_button.checked = false 512 | end 513 | end 514 | end 515 | rblist:track_changes'value' 516 | 517 | --init 518 | 519 | rblist:init_ignore{options=1, value=1} 520 | 521 | function rblist:create_radio(index, value, text, radio) 522 | local rb = self:radio_class({ 523 | iswidget = false, 524 | checked_value = value, 525 | button = glue.update({ 526 | tabgroup = self.tabgroup or self, 527 | tabindex = index, 528 | }), 529 | label = glue.update({ 530 | text = text, 531 | }), 532 | radio_group = self, 533 | }, self.radio, radio) 534 | 535 | self.radios[value] = rb 536 | 537 | rb:on('checked_changed', function(rb, checked) 538 | if checked then 539 | self._value = rb.value 540 | else 541 | self._value = self.none_checked_value 542 | end 543 | end) 544 | end 545 | 546 | function rblist:after_init(t) 547 | self.radios = {} --{value -> button} 548 | if t.option_list then 549 | for i,v in ipairs(t.option_list) do 550 | local value, text, radio 551 | if type(v) == 'table' then 552 | value = v.value 553 | text = v.text 554 | radio = v.radio 555 | else 556 | value = v 557 | text = v 558 | end 559 | self:create_radio(i, value, text, radio) 560 | end 561 | end 562 | if t.options then 563 | local i = 1 564 | for value, text in glue.sortedpairs(t.options) do 565 | self:create_radio(i, value, text) 566 | i = i + 1 567 | end 568 | end 569 | if t.value ~= nil then 570 | self.value = t.value 571 | else 572 | self.value = self.none_checked_value 573 | end 574 | end 575 | 576 | --multi-choice button -------------------------------------------------------- 577 | 578 | local choicebutton = ui.layer:subclass'choicebutton' 579 | ui.choicebutton = choicebutton 580 | 581 | choicebutton.layout = 'flexbox' 582 | choicebutton.align_items_y = 'center' 583 | 584 | --model 585 | 586 | choicebutton.option_list = {} --{{index=, text=, value=, ...}, ...} 587 | 588 | function choicebutton:get_value() 589 | local btn = self.selected_button 590 | return btn and btn.choicebutton_value 591 | end 592 | 593 | function choicebutton:set_value(value) 594 | self:select_button(self:button_by_value(value)) 595 | end 596 | 597 | --view 598 | 599 | ui:style('button :selected', { 600 | background_color = '#ccc', 601 | text_color = '#000', 602 | }) 603 | 604 | function choicebutton:find_button(selects) 605 | for i,btn in ipairs(self) do 606 | if btn.choicebutton == self and selects(btn) then 607 | return btn 608 | end 609 | end 610 | end 611 | 612 | function choicebutton:button_by_value(value) 613 | return self:find_button(function(btn) return btn.choicebutton_value == value end) 614 | end 615 | 616 | function choicebutton:button_by_index(index) 617 | return self:find_button(function(btn) return btn.index == index end) 618 | end 619 | 620 | function choicebutton:get_selected_button() 621 | return self:find_button(function(btn) return btn.tags[':selected'] end) 622 | end 623 | 624 | function choicebutton:set_selected_button(btn) 625 | self:select_button(btn) 626 | end 627 | 628 | function choicebutton:unselect_button(btn) 629 | btn:settag(':selected', false) 630 | end 631 | 632 | function choicebutton:select_button(btn, focus) 633 | if not btn then return end 634 | local sbtn = self.selected_button 635 | if sbtn == btn then return end 636 | if sbtn then 637 | self:unselect_button(sbtn) 638 | end 639 | if focus then 640 | btn:focus() 641 | end 642 | btn:settag(':selected', true) 643 | self:fire('value_changed', btn.choicebutton_value) 644 | end 645 | 646 | --drawing 647 | 648 | choicebutton.button_corner_radius = 0 649 | 650 | function choicebutton:sync_layout_for_button(b) 651 | local r = self.button_corner_radius 652 | b.corner_radius_top_left = b.index == 1 and r or 0 653 | b.corner_radius_bottom_left = b.index == 1 and r or 0 654 | b.corner_radius_top_right = b.index == #self.option_list and r or 0 655 | b.corner_radius_bottom_right = b.index == #self.option_list and r or 0 656 | end 657 | 658 | --init 659 | 660 | choicebutton.button_class = ui.button 661 | 662 | choicebutton:init_ignore{value=1} 663 | 664 | function choicebutton:create_button(index, value) 665 | 666 | local btn = self:button_class({ 667 | tags = 'choicebutton_button', 668 | choicebutton = self, 669 | parent = self, 670 | iswidget = false, 671 | index = index, 672 | text = type(value) == 'table' and value.text or value, 673 | choicebutton_value = type(value) == 'table' and value.value or value, 674 | }, self.button, type(value) == 'table' and value.button or nil) 675 | 676 | function btn:after_sync() 677 | self.choicebutton:sync_layout_for_button(self) 678 | end 679 | 680 | --input/abstract 681 | function btn.before_press(btn) 682 | self:select_button(btn) 683 | end 684 | 685 | --input/keyboard 686 | function btn.before_keypress(btn, key) 687 | if key == 'left' then 688 | self:select_button(self:button_by_index(btn.index - 1), true) 689 | return true 690 | elseif key == 'right' then 691 | self:select_button(self:button_by_index(btn.index + 1), true) 692 | return true 693 | end 694 | end 695 | 696 | return btn 697 | end 698 | 699 | function choicebutton:after_init(t) 700 | for i,v in ipairs(t.option_list) do 701 | local btn = self:create_button(type(v) == 'table' and v.index or i, v) 702 | end 703 | if t.value ~= nil then 704 | self:select_button(self:button_by_value(t.value), false) 705 | end 706 | end 707 | 708 | --demo ----------------------------------------------------------------------- 709 | 710 | if not ... then require('ui_demo')(function(ui, win) 711 | 712 | ui:inherit() 713 | 714 | win.view.grid_wrap = 10 715 | win.view.grid_flow = 'y' 716 | win.view.item_align_x = 'left' 717 | win.view.grid_min_lines = 2 718 | 719 | local b1 = ui:button{ 720 | id = 'OK', 721 | parent = win, 722 | min_cw = 120, 723 | text = '&OK', 724 | default = true, 725 | } 726 | 727 | local btn = button:subclass'btn' 728 | 729 | local b2 = btn(ui, { 730 | id = 'Disabled', 731 | parent = win, 732 | text = 'Disabled', 733 | enabled = false, 734 | text_align_x = 'right', 735 | }) 736 | 737 | local b3 = btn(ui, { 738 | id = 'Cancel', 739 | parent = win, 740 | text = '&Cancel', 741 | cancel = true, 742 | text_align_x = 'left', 743 | }) 744 | 745 | function b1:gotfocus() print'b1 got focus' end 746 | function b1:lostfocus() print'b1 lost focus' end 747 | function b2:gotfocus() print'b2 got focus' end 748 | function b2:lostfocus() print'b2 lost focus' end 749 | 750 | function b1:pressed() print'b1 pressed' end 751 | function b2:pressed() print'b2 pressed' end 752 | 753 | local cb1 = ui:checkbox{ 754 | id = 'CB1', 755 | parent = win, 756 | min_cw = 200, 757 | label = {text = 'Check me.\nI\'m multiline.'}, 758 | checked = true, 759 | --enabled = false, 760 | } 761 | 762 | local cb2 = ui:checkbox{ 763 | id = 'CB2', 764 | parent = win, 765 | label = {text = 'Check me too', nowrap = true}, 766 | align = 'right', 767 | --enabled = false, 768 | } 769 | 770 | local rb1 = ui:radio{ 771 | id = 'RB1', 772 | parent = win, 773 | label = {text = 'Radio me', nowrap = true}, 774 | checked = true, 775 | radio_group = 1, 776 | --enabled = false, 777 | } 778 | 779 | local rb2 = ui:radio{ 780 | id = 'RB2', 781 | parent = win, 782 | label = {text = 'Radio me too', nowrap = true}, 783 | radio_group = 1, 784 | align = 'right', 785 | --enabled = false, 786 | } 787 | 788 | ui.radiolist{ 789 | parent = win, 790 | option_list = { 791 | 'Option 1', 792 | 'Option 2', 793 | 'Option 3', 794 | }, 795 | value = 'Option 2', 796 | } 797 | 798 | local cb1 = ui:choicebutton{ 799 | id = 'CHOICE', 800 | parent = win, 801 | min_cw = 400, 802 | option_list = { 803 | 'Choose me', 804 | 'No, me!', 805 | {text = 'Me, me, me!', value = 'val3'}, 806 | }, 807 | value = 'val3', 808 | } 809 | for i,b in ipairs(cb1) do 810 | if b.isbutton then 811 | b.id = 'CHOICE'..i 812 | end 813 | end 814 | 815 | end) end 816 | -------------------------------------------------------------------------------- /ui_calendar.lua: -------------------------------------------------------------------------------- 1 | 2 | --Calendar widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 6 | local glue = require'glue' 7 | local push = table.insert 8 | 9 | local calendar = ui.grid:subclass'calendar' 10 | ui.calendar = calendar 11 | 12 | calendar.col_resize = false 13 | calendar.col_move = false 14 | calendar.cell_select = true 15 | 16 | function calendar:get_time() 17 | return self._time 18 | end 19 | 20 | local function month(time) 21 | local t = os.date('*t', time) 22 | local t0 = os.time(glue.merge({day = 1, hour = 0, min = 0, sec = 0}, t)) 23 | local t1 = os.time(glue.merge({day = 1, hour = 0, min = 0, sec = 0, month = t.month + 1}, t)) - 1 24 | local t0 = os.date('*t', t0) 25 | local t1 = os.date('*t', t1) 26 | t0.days = t1.day 27 | t0.today = t.day 28 | return t0 29 | end 30 | 31 | ui:style('grid_cell :current_month', { 32 | background_color = '#111', 33 | }) 34 | 35 | ui:style('grid_cell :today', { 36 | background_color = '#260', 37 | }) 38 | 39 | function calendar:sync_cell(cell, i, col, val) 40 | if not val then 41 | val = '' 42 | else 43 | cell:settag('today', val.today) 44 | cell:settag('current_month', val.current_month) 45 | val = val.day 46 | end 47 | cell:sync_value(i, col, val) 48 | end 49 | 50 | function calendar:set_time(time) 51 | self._time = time 52 | self.rows = {} 53 | local t = month(time) 54 | local wday = t.wday 55 | local week = 1 56 | local row = {} 57 | push(self.rows, row) 58 | for wday = 1, wday - 1 do 59 | push(row, {current_month = false, day = 'x'}) 60 | end 61 | for day = 1, t.days do 62 | push(row, {current_month = true, day = day, today = day == t.today}) 63 | wday = wday + 1 64 | if wday > 7 then 65 | wday = 1 66 | week = week + 1 67 | row = {} 68 | push(self.rows, row) 69 | end 70 | end 71 | for wday = wday, 7 do 72 | push(row, {current_month = false, day = 'y'}) 73 | end 74 | end 75 | 76 | ui.weekdays_short = ui.weekdays_short 77 | or {'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'} 78 | 79 | function calendar:before_init() 80 | self.cols = {} 81 | for i = 1, 7 do 82 | push(self.cols, { 83 | text = ui.weekdays_short[i], 84 | text_align_x = 'center', 85 | }) 86 | end 87 | end 88 | 89 | function calendar:after_sync() 90 | local w = math.floor(self.cw / 7) 91 | for i = 1, 7 do 92 | self.cols[i].w = w 93 | end 94 | self.row_h = math.floor(self.scroll_pane.rows_pane.rows_layer.ch / self.row_count) 95 | end 96 | 97 | 98 | --demo ----------------------------------------------------------------------- 99 | 100 | if not ... then require('ui_demo')(function(ui, win) 101 | 102 | local c = ui:calendar{ 103 | x = 10, y = 10, 104 | w = 7 * 30, h = 6 * 30, 105 | parent = win, 106 | time = os.time(), 107 | } 108 | 109 | end) end 110 | -------------------------------------------------------------------------------- /ui_colorpicker.lua: -------------------------------------------------------------------------------- 1 | 2 | --Color Picker widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 6 | local glue = require'glue' 7 | local bitmap = require'bitmap' 8 | local color = require'color' 9 | local cairo = require'cairo' 10 | local lerp = glue.lerp 11 | local clamp = glue.clamp 12 | 13 | --line hit testing (from path2d) --------------------------------------------- 14 | 15 | local epsilon = 1e-10 16 | local function near(x, y) 17 | return abs(x - y) <= epsilon * max(1, abs(x), abs(y)) 18 | end 19 | 20 | --distance between two points squared. 21 | local function distance2(x1, y1, x2, y2) 22 | return (x2-x1)^2 + (y2-y1)^2 23 | end 24 | 25 | --intersect infinite line with its perpendicular from point (x, y) 26 | --return the intersection point. 27 | local function point_line_intersection(x, y, x1, y1, x2, y2) 28 | local dx = x2 - x1 29 | local dy = y2 - y1 30 | local k = dx^2 + dy^2 31 | if near(k, 0) then return x1, y1 end --line has no length 32 | local k = ((x - x1) * dy - (y - y1) * dx) / k 33 | return 34 | x - k * dy, 35 | y + k * dx 36 | end 37 | 38 | --return the time in 0..1 in the line segment (x1, y1, x2, y2) where the 39 | --perpendicular from point (x0, y0) intersects the line. 40 | local function line_hit(x0, y0, x1, y1, x2, y2) 41 | local x, y = point_line_intersection(x0, y0, x1, y1, x2, y2) 42 | local tx = near(x2, x1) and 0 or (x - x1) / (x2 - x1) 43 | local ty = near(y2, y1) and 0 or (y - y1) / (y2 - y1) 44 | if tx < 0 or ty < 0 then 45 | --intersection is outside the segment, closer to the first endpoint 46 | return distance2(x0, y0, x1, y1), x1, y1, 0 47 | elseif tx > 1 or ty > 1 then 48 | --intersection is outside the segment, closer to the second endpoint 49 | return distance2(x0, y0, x2, y2), x2, y2, 1 50 | end 51 | return max(tx, ty) 52 | end 53 | 54 | --hue vertical bar ----------------------------------------------------------- 55 | 56 | local hue_bar = ui.layer:subclass'hue_bar' 57 | ui.hue_bar = hue_bar 58 | 59 | --model 60 | 61 | function hue_bar:get_hue() 62 | return self._hue 63 | end 64 | 65 | function hue_bar:set_hue(hue) 66 | hue = clamp(hue, 0, 360) 67 | local old_hue = self._hue 68 | if old_hue ~= hue then 69 | self._hue = hue 70 | if self:isinstance() then 71 | self:fire('hue_changed', hue, old_hue) 72 | self:invalidate() 73 | end 74 | end 75 | end 76 | 77 | hue_bar.hue = 0 78 | 79 | hue_bar:init_ignore{hue=1} 80 | 81 | function hue_bar:after_init(t) 82 | self._hue = t.hue 83 | end 84 | 85 | --view/hue-bar 86 | 87 | function hue_bar:sync_bar() 88 | if not self._bmp or self._bmp.h ~= self.ch or self._bmp.w ~= self.cw then 89 | self._bmp = bitmap.new(self.cw, self.ch, 'bgra8') 90 | local bmp = self._bmp 91 | local _, setpixel = bitmap.pixel_interface(bmp) 92 | for y = 0, bmp.h-1 do 93 | local hue = lerp(y, 0, bmp.h-1, 0, 360) 94 | local r, g, b = color.convert('rgb', 'hsl', hue, 1, 0.5) 95 | for x = 0, bmp.w-1 do 96 | setpixel(x, y, r * 255, g * 255, b * 255, 255) 97 | end 98 | end 99 | end 100 | end 101 | 102 | function hue_bar:draw_bar(cr) 103 | self:sync_bar() 104 | local sr = cairo.image_surface(self._bmp) 105 | cr:operator'over' 106 | cr:source(sr) 107 | cr:paint() 108 | cr:rgb(0, 0, 0) --release source 109 | sr:free() 110 | end 111 | 112 | function hue_bar:background_visible() 113 | return true 114 | end 115 | 116 | function hue_bar:paint_background(cr) 117 | self:draw_bar(cr) 118 | end 119 | 120 | --view/pointer 121 | 122 | hue_bar.pointer_style = 'sliderule' --sliderule, needle 123 | 124 | function hue_bar:before_draw_content(cr) 125 | local y = glue.round(self.hue / 360 * self.ch) + .5 126 | self['draw_pointer_'..self.pointer_style](self, cr, y) 127 | end 128 | 129 | --view/pointer/needle 130 | 131 | hue_bar.needle_pointer_color = '#0008' 132 | 133 | ui:style('hue_bar :focused', { 134 | needle_pointer_color = '#000', 135 | }) 136 | 137 | function hue_bar:draw_pointer_needle(cr, y) 138 | local w = self.cw 139 | cr:save() 140 | cr:line_width(1) 141 | cr:operator'over' 142 | cr:rgba(self.ui:rgba(self.needle_pointer_color)) 143 | local sw = w / 3 144 | local sh = 1.5 145 | cr:new_path() 146 | cr:move_to(0, y-sh) 147 | cr:line_to(0, y+sh) 148 | cr:line_to(sw, y) 149 | cr:fill() 150 | cr:move_to(w, y-sh) 151 | cr:line_to(w, y+sh) 152 | cr:line_to(w - sw, y) 153 | cr:fill() 154 | cr:restore() 155 | end 156 | 157 | --view/pointer/sliderule 158 | 159 | local rule = ui.layer:subclass'hue_bar_sliderule' 160 | hue_bar.sliderule_class = rule 161 | 162 | rule.activable = false 163 | 164 | rule.h = 8 165 | rule.opacity = .7 166 | rule.border_offset = 1 167 | rule.border_width = 2 168 | rule.border_color = '#fff' 169 | rule.corner_radius = 3 170 | rule.outline_width = 1 171 | rule.outline_color = '#333' 172 | 173 | ui:style('hue_bar :focused > hue_bar_sliderule', { 174 | opacity = 1, 175 | }) 176 | 177 | function rule:before_draw_border(cr) 178 | cr:line_width(self.outline_width) 179 | cr:rgba(self.ui:rgba(self.outline_color)) 180 | self:border_path(cr, -1) 181 | cr:stroke() 182 | self:border_path(cr, 1) 183 | cr:stroke() 184 | end 185 | 186 | function hue_bar:draw_pointer_sliderule(cr, y) 187 | if not self.sliderule or not self.sliderule.islayer then 188 | self.sliderule = self.sliderule_class(self.ui, { 189 | parent = self, 190 | }, self.sliderule) 191 | end 192 | local rule = self.sliderule 193 | rule.y = glue.round(y - rule.h / 2) 194 | rule.w = self.w 195 | end 196 | 197 | --input/mouse 198 | 199 | hue_bar.mousedown_activate = true 200 | 201 | function hue_bar:mousemove(mx, my) 202 | if not self.active then return end 203 | self.hue = lerp(my, 0, self.ch-1, 0, 360) 204 | self:invalidate() 205 | end 206 | 207 | --input/keyboard 208 | 209 | hue_bar.focusable = true 210 | 211 | function hue_bar:keypress(key) 212 | if key == 'down' or key == 'up' 213 | or key == 'pagedown' or key == 'pageup' 214 | or key == 'home' or key == 'end' 215 | then 216 | local delta = 217 | (key:find'down' and 1 or -1) 218 | * (self.ui:key'shift' and .01 or 1) 219 | * (self.ui:key'ctrl' and .1 or 1) 220 | * (key:find'page' and 5 or 1) 221 | * (key == 'home' and 1/0 or 1) 222 | * (key == 'end' and -1/0 or 1) 223 | * 360 224 | * 0.1 225 | self.hue = self.hue + delta 226 | return true 227 | end 228 | end 229 | 230 | --input/wheel 231 | 232 | hue_bar.vscrollable = true 233 | 234 | function hue_bar:mousewheel(pages) 235 | self.hue = self.hue + 236 | -pages / 3 237 | * (self.ui:key'shift' and .01 or 1) 238 | * (self.ui:key'ctrl' and .1 or 1) 239 | * 360 240 | * 0.1 241 | end 242 | 243 | --abstract pick rectangle ---------------------------------------------------- 244 | 245 | local prect = ui.layer:subclass'pick_rectangle' 246 | ui.pick_rectangle = prect 247 | 248 | --model 249 | 250 | function prect:get_a() error'stub' end 251 | function prect:set_a(a) error'stub' end 252 | function prect:get_b() error'stub' end 253 | function prect:set_b(b) error'stub' end 254 | function prect:a_range() error'stub' end 255 | function prect:b_range() error'stub' end 256 | 257 | function prect:ab(x, y) 258 | local a1, a2 = self:a_range() 259 | local b1, b2 = self:b_range() 260 | local a = clamp(lerp(x, 0, self.cw-1, a1, a2), a1, a2) 261 | local b = clamp(lerp(y, 0, self.ch-1, b1, b2), b1, b2) 262 | return a, b 263 | end 264 | 265 | function prect:xy(a, b) 266 | local a1, a2 = self:a_range() 267 | local b1, b2 = self:b_range() 268 | local x = lerp(a, a1, a2, 0, self.cw-1) 269 | local y = lerp(b, b1, b2, 0, self.ch-1) 270 | return x, y 271 | end 272 | 273 | function prect:abrect() 274 | local a0, b0 = self:ab(0, 0) 275 | local a1, b1 = self:ab(self.cw, self.ch) 276 | return a0, b0, a1, b1 277 | end 278 | 279 | function prect:a_range() 280 | local a0, b0, a1, b1 = self:abrect() 281 | return a0, a1 282 | end 283 | 284 | function prect:b_range() 285 | local a0, b0, a1, b1 = self:abrect() 286 | return b0, b1 287 | end 288 | 289 | --view/pointer 290 | 291 | prect.pointer_style = 'cross' --circle, cross 292 | 293 | function prect:before_draw_content(cr) 294 | local cx, cy = self:xy(self.a, self.b) 295 | self['draw_pointer_'..self.pointer_style](self, cr, cx, cy) 296 | end 297 | 298 | --view/pointer/cross 299 | 300 | prect.pointer_cross_opacity = .5 301 | 302 | ui:style('pick_rectangle :focused', { 303 | pointer_cross_opacity = 1, 304 | }) 305 | 306 | function prect:pointer_cross_rgb(x, y) error'stub' end 307 | 308 | function prect:draw_pointer_cross(cr, cx, cy) 309 | local r, g, b = self:pointer_cross_rgb(cx, cy) 310 | cr:save() 311 | cr:rgba(r, g, b, self.pointer_cross_opacity) 312 | cr:operator'over' 313 | cr:line_width(1) 314 | cr:translate(cx, cy) 315 | cr:new_path() 316 | for i=1,4 do 317 | cr:move_to(0, 6) 318 | cr:rel_line_to(-1, 4) 319 | cr:rel_line_to(2, 0) 320 | cr:close_path() 321 | cr:rotate(math.rad(90)) 322 | end 323 | cr:fill_preserve() 324 | cr:stroke() 325 | cr:restore() 326 | end 327 | 328 | --view/pointer/circle 329 | 330 | prect.circle_pointer_color = '#fff8' 331 | prect.circle_pointer_outline_color = '#3338' 332 | prect.circle_pointer_outline_width = 1 333 | prect.circle_pointer_radius = 9 334 | prect.circle_pointer_inner_radius = 6 335 | 336 | ui:style('pick_rectangle :focused', { 337 | circle_pointer_color = '#fff', 338 | circle_pointer_outline_color = '#333', 339 | }) 340 | 341 | function prect:draw_pointer_circle(cr, cx, cy) 342 | cr:save() 343 | cr:rgba(self.ui:rgba(self.circle_pointer_color)) 344 | cr:operator'over' 345 | cr:line_width(self.circle_pointer_outline_width) 346 | cr:fill_rule'even_odd' 347 | cr:new_path() 348 | cr:circle(cx, cy, self.circle_pointer_radius) 349 | cr:circle(cx, cy, self.circle_pointer_inner_radius) 350 | cr:fill_preserve() 351 | cr:rgba(self.ui:rgba(self.circle_pointer_outline_color)) 352 | cr:stroke() 353 | cr:restore() 354 | end 355 | 356 | --input/mouse 357 | 358 | prect.mousedown_activate = true 359 | 360 | function prect:mousemove(mx, my) 361 | if not self.active then return end 362 | self.a, self.b = self:ab(mx, my) 363 | end 364 | 365 | --input/keyboard 366 | 367 | prect.focusable = true 368 | 369 | function prect:keypress(key) 370 | local delta = 371 | (self.ui:key'shift' and .01 or 1) 372 | * (self.ui:key'ctrl' and 0.1 or 1) 373 | * (key:find'page' and 5 or 1) 374 | * (key == 'home' and 1/0 or 1) 375 | * (key == 'end' and -1/0 or 1) 376 | * 0.1 377 | if key == 'down' or key == 'up' or key == 'pagedown' or key == 'pageup' 378 | or key == 'home' or key == 'end' 379 | then 380 | local delta = delta * (key:find'down' and 1 or -1) 381 | self.b = self.b + lerp(delta, 0, 1, self:b_range()) 382 | self:invalidate() 383 | return true 384 | elseif key == 'left' or key == 'right' then 385 | local delta = delta * (key:find'left' and -1 or 1) 386 | self.a = self.a + lerp(delta, 0, 1, self:a_range()) 387 | self:invalidate() 388 | return true 389 | end 390 | end 391 | 392 | --input/wheel 393 | 394 | prect.vscrollable = true 395 | 396 | function prect:mousewheel(pages) 397 | local delta = 398 | -pages / 3 399 | * (self.ui:key'shift' and .01 or 1) 400 | * (self.ui:key'ctrl' and .1 or 1) 401 | * 0.1 402 | self.b = self.b + lerp(delta, 0, 1, self:b_range()) 403 | end 404 | 405 | --saturation/luminance rectangle --------------------------------------------- 406 | 407 | local slrect = prect:subclass'sat_lum_rectangle' 408 | ui.sat_lum_rectangle = slrect 409 | 410 | --model 411 | 412 | slrect.hue = 0 413 | slrect.sat = 0 414 | slrect.lum = 0 415 | 416 | slrect:stored_property'hue' 417 | slrect:stored_property'sat' 418 | slrect:stored_property'lum' 419 | 420 | slrect:track_changes'hue' 421 | slrect:track_changes'sat' 422 | slrect:track_changes'lum' 423 | 424 | function slrect:override_set_hue(inherited, hue) 425 | if inherited(self, hue % 360) then 426 | self:fire'color_changed' 427 | self:invalidate() 428 | end 429 | end 430 | 431 | function slrect:override_set_sat(inherited, sat) 432 | if inherited(self, clamp(sat, 0, 1)) then 433 | self:fire'color_changed' 434 | self:invalidate() 435 | end 436 | end 437 | 438 | function slrect:override_set_lum(inherited, lum) 439 | if inherited(self, clamp(lum, 0, 1)) then 440 | self:fire'color_changed' 441 | self:invalidate() 442 | end 443 | end 444 | 445 | function slrect:hsl() 446 | return self.hue, self.sat, self.lum 447 | end 448 | 449 | function slrect:rgb() 450 | return color.convert('rgb', 'hsl', self:hsl()) 451 | end 452 | 453 | function slrect:get_a() return self.sat end 454 | function slrect:set_a(a) self.sat = a end 455 | function slrect:get_b() return 1-self.lum end 456 | function slrect:set_b(b) self.lum = 1-b end 457 | function slrect:a_range() return 0, 1 end 458 | function slrect:b_range() return 0, 1 end 459 | 460 | slrect:init_ignore{hue=1, sat=1, lum=1} 461 | 462 | function slrect:after_init(t) 463 | self._hue = t.hue 464 | self._sat = t.sat 465 | self._lum = t.lum 466 | end 467 | 468 | --view 469 | 470 | function slrect:draw_colors(cr) 471 | if not self._bmp or self._bmp.h ~= self.ch or self._bmp.w ~= self.cw then 472 | self._bmp = bitmap.new(self.cw, self.ch, 'bgra8') 473 | end 474 | if self._bmp_hue ~= self.hue then 475 | self._bmp_hue = self.hue 476 | local bmp = self._bmp 477 | local _, setpixel = bitmap.pixel_interface(bmp) 478 | local w, h = bmp.w, bmp.h 479 | for y = 0, h-1 do 480 | for x = 0, w-1 do 481 | local sat = lerp(x, 0, w-1, 0, 1) 482 | local lum = lerp(y, 0, h-1, 1, 0) 483 | local r, g, b = color.convert('rgb', 'hsl', self.hue, sat, lum) 484 | setpixel(x, y, r * 255, g * 255, b * 255, 255) 485 | end 486 | end 487 | end 488 | cr:save() 489 | local sr = cairo.image_surface(self._bmp) 490 | cr:operator'over' 491 | cr:source(sr) 492 | cr:paint() 493 | cr:rgb(0, 0, 0) --release source 494 | sr:free() 495 | cr:restore() 496 | end 497 | 498 | function slrect:pointer_cross_rgb(x, y) 499 | local hue = self.hue + 180 500 | local lum = self.lum > 0.5 and 0 or 1 501 | local sat = 1 - self.sat 502 | return color.convert('rgb', 'hsl', hue, sat, lum) 503 | end 504 | 505 | function slrect:before_draw_content(cr) 506 | self:draw_colors(cr) 507 | end 508 | 509 | --saturation / value rectangle ----------------------------------------------- 510 | 511 | local svrect = prect:subclass'sat_val_rectangle' 512 | ui.sat_val_rectangle = svrect 513 | 514 | --model 515 | 516 | svrect.hue = 0 517 | svrect.sat = 0 518 | svrect.val = 0 519 | 520 | svrect:stored_property'hue' 521 | svrect:stored_property'sat' 522 | svrect:stored_property'val' 523 | 524 | svrect:track_changes'hue' 525 | svrect:track_changes'sat' 526 | svrect:track_changes'val' 527 | 528 | function svrect:override_set_hue(inherited, hue) 529 | if inherited(self, hue % 360) then 530 | self:fire'color_changed' 531 | self:invalidate() 532 | end 533 | end 534 | 535 | function svrect:override_set_sat(inherited, sat) 536 | if inherited(self, clamp(sat, 0, 1)) then 537 | self:fire'color_changed' 538 | self:invalidate() 539 | end 540 | end 541 | 542 | function svrect:override_set_val(inherited, val) 543 | if inherited(self, clamp(val, 0, 1)) then 544 | self:fire'color_changed' 545 | self:invalidate() 546 | end 547 | end 548 | 549 | function svrect:hsv() 550 | return self.hue, self.sat, self.val 551 | end 552 | 553 | function svrect:rgb() 554 | return color.convert('rgb', 'hsv', self:hsv()) 555 | end 556 | 557 | function svrect:get_a() return self.sat end 558 | function svrect:set_a(a) self.sat = a end 559 | function svrect:get_b() return 1-self.val end 560 | function svrect:set_b(b) self.val = 1-b end 561 | function svrect:a_range() return 0, 1 end 562 | function svrect:b_range() return 0, 1 end 563 | 564 | svrect:init_ignore{hue=1, sat=1, val=1} 565 | 566 | function svrect:after_init(t) 567 | self._hue = t.hue 568 | self._sat = t.sat 569 | self._val = t.val 570 | end 571 | 572 | --view 573 | 574 | function svrect:draw_colors(cr) 575 | cr:save() 576 | local g1 = cairo.linear_gradient(0, 0, 0, self.ch) 577 | g1:add_color_stop(0, 1, 1, 1, 1) 578 | g1:add_color_stop(1, 0, 0, 0, 1) 579 | local g2 = cairo.linear_gradient(0, 0, self.cw, 0) 580 | local r, g, b = color.convert('rgb', 'hsl', self.hue, 1, .5) 581 | g2:add_color_stop(0, r, g, b, 0) 582 | g2:add_color_stop(1, r, g, b, 1) 583 | cr:operator'over' 584 | cr:new_path() 585 | cr:rectangle(0, 0, self.cw, self.ch) 586 | cr:source(g1) 587 | cr:fill_preserve() 588 | cr:operator'multiply' 589 | cr:source(g2) 590 | cr:fill() 591 | cr:rgb(0, 0, 0) --clear source 592 | g1:free() 593 | g2:free() 594 | cr:restore() 595 | end 596 | 597 | function svrect:pointer_cross_rgb(x, y) 598 | local hue = self.hue + 180 599 | local val = self.val > 0.5 and 0 or 1 600 | local sat = 1 - self.sat 601 | return color.convert('rgb', 'hsv', hue, sat, val) 602 | end 603 | 604 | function svrect:before_draw_content(cr) 605 | self:draw_colors(cr) 606 | end 607 | 608 | --saturation / luminance triangle -------------------------------------------- 609 | 610 | local sltr = slrect:subclass'sat_lum_triangle' 611 | ui.sat_lum_triangle = sltr 612 | 613 | function sltr:override_set_angle(inherited, angle) 614 | if inherited(self, angle % 360) then 615 | self:invalidate() 616 | end 617 | end 618 | 619 | function sltr:get_triangle_radius() 620 | return math.min(self.cw, self.ch) / 2 621 | end 622 | 623 | function sltr:triangle_points() 624 | local r = self.triangle_radius 625 | local a = math.rad(self.hue) 626 | local third = 2/3 * math.pi 627 | local x1 = math.cos(a + 0 * third) * r 628 | local y1 = -math.sin(a + 0 * third) * r 629 | local x2 = math.cos(a + 1 * third) * r 630 | local y2 = -math.sin(a + 1 * third) * r 631 | local x3 = math.cos(a + 2 * third) * r 632 | local y3 = -math.sin(a + 2 * third) * r 633 | return x1, y1, x2, y2, x3, y3 634 | end 635 | 636 | function sltr:xy(a, b) 637 | local s, l = a, 1-b 638 | local hx, hy, sx, sy, vx, vy = self:triangle_points() 639 | local mx = (sx + vx) / 2 640 | local my = (sy + vy) / 2 641 | local a = (1 - 2 * math.abs(l - .5)) * s 642 | local x = self.cx + sx + (vx - sx) * l + (hx - mx) * a 643 | local y = self.cy + sy + (vy - sy) * l + (hy - my) * a 644 | return x, y 645 | end 646 | 647 | function sltr:ab(x, y) 648 | local r = self.triangle_radius 649 | x = x - r 650 | y = y - r 651 | local hx, hy, sx, sy, vx, vy = self:triangle_points() 652 | local bx = (sx + vx) / 2 653 | local by = (sy + vy) / 2 654 | local l = line_hit(x, y, sx, sy, vx, vy) 655 | local t = line_hit(x, y, bx, by, hx, hy) 656 | local s = clamp(t / (2 * (l <= 0.5 and l or (1 - l))), 0, 1) 657 | return s, 1-l 658 | end 659 | 660 | function sltr:draw_colors(cr) 661 | local r = self.triangle_radius 662 | local hx, hy, sx, sy, vx, vy = self:triangle_points() 663 | 664 | cr:save() 665 | 666 | cr:translate(r, r) 667 | 668 | cr:new_path() 669 | cr:move_to(hx, hy) 670 | cr:line_to(sx, sy) 671 | cr:line_to(vx, vy) 672 | cr:close_path() 673 | cr:clip() 674 | 675 | --start from a black triangle 676 | cr:rgba(0, 0, 0, 1) 677 | cr:rectangle(-r, -r, 2*r, 2*r) 678 | cr:operator'over' 679 | cr:fill() 680 | 681 | --hsl(hue, 1, 1) to transparent gradient 682 | local g1 = cairo.linear_gradient(hx, hy, (sx + vx) / 2, (sy + vy) / 2) 683 | local R, G, B = color.convert('rgb', 'hsl', self.hue, 1, .5) 684 | g1:add_color_stop(0, R, G, B, 1) 685 | g1:add_color_stop(1, R, G, B, 0) 686 | cr:operator'over' 687 | cr:source(g1) 688 | cr:rectangle(-r, -r, 2*r, 2*r) 689 | cr:fill() 690 | 691 | --white to transparent gradient 692 | local g2 = cairo.linear_gradient(vx, vy, (hx + sx) / 2, (hy + sy) / 2) 693 | g2:add_color_stop(0, 1, 1, 1, 1) 694 | g2:add_color_stop(1, 1, 1, 1, 0) 695 | cr:operator'lighten' 696 | cr:source(g2) 697 | cr:rectangle(-r, -r, 2*r, 2*r) 698 | cr:fill() 699 | 700 | cr:rgb(0, 0, 0) --release source 701 | g1:free() 702 | g2:free() 703 | 704 | cr:restore() 705 | end 706 | 707 | --color picker --------------------------------------------------------------- 708 | 709 | local picker = ui.layer:subclass'colorpicker' 710 | ui.colorpicker = picker 711 | picker.iswidget = true 712 | 713 | --model 714 | 715 | picker._mode = 'HSL' 716 | 717 | function picker:get_mode() 718 | return self._mode 719 | end 720 | 721 | function picker:set_mode(mode) 722 | self._mode = mode 723 | if self:isinstance() then 724 | local hsl = mode == 'HSL' 725 | self.rectangle = hsl and self.sat_lum_rectangle or self.sat_val_rectangle 726 | 727 | self.sat_lum_rectangle.visible = hsl 728 | self.lum_label.visible = hsl 729 | self.lum_slider.visible = hsl 730 | 731 | self.sat_val_rectangle.visible = not hsl 732 | self.val_label.visible = not hsl 733 | self.val_slider.visible = not hsl 734 | 735 | if hsl then 736 | local h, s, l = color.convert('hsl', 'hsv', self.sat_val_rectangle:hsv()) 737 | self.sat_lum_rectangle.sat = s 738 | self.sat_lum_rectangle.lum = l 739 | else 740 | local h, s, v = color.convert('hsv', 'hsl', self.sat_lum_rectangle:hsl()) 741 | self.sat_val_rectangle.sat = s 742 | self.sat_val_rectangle.val = v 743 | end 744 | 745 | self.mode_button.selected = mode 746 | end 747 | end 748 | 749 | --view 750 | 751 | picker.hue_bar_class = hue_bar 752 | picker.sat_lum_rectangle_class = slrect 753 | picker.sat_val_rectangle_class = svrect 754 | picker.mode_button_class = ui.choicebutton 755 | 756 | function picker:create_hue_bar() 757 | return self.hue_bar_class(self.ui, { 758 | parent = self, 759 | iswidget = false, 760 | picker = self, 761 | hue_changed = function(_, hue) 762 | self.hue_slider.position = hue 763 | end, 764 | }, self.hue_bar) 765 | end 766 | 767 | function picker:create_sat_lum_rectangle() 768 | return self.sat_lum_rectangle_class(self.ui, { 769 | parent = self, 770 | iswidget = false, 771 | picker = self, 772 | visible = false, 773 | sat_changed = function(_, sat) 774 | self.sat_slider.position = sat 775 | end, 776 | lum_changed = function(_, lum) 777 | self.lum_slider.position = lum 778 | end, 779 | color_changed = function() 780 | self:sync_editboxes() 781 | end, 782 | }, self.sat_lum_rectangle) 783 | end 784 | 785 | function picker:create_sat_val_rectangle() 786 | return self.sat_val_rectangle_class(self.ui, { 787 | parent = self, 788 | iswidget = false, 789 | picker = self, 790 | visible = false, 791 | sat_changed = function(_, sat) 792 | self.sat_slider.position = sat 793 | end, 794 | val_changed = function(_, val) 795 | self.val_slider.position = val 796 | end, 797 | color_changed = function() 798 | self:sync_editboxes() 799 | end, 800 | }, self.sat_val_rectangle) 801 | end 802 | 803 | function picker:create_mode_button() 804 | return self.mode_button_class(self.ui, { 805 | parent = self, 806 | iswidget = false, 807 | picker = self, 808 | values = {'HSL', 'HSV'}, 809 | value_selected = function(_, mode) 810 | self.mode = mode 811 | end, 812 | button = {h = 17, font_size = 11}, 813 | button_corner_radius = 5, 814 | w = 70, 815 | }, self.mode_button) 816 | end 817 | 818 | function picker:create_rgb_editbox() 819 | return self.ui:editbox{ 820 | parent = self, 821 | iswidget = false, 822 | picker = self, 823 | } 824 | end 825 | 826 | function picker:sync_editboxes() 827 | if not self.window.cr then return end 828 | local sr = self.rectangle 829 | local re = self.rgb_editbox 830 | local xe = self.hex_editbox 831 | local r, g, b = sr:rgb() 832 | re.text = string.format( 833 | '%d, %d, %d', 834 | r * 255, g * 255, b * 255) 835 | xe.text = color.format('#', 'rgb', r, g, b) 836 | end 837 | 838 | function picker:after_sync() 839 | 840 | local hb = self.hue_bar 841 | local sr = self.rectangle 842 | local mb = self.mode_button 843 | local re = self.rgb_editbox 844 | local xe = self.hex_editbox 845 | local rl = self.rgb_label 846 | local xl = self.hex_label 847 | local hl = self.hue_label 848 | local hs = self.hue_slider 849 | local sl = self.sat_label 850 | local ss = self.sat_slider 851 | local ll = self.lum_label 852 | local ls = self.lum_slider 853 | local vl = self.val_label 854 | local vs = self.val_slider 855 | 856 | local h = 1 857 | local w = self.cw - h - 10 858 | hb.x = w + 10 + 8 859 | hb.w = h 860 | hb.h = self.ch 861 | 862 | local x2 = self.cw - self.hue_bar.w 863 | local w = math.min(self.ch, x2) 864 | local dw = math.max(1, x2 - self.ch) 865 | hb.w = hb.w + dw 866 | hb.x = hb.x - dw 867 | sr.w = w 868 | sr.h = w 869 | 870 | sr.hue = self.hue_bar.hue 871 | 872 | mb.x = 400 873 | 874 | local sx, sy = 14, 4 875 | local x1 = self.cw + sx + sx 876 | local w1 = 30 877 | local h = 30 878 | local x2 = x1 + sx + w1 879 | local w2 = 100 880 | local y = 0 881 | 882 | rl.x = x1 883 | rl.y = y 884 | rl.w = w1 885 | rl.h = h 886 | re.x = x2 887 | re.y = y 888 | re.w = w2 889 | 890 | y = y + h + sy 891 | 892 | xl.x = x1 893 | xl.y = y 894 | xl.w = w1 895 | xl.h = h 896 | xe.x = x2 897 | xe.y = y 898 | xe.w = w2 899 | 900 | y = y + h + sy + 20 901 | 902 | hl.x = x1 903 | hl.y = y 904 | hl.w = w1 905 | hl.h = h 906 | hs.x = x2 907 | hs.y = y 908 | hs.w = w2 909 | hs.h = h 910 | 911 | y = y + h + sy 912 | 913 | sl.x = x1 914 | sl.y = y 915 | sl.w = w1 916 | sl.h = h 917 | ss.x = x2 918 | ss.y = y 919 | ss.w = w2 920 | ss.h = h 921 | 922 | y = y + h + sy 923 | 924 | ll.x = x1 925 | ll.y = y 926 | ll.w = w1 927 | ll.h = h 928 | ls.x = x2 929 | ls.y = y 930 | ls.w = w2 931 | ls.h = h 932 | 933 | vl.x = x1 934 | vl.y = y 935 | vl.w = w1 936 | vl.h = h 937 | vs.x = x2 938 | vs.y = y 939 | vs.w = w2 940 | vs.h = h 941 | end 942 | 943 | --init 944 | 945 | picker:init_ignore{mode=1} 946 | 947 | function picker:after_init(t) 948 | 949 | self.hue_bar = self:create_hue_bar() 950 | 951 | self.sat_lum_rectangle = self:create_sat_lum_rectangle() 952 | self.sat_val_rectangle = self:create_sat_val_rectangle() 953 | 954 | self.mode_button = self:create_mode_button() 955 | 956 | self.rgb_editbox = self:create_rgb_editbox() 957 | self.hex_editbox = self:create_rgb_editbox() 958 | 959 | self.rgb_label = self.ui:layer{text = 'RGB:', parent = self, text_align_x = 'left'} 960 | self.hex_label = self.ui:layer{text = 'HEX:', parent = self, text_align_x = 'left'} 961 | self.hue_label = self.ui:layer{text = 'Hue:', parent = self, text_align_x = 'left'} 962 | self.sat_label = self.ui:layer{text = 'Sat:', parent = self, text_align_x = 'left'} 963 | self.lum_label = self.ui:layer{text = 'Lum:', parent = self, text_align_x = 'left'} 964 | self.val_label = self.ui:layer{text = 'Val:', parent = self, text_align_x = 'left'} 965 | 966 | self.hue_slider = self.ui:slider{ 967 | parent = self, 968 | size = 360, 969 | step = 1/4, 970 | position = self.hue_bar.hue, 971 | position_changed = function(slider, pos) 972 | self.hue_bar.hue = pos 973 | end, 974 | } 975 | self.sat_slider = self.ui:slider{ 976 | parent = self, 977 | size = 1, 978 | step = 0.001, 979 | position = self.sat_lum_rectangle.sat, 980 | position_changed = function(slider, pos) 981 | self.rectangle.sat = pos 982 | end, 983 | } 984 | self.lum_slider = self.ui:slider{ 985 | parent = self, 986 | size = 1, 987 | step = 0.001, 988 | position = self.sat_lum_rectangle.lum, 989 | position_changed = function(slider, pos) 990 | self.sat_lum_rectangle.lum = pos 991 | end, 992 | } 993 | self.val_slider = self.ui:slider{ 994 | parent = self, 995 | size = 1, 996 | step = 0.001, 997 | position = self.sat_val_rectangle.val, 998 | position_changed = function(slider, pos) 999 | self.sat_val_rectangle.val = pos 1000 | end, 1001 | } 1002 | 1003 | self.mode = t.mode 1004 | end 1005 | 1006 | --demo ----------------------------------------------------------------------- 1007 | 1008 | if not ... then require('ui_demo')(function(ui, win) 1009 | 1010 | win.view.background_color = '#222' 1011 | 1012 | local cp = ui:colorpicker{ 1013 | x = 20, y = 20, 1014 | w = 220, h = 200, 1015 | parent = win, 1016 | hue_bar = {hue = 60, tooltip = 'Hue bar'}, 1017 | sat_lum_rectangle = {sat = .7, lum = .3}, --, tooltip = 'Saturation x Luminance square'}, 1018 | sat_val_rectangle = {sat = .7, val = .3}, --, tooltip = 'Saturation x Value square'}, 1019 | --mode = 'HSV', 1020 | --sat_lum_rectangle_class = sltr, 1021 | } 1022 | 1023 | end) end 1024 | 1025 | -------------------------------------------------------------------------------- /ui_demo.lua: -------------------------------------------------------------------------------- 1 | --go@ luajit -jp=2fi1m1 * 2 | print(package.cpath) 3 | local ui = require'ui'() 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 ... == 'ui_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 = true, 105 | --clip = false, 106 | clip = '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 | 858 | win:show() 859 | ui:run() 860 | ui:free() 861 | -------------------------------------------------------------------------------- /ui_demo1.lua: -------------------------------------------------------------------------------- 1 | --go @ luajit -jp * 2 | local time = require'time' 3 | local ui = require'ui' 4 | ui.use_google_fonts = true 5 | 6 | local win = ui:window{ 7 | w = 800, h = 600, 8 | --transparent = true, frame = false, 9 | } 10 | 11 | function win:keydown(key) 12 | if key == 'esc' then 13 | self:close() 14 | end 15 | end 16 | 17 | win.view.padding = 20 18 | 19 | local scale = 2 20 | local pad = 0 21 | 22 | local function test_layerlib() 23 | 24 | win.view.layout = 'flexbox' 25 | 26 | print(ui.layer.font_size) 27 | 28 | local layer = ui:layer{ 29 | 30 | parent = win, 31 | 32 | --interdependent properties: test that init order is stable 33 | cx = 100 * scale, 34 | cy = 100 * scale, 35 | x = 0, y = 0, 36 | w = 0, h = 0, 37 | cw = 200 - pad * 2, 38 | ch = 200 - pad * 2, 39 | 40 | --has no effect on null layouts 41 | min_cw = 400, 42 | min_ch = 300, 43 | 44 | --padding_left = 20, 45 | padding = pad, 46 | 47 | rotation = 0, 48 | rotation_cx = -80, 49 | rotation_cy = -80, 50 | 51 | --scale = scale, 52 | scale_cx = 100, 53 | scale_cy = 100, 54 | 55 | snap_x = true, 56 | snap_y = false, 57 | 58 | clip_content = true, 59 | opacity = .8, 60 | --operator = 'xor', 61 | 62 | --border_width_right = 1, 63 | border_width = 10, 64 | --border_color_left = '#f00', 65 | border_color = '#fff', 66 | corner_radius_bottom_right = 30, 67 | corner_radius = 5, 68 | 69 | border_dash = {4, 3}, 70 | border_dash_offset = 1, 71 | 72 | --background_type = 'color', 73 | background_color = '#639', 74 | 75 | background_type = 'gradient', 76 | background_x1 = 0, 77 | background_y1 = 0, 78 | background_x2 = 0, 79 | background_y2 = 1, 80 | background_r1 = 50, 81 | background_r2 = 100, 82 | background_color_stops = {0, '#f00', .5, '#00f'}, 83 | 84 | background_hittable = false, 85 | background_operator = 'xor', 86 | background_clip_border_offset = 0, 87 | 88 | background_x = 50, 89 | background_y = 50, 90 | 91 | background_rotation = 10, 92 | background_rotation_cx = 10, 93 | background_rotation_cy = 10, 94 | 95 | background_scale = 100, 96 | background_scale_cx = 40, 97 | background_scale_cy = 40, 98 | background_extend = 'reflect', 99 | 100 | text = 'Hello', 101 | font = 'Open Sans Bold', 102 | font_size = 100, 103 | 104 | script = 'Zyyy', 105 | lang = 'en-us', 106 | dir = 'ltr', 107 | 108 | wrap = false, 109 | line_spacing = 1.2, 110 | hardline_spacing = 2, 111 | paragraph_spacing = 3, 112 | 113 | text_opacity = .8, 114 | text_color = '#ff0', 115 | --text_operator = 'xor', 116 | 117 | text_align_x = 'right', 118 | text_align_y = 'bottom', 119 | 120 | shadow_color = '#000', 121 | shadow_x = 2, 122 | shadow_y = 2, 123 | shadow_blur = 1, 124 | shadow_content = true, 125 | shadow_inset = true, 126 | 127 | --layout = 'textbox', 128 | 129 | } 130 | 131 | end 132 | 133 | local function test_flex() 134 | 135 | win.view.layout = 'flexbox' 136 | 137 | local flex = ui:layer{ 138 | parent = win, 139 | layout = 'flexbox', 140 | flex_wrap = true, 141 | --flex_flow = 'y', 142 | item_align_y = 'center', 143 | align_items_y = 'start', 144 | border_width = 20, 145 | padding = 20, 146 | border_color = '#333', 147 | snap_x = false, 148 | clip_content = true, 149 | 150 | x = 40, y = 40, 151 | min_cw = win.cw - 120 - 40, 152 | min_ch = win.ch - 120 - 40, 153 | } 154 | 155 | for i = 1, 100 do 156 | local r = math.random(10) 157 | local b = ui:layer{ 158 | parent = flex, 159 | layout = 'textbox', 160 | border_width = 1, 161 | min_cw = r * 12, 162 | min_ch = r * 6, 163 | --break_after = i == 50, 164 | --break_before = i == 50, 165 | --fr = r, 166 | --font_size = 10 + i * 3, 167 | } 168 | end 169 | 170 | end 171 | 172 | local function test_grid() 173 | 174 | win.view.layout = 'flexbox' 175 | 176 | local grid = ui:layer{ 177 | parent = win, 178 | 179 | layout = 'grid', 180 | item_align_y = 'center', 181 | item_align_x = 'center', 182 | --align_items_y = 'start', 183 | --align_items_x = 'stretch', 184 | 185 | --grid_flow = 'yrb', 186 | grid_wrap = 6, 187 | grid_min_lines = 3, 188 | grid_col_gap = 1, 189 | grid_row_gap = 4, 190 | grid_col_frs = {3, 1, 2}, 191 | grid_row_frs = {2, 1, 2}, 192 | 193 | border_width = 20, 194 | padding = 20, 195 | border_color = '#333', 196 | --clip_content = true, 197 | 198 | } 199 | 200 | for i = 1, 15 do 201 | local r = math.random(30) 202 | local b = ui:layer{ 203 | parent = grid, 204 | 205 | --align_x = 'right', 206 | 207 | layout = 'textbox', 208 | text_align_y = 'bottom', 209 | 210 | --grid_row = i, 211 | grid_col = -i, 212 | 213 | min_cw = r * 3, 214 | min_ch = r * 2, 215 | 216 | border_width = 1, 217 | 218 | snap_x = false, 219 | } 220 | end 221 | 222 | end 223 | 224 | ------------------------------------------------------------------------------ 225 | 226 | local function fps_function() 227 | local count_per_sec = 2 228 | local frame_count, last_frame_count, last_time = 0, 0 229 | return function() 230 | last_time = last_time or time.clock() 231 | frame_count = frame_count + 1 232 | local time = time.clock() 233 | if time - last_time > 1 / count_per_sec then 234 | last_frame_count, frame_count = frame_count, 0 235 | last_time = time 236 | end 237 | return last_frame_count * count_per_sec 238 | end 239 | end 240 | 241 | function win:before_draw() 242 | self:invalidate() 243 | end 244 | 245 | local fps = fps_function() 246 | 247 | win.native_window:on('repaint', function(self) 248 | self:title(string.format('%d fps', fps())) 249 | end) 250 | 251 | --keep showing fps in the titlebar every second. 252 | ui:runevery(1, function() 253 | if win.dead then 254 | ui:quit() 255 | else 256 | win:invalidate() 257 | end 258 | end) 259 | 260 | 261 | test_layerlib() 262 | --test_flex() 263 | --test_grid() 264 | 265 | ui:run() 266 | 267 | ui:free() 268 | 269 | require'layerlib_h'.memreport() 270 | 271 | --[[ 272 | 273 | set_border_line_to=1, 274 | 275 | get_background_image=1, 276 | set_background_image=1, 277 | 278 | --text 279 | 280 | get_text_span_feature_count=1, 281 | clear_text_span_features=1, 282 | get_text_span_feature=1, 283 | add_text_span_feature=1, 284 | 285 | text_caret_width=1, 286 | text_caret_color=1, 287 | text_caret_insert_mode=1, 288 | text_selectable=1, 289 | 290 | 291 | ]] 292 | -------------------------------------------------------------------------------- /ui_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 | -------------------------------------------------------------------------------- /ui_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'ui' 9 | local glue = require'glue' 10 | 11 | local dropdown = ui.editbox:subclass'dropdown' 12 | ui.dropdown = dropdown 13 | 14 | dropdown.text_editable = false 15 | dropdown.text_selectable = false 16 | 17 | function dropdown:after_init(t) 18 | self.button = self:create_button() 19 | self.popup = self:create_popup() 20 | self.picker = self:create_picker() 21 | end 22 | 23 | --open/close state ----------------------------------------------------------- 24 | 25 | function dropdown:get_isopen() 26 | return self.popup.visible 27 | end 28 | 29 | function dropdown:set_isopen(open) 30 | if open then 31 | self:open() 32 | else 33 | self:close() 34 | end 35 | end 36 | dropdown:track_changes'isopen' 37 | 38 | function dropdown:open() 39 | self:focus(true) 40 | self.popup:show() 41 | end 42 | 43 | function dropdown:close() 44 | if not self.popup.dead then 45 | self.popup:hide() 46 | end 47 | end 48 | 49 | function dropdown:toggle() 50 | self.isopen = not self.isopen 51 | end 52 | 53 | function dropdown:_opened() 54 | self.button.text = self.button.close_text 55 | self.picker:fire'opened' 56 | self:fire'opened' 57 | end 58 | 59 | function dropdown:_closed() 60 | self.button.text = self.button.open_text 61 | self.picker:fire'closed' 62 | self:fire'closed' 63 | end 64 | 65 | --keyboard interaction 66 | 67 | function dropdown:lostfocus() 68 | self:close() 69 | end 70 | 71 | function dropdown:keydown(key) 72 | return self.picker:fire('keydown', key) 73 | end 74 | 75 | function dropdown:keyup(key) 76 | if key == 'esc' and self.isopen then 77 | self:close() 78 | return true 79 | end 80 | return self.picker:fire('keyup', key) 81 | end 82 | 83 | function dropdown:keypress(key) 84 | if key == 'enter' and not self.isopen then 85 | self:open() 86 | return true 87 | end 88 | return self.picker:fire('keypress', key) 89 | end 90 | 91 | --mouse interaction 92 | 93 | dropdown.mousedown_activate = true 94 | 95 | function dropdown:click() 96 | self:toggle() 97 | end 98 | 99 | --open/close button ---------------------------------------------------------- 100 | 101 | local button = ui.layer:subclass'dropdown_button' 102 | dropdown.button_class = button 103 | 104 | function dropdown:create_button() 105 | return self.button_class(self.ui, { 106 | parent = self, 107 | dropdown = self, 108 | iswidget = false, 109 | }, self.button) 110 | end 111 | 112 | button.activable = false 113 | 114 | button.font = 'IonIcons,16' 115 | button.open_text = '\u{f280}' 116 | button.close_text = '\u{f286}' 117 | button.text_color = '#aaa' 118 | button.text = button.open_text 119 | 120 | ui:style('dropdown :hot > dropdown_button', { 121 | text_color = '#fff', 122 | }) 123 | 124 | function dropdown:after_sync_styles() 125 | self.padding_right = math.floor(self.h * .8) 126 | end 127 | 128 | function dropdown:before_sync_layout_children() 129 | local btn = self.button 130 | btn.h = self.ch 131 | btn.x = self.cw 132 | btn.w = self.pw2 133 | end 134 | 135 | --popup window --------------------------------------------------------------- 136 | 137 | local popup = ui.popup:subclass'dropdown_popup' 138 | dropdown.popup_class = popup 139 | 140 | popup.activable = false 141 | 142 | function dropdown:create_popup(ui, t) 143 | return self.popup_class(self.ui, { 144 | w = 200, h = 200, --required; sync'ed layer 145 | parent = self, 146 | visible = false, 147 | dropdown = self, 148 | }, self.popup) 149 | end 150 | 151 | function popup:after_init() 152 | self:frame_rect(0, self.dropdown.h) 153 | end 154 | 155 | function popup:shown() 156 | self.dropdown:_opened() 157 | end 158 | 159 | function popup:hidden() 160 | --TODO: bug: parent window does not repaint synchronously after child is closed. 161 | self.ui:runafter(0, function() 162 | self.dropdown:_closed() 163 | end) 164 | end 165 | 166 | function popup:override_parent_window_mousedown_autohide(inherited, ...) 167 | if self.dropdown.button.hot or self.dropdown.hot then 168 | --prevent autohide to avoid re-opening the popup by the dropdown. 169 | return 170 | end 171 | inherited(self, ...) 172 | end 173 | 174 | --list picker widget --------------------------------------------------------- 175 | 176 | local list = ui.scrollbox:subclass'dropdown_list' 177 | ui.dropdown_list = list 178 | 179 | list.auto_w = true 180 | list.vscrollbar = {autohide_empty = false} 181 | 182 | list.border_color = '#333' 183 | list.padding_left = 1 184 | list.border_width_left = 1 185 | list.border_width_right = 1 186 | list.border_width_bottom = 1 187 | 188 | local item = ui.layer:subclass'dropdown_item' 189 | list.item_class = item 190 | 191 | item.layout = 'textbox' 192 | item.text_align_x = 'auto' 193 | 194 | item.padding_left = 6 195 | 196 | ui:style('dropdown_item :hot', { 197 | background_color = '#222', 198 | }) 199 | 200 | ui:style('dropdown_item :selected', { 201 | background_color = '#226', 202 | }) 203 | 204 | --init 205 | 206 | function list:create_item(i, t) 207 | local value = type(t) == 'string' and i or t.value 208 | local text = type(t) == 'string' and t or t.text or value 209 | local t = type(t) == 'table' and t or nil 210 | local item = self.item_class({ 211 | parent = self.content, 212 | text = text, 213 | index = i, 214 | item_value = value, 215 | picker = self, 216 | dropdown = self.dropdown, 217 | select = self.item_select, 218 | unselect = self.item_unselect, 219 | }, t) 220 | item:inherit() 221 | self.by_value[value] = i 222 | item:on('mousedown', self.item_mousedown) 223 | return item 224 | end 225 | local time = require'time' 226 | function list:set_options(t) 227 | if not t then return end 228 | self.by_value = {} --{value -> item_index} 229 | local t0 = time.clock() 230 | for i,t in ipairs(t) do 231 | self:create_item(i, t) 232 | end 233 | print((time.clock() - t0) * 1000) 234 | end 235 | 236 | list:init_ignore{options=1} 237 | 238 | function list:after_init(t) 239 | local ct = self.content 240 | ct.layout = 'flexbox' 241 | ct.flex_flow = 'y' 242 | ct.dropdown = self.dropdown 243 | ct.picker = self 244 | function ct:mouseup() 245 | self.active = false 246 | local item = self.picker.selected_item 247 | if item then 248 | self.dropdown.value = item.item_value 249 | self.dropdown:close() 250 | end 251 | end 252 | function ct:mousemove(mx, my) 253 | local item = self:hit_test_children(mx, my, 'activate') 254 | if item and item.item_value then 255 | item:select() 256 | end 257 | end 258 | self.options = t.options 259 | end 260 | 261 | --sync'ing 262 | 263 | function dropdown:before_sync_layout_children() 264 | if self.picker.w ~= self.w then 265 | self.picker.w = self.w 266 | self.popup:sync() 267 | self.picker.ch = math.min(self.picker.content.h, self.w * 1.5) 268 | self.popup:client_size(self.picker:size()) 269 | self.popup:invalidate() 270 | end 271 | end 272 | 273 | --item selection 274 | 275 | function list.item_select(self) --self is the item! 276 | local sel_item = self.picker.selected_item 277 | if sel_item == self then return end 278 | if sel_item then 279 | sel_item:unselect() 280 | end 281 | self:make_visible() 282 | self:settag(':selected', true) 283 | self.picker.selected_item = self 284 | end 285 | 286 | function list.item_unselect(self) --self is the item! 287 | self.picker.selected_item = false 288 | self:settag(':selected', false) 289 | end 290 | 291 | --mouse interaction 292 | 293 | function list.item_mousedown(self) --self is the item! 294 | self.parent.active = true 295 | end 296 | 297 | --keyboard interaction 298 | 299 | function list:next_page_item(from_item, pages) 300 | local ct = self.content 301 | local last_index = pages > 0 and #ct or 1 302 | local step = pages > 0 and 1 or -1 303 | local h = self.view.ch 304 | local y = 0 305 | local from_index = from_item and from_item.index or last_index 306 | for i = from_index, last_index, step do 307 | y = y + ct[i].h 308 | if y > h then 309 | return ct[i] 310 | end 311 | end 312 | return ct[last_index] 313 | end 314 | 315 | function list:keypress(key) 316 | if key == 'up' or key == 'down' 317 | or key == 'pageup' or key == 'pagedown' 318 | or key == 'home' or key == 'end' 319 | or key == 'enter' 320 | then 321 | local hot_item = self.ui.hot_widget 322 | local item = self.selected_item 323 | or (hot_item and hot_item.picker and hot_item.index and hot_item) 324 | local ct = self.content 325 | if key == 'down' then 326 | if not item then 327 | item = ct[1] 328 | else 329 | item = ct[item.index + 1] 330 | end 331 | elseif key == 'up' then 332 | item = item and ct[item.index - 1] or ct[1] 333 | elseif key:find'page' then 334 | item = self:next_page_item(item, key == 'pagedown' and 1 or -1) 335 | elseif key == 'home' then 336 | item = ct[1] 337 | elseif key == 'end' then 338 | item = ct[#ct] 339 | end 340 | if item then 341 | item:select() 342 | if key == 'enter' or not self.dropdown.isopen then 343 | self.dropdown.value = item.item_value 344 | self.dropdown:close() 345 | end 346 | return true 347 | end 348 | end 349 | end 350 | 351 | --dropdown interface 352 | 353 | function list:pick_value(value) 354 | local index = self.by_value[value] 355 | if not index then return end 356 | local item = self.content[index] 357 | item:select() 358 | return true, index 359 | end 360 | 361 | function list:picked_value_text() 362 | local item = self.selected_item 363 | return item and item.text 364 | end 365 | 366 | function list:opened() 367 | if self.selected_item then 368 | self.selected_item:make_visible() 369 | end 370 | end 371 | 372 | --picker widget -------------------------------------------------------------- 373 | 374 | dropdown.picker_class = list 375 | dropdown.picker_classname = false --by-name override 376 | 377 | function dropdown:create_picker() 378 | 379 | local class = 380 | self.picker and (self.picker.class or self.picker.classname) 381 | or self.picker_class 382 | or self.ui[self.picker_classname] 383 | 384 | local picker = class(self.ui, { 385 | parent = self.popup, 386 | dropdown = self, 387 | }, self.picker) 388 | 389 | return picker 390 | end 391 | 392 | --data binding --------------------------------------------------------------- 393 | 394 | --allow setting and typing values outside of the picker's range. 395 | dropdown.allow_any_value = false 396 | 397 | dropdown:init_ignore{value=1} 398 | 399 | function dropdown:after_init(t) 400 | self.value = t.value 401 | end 402 | 403 | function dropdown:value_changed(val) 404 | self.text = self.picker:picked_value_text() or self:value_text(val) 405 | end 406 | 407 | function dropdown:validate_value(val, old_val) 408 | local picked, picked_val = self.picker:pick_value(val) 409 | if picked then 410 | return picked_val 411 | elseif self.allow_any_value then 412 | return val 413 | else 414 | return old_val 415 | end 416 | end 417 | 418 | --demo ----------------------------------------------------------------------- 419 | 420 | if not ... then require('ui_demo')(function(ui, win) 421 | 422 | win.x = 500 423 | win.w = 300 424 | win.h = 900 425 | 426 | --[[ 427 | local dropdown1 = ui:dropdown{ 428 | parent = win, 429 | picker = { 430 | options = { 431 | 'Apples', 'Oranges', 'Bananas', 432 | 'Burgers', 'Cheese', 'Fries', 433 | 'Peppers', 'Onions', 'Olives', 434 | 'Pumpkins', 'Eggplants', 'Cauliflower', 435 | 'Butter', 'Coconut Oil', 'Olive Oil', 'Sunflower Oil', 436 | 'Zucchinis', 'Squash', 437 | 'Lettuce', 'Spinach', 438 | 'I\'m hungry', 439 | } 440 | }, 441 | --picker = {rows = {'Row 1', 'Row 2', 'Row 3', {}}}, 442 | value = 20, 443 | --value = 'Some invalid value', 444 | allow_any_value = true, 445 | } 446 | ]] 447 | 448 | local t = {} 449 | for i = 1, 1000 do 450 | t[i] = 'Row' --'Row '..i 451 | end 452 | local dropdown2 = ui:dropdown{ 453 | parent = win, 454 | picker = {options = t}, 455 | value = 2, 456 | } 457 | 458 | function win:after_sync() 459 | self:invalidate() 460 | end 461 | 462 | end) end 463 | -------------------------------------------------------------------------------- /ui_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'ui' 7 | local tr = require'tr' 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 | editbox:instance_only'cue' 76 | 77 | editbox.cue_layer_class = ui.layer 78 | 79 | editbox:init_ignore{cue=1} 80 | 81 | function editbox:create_cue_layer() 82 | local cue_layer = self.cue_layer_class(self.ui, { 83 | tags = 'cue_layer', 84 | parent = self, 85 | editbox = self, 86 | activable = false, 87 | nowrap = true, 88 | }, self.cue_layer) 89 | 90 | function cue_layer:before_sync_layout() 91 | local ed = self.editbox 92 | self.visible = 93 | (not ed.show_cue_when_focused or ed.focused) 94 | and ed.text_len == 0 95 | if self.visible then 96 | self.text_align_x = ed.text_align_x 97 | self.text_align_y = ed.text_align_y 98 | self.w = ed.cw 99 | self.h = ed.ch 100 | end 101 | end 102 | 103 | return cue_layer 104 | end 105 | 106 | function editbox:after_init(t) 107 | self.cue_layer = self:create_cue_layer() 108 | self.cue = t.cue 109 | end 110 | 111 | --password masking ----------------------------------------------------------- 112 | 113 | --Password masking works by drawing fixed-width dots in place of actual 114 | --characters. Because cursor placement and hit-testing must continue 115 | --to work over these markers, we have to translate from "text space" (where 116 | --the original cursor positions are) to "mask space" (where the fixed-width 117 | --visual cursor positons are) in order to draw the cursor and the selection 118 | --rectangles. We also need to translate back to text space for hit-testing. 119 | 120 | editbox.password = false 121 | 122 | function editbox:override_caret_rect(inherited) 123 | local x, y, w, h = inherited(self) 124 | if self.password then 125 | x, y = self:text_to_mask(x, y) 126 | w = self.insert_mode and self:password_char_advance_x() or 1 127 | if self.text_selection.cursor2:rtl() then 128 | x = x - w 129 | end 130 | x = snap(x) 131 | y = snap(y) 132 | end 133 | return x, y, w, h 134 | end 135 | 136 | function editbox:override_draw_selection_rect(inherited, x, y, w, h, cr) 137 | local x1, y1 = self:text_to_mask(x, y) 138 | local x2, y2 = self:text_to_mask(x + w, y + h) 139 | inherited(self, x1, y1, x2-x1, y2-y1, cr) 140 | end 141 | 142 | --compute the text-space to mask-space mappings on each text sync. 143 | function editbox:sync_password_mask() 144 | if not self.text_selection then return end 145 | local segs = self.text_selection.segments 146 | if segs.lines.pw_cursor_is then return end 147 | segs.lines.pw_cursor_is = {} 148 | segs.lines.pw_cursor_xs = {} 149 | local i = 0 150 | for _,x in segs:cursor_xs() do 151 | segs.lines.pw_cursor_is[snap(x, 1/256)] = i 152 | segs.lines.pw_cursor_xs[i] = x 153 | i = i + 1 154 | end 155 | end 156 | 157 | function editbox:password_char_advance_x() 158 | --TODO: maybe use the min(w, h) of the "M" char here? 159 | return self.text_selection.segments.text_runs[1].font_size * .75 160 | end 161 | 162 | --convert "text space" cursor coordinates to "mask space" coordinates. 163 | --NOTE: input must be an exact cursor position. 164 | function editbox:text_to_mask(x, y) 165 | if self.password then 166 | local segs = self.text_selection.segments 167 | local line_x = segs:line_pos(1) 168 | local i = segs.lines.pw_cursor_is[snap(x - line_x, 1/256)] 169 | x = line_x + i * self:password_char_advance_x() 170 | end 171 | return x, y 172 | end 173 | 174 | --convert "mask space" coordinates to "text space" coordinates. 175 | --NOTE: input can be arbitrary but output is snapped to a cursor position. 176 | function editbox:mask_to_text(x, y) 177 | if self.password then 178 | local segs = self:sync_text_shape() 179 | local line_x = segs:line_pos(1) 180 | local w = self:password_char_advance_x() 181 | local i = snap(x - line_x, w) / w 182 | local i = clamp(i, 0, #segs.lines.pw_cursor_xs) 183 | x = line_x + segs.lines.pw_cursor_xs[i] 184 | end 185 | return x, y 186 | end 187 | 188 | function editbox:draw_password_char(cr, i, w, h) 189 | cr:new_path() 190 | cr:circle(w / 2, h / 2, math.min(w, h) * .3) 191 | cr:rgba(self.ui:rgba(self.text_color)) 192 | cr:fill() 193 | end 194 | 195 | function editbox:draw_password_mask(cr) 196 | local w = self:password_char_advance_x() 197 | local h = self.ch 198 | local segs = self.text_selection.segments 199 | local x = segs:line_pos(1) 200 | cr:save() 201 | cr:translate(x, 0) 202 | for i = 0, #segs.lines.pw_cursor_xs-1 do 203 | self:draw_password_char(cr, i, w, h) 204 | cr:translate(w, 0) 205 | end 206 | cr:restore() 207 | end 208 | 209 | function editbox:override_draw_text(inherited, cr) 210 | if self.password then 211 | self:draw_password_mask(cr) 212 | else 213 | inherited(self, cr) 214 | end 215 | end 216 | 217 | function editbox:before_sync_text_align() 218 | if self.password then 219 | self.text_align_x = 'left' 220 | end 221 | end 222 | 223 | function editbox:after_sync_text_align() 224 | if self.password then 225 | self:sync_password_mask() 226 | end 227 | end 228 | 229 | --password eye button -------------------------------------------------------- 230 | 231 | ui:style('editbox_eye_button', { 232 | text_color = '#aaa', 233 | }) 234 | ui:style('editbox_eye_button :hot', { 235 | text_color = '#fff', 236 | }) 237 | 238 | function editbox:after_init() 239 | if self.password then 240 | local no_eye = '\u{f2e8}' 241 | local eye = '\u{f2e9}' 242 | self.eye_button = self.ui:layer({ 243 | parent = self, 244 | tags = 'editbox_eye_button', 245 | font = 'Ionicons,16', 246 | text = no_eye, 247 | cursor = 'hand', 248 | click = function(btn) 249 | self.password = not self.password 250 | btn.text = self.password and no_eye or eye 251 | self:invalidate() 252 | end, 253 | }, self.eye_button) 254 | self.padding_right = 20 255 | end 256 | end 257 | 258 | function editbox:before_sync_layout_children() 259 | if self.password then 260 | local eye = self.eye_button 261 | eye.x = self.w - 10 262 | eye.y = self.h / 2 263 | end 264 | end 265 | 266 | --special text clipping ------------------------------------------------------ 267 | 268 | --allow fonts with long stems to overflow the text box on the y-axis. 269 | editbox.text_overflow_y = 4 270 | 271 | --clip the left & right sides of the box without clipping the top & bottom. 272 | function editbox:text_clip_rect() 273 | local ph = self.text_overflow_y 274 | return 0, -ph, self.cw, self.ch + 2 * ph 275 | end 276 | 277 | function editbox:override_draw_content(inherited, cr) 278 | self:draw_children(cr) 279 | cr:save() 280 | cr:rectangle(self:text_clip_rect()) 281 | cr:clip() 282 | self:draw_text_selection(cr) 283 | self:draw_text(cr) 284 | self:draw_caret(cr) 285 | cr:restore() 286 | end 287 | 288 | function editbox:override_hit_test_text(inherited, x, y, reason) 289 | if not box2d.hit(x, y, self:text_clip_rect()) then 290 | return 291 | end 292 | return inherited(self, x, y, reason) 293 | end 294 | 295 | function editbox:override_make_visible_caret(inherited) 296 | local segs = self.text_segments 297 | if not segs then return end 298 | local lines = segs.lines 299 | local y = lines.y 300 | inherited(self) 301 | lines.y = y 302 | end 303 | 304 | --demo ----------------------------------------------------------------------- 305 | 306 | if not ... then require('ui_demo')(function(ui, win) 307 | 308 | win.x = 500 309 | win.w = 300 310 | win.h = 900 311 | 312 | win.view.layout = 'flexbox' 313 | win.view.flex_flow = 'y' 314 | win.view.item_align_y = 'top' 315 | 316 | ui:add_font_file('FSEX300.ttf', 'fixedsys') 317 | ui:add_font_file('media/fonts/amiri-regular.ttf', 'Amiri') 318 | 319 | local cue = 'Type text here...' 320 | local s = 'abcd efgh ijkl mnop qrst uvw xyz 0123 4567 8901 2345' 321 | 322 | win.view.accepts_drag_groups = {true} 323 | 324 | --defaults all-around. 325 | ui:editbox{ 326 | parent = win, 327 | text = 'Hello World!', 328 | cue = cue, 329 | --mousedown_activate = true, 330 | } 331 | 332 | --maxlen: truncate initial text. prevent editing past maxlen. 333 | ui:editbox{ 334 | parent = win, 335 | text = 'Hello World!', 336 | maxlen = 5, 337 | cue = cue, 338 | } 339 | 340 | --right align 341 | ui:editbox{ 342 | parent = win, 343 | text = 'Hello World!', 344 | text_align_x = 'right', 345 | cue = cue, 346 | } 347 | 348 | --center align 349 | ui:editbox{ 350 | parent = win, 351 | text = 'Hello World!', 352 | text_align_x = 'center', 353 | cue = cue, 354 | } 355 | 356 | --scrolling, left align 357 | ui:editbox{ 358 | parent = win, 359 | text = s, 360 | cue = cue, 361 | } 362 | 363 | --scrolling, right align 364 | ui:editbox{ 365 | parent = win, 366 | text = s, 367 | text_align_x = 'right', 368 | cue = cue, 369 | } 370 | 371 | --scrolling, center align 372 | ui:editbox{ 373 | parent = win, 374 | text = s, 375 | text_align_x = 'center', 376 | cue = cue, 377 | } 378 | 379 | --invalid font 380 | ui:editbox{ 381 | parent = win, 382 | font = 'Invalid Font,20', 383 | text = s, 384 | cue = cue, 385 | } 386 | 387 | --rtl, align=auto 388 | ui:editbox{ 389 | parent = win, 390 | font = 'Amiri,20', 391 | text = 'السَّلَامُ عَلَيْكُمْ', 392 | cue = cue, 393 | } 394 | 395 | --password, scrolling, left align (the only alignment supported) 396 | ui:editbox{ 397 | parent = win, 398 | text = 'peekaboo', 399 | password = true, 400 | } 401 | 402 | end) end 403 | -------------------------------------------------------------------------------- /ui_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'ui' 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 | -------------------------------------------------------------------------------- /ui_layout_editor_app.lua: -------------------------------------------------------------------------------- 1 | 2 | --UI Layout Editor app. 3 | --Written by Cosmin Apreutesei. 4 | 5 | local ui = require'ui' 6 | 7 | ui:window{ 8 | w = 800, 9 | h = 500, 10 | } 11 | 12 | ui:run() 13 | -------------------------------------------------------------------------------- /ui_list.lua: -------------------------------------------------------------------------------- 1 | 2 | --Editable flexbox widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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 | if not ... then require('ui_demo')(function(ui, win) 15 | 16 | ui:list{ 17 | parent = win, 18 | border_width = 1, 19 | x = 100, y = 100, 20 | {border_width = 1, min_cw = 100, min_ch = 100}, 21 | {border_width = 1, min_cw = 50, min_ch = 100}, 22 | {border_width = 1, min_cw = 100, min_ch = 100}, 23 | } 24 | 25 | end) end 26 | -------------------------------------------------------------------------------- /ui_menu.lua: -------------------------------------------------------------------------------- 1 | 2 | --Menu Bar & Menu widgets. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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('ui_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 | -------------------------------------------------------------------------------- /ui_popup.lua: -------------------------------------------------------------------------------- 1 | 2 | --Pop-up Window. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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('ui_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 | -------------------------------------------------------------------------------- /ui_progressbar.lua: -------------------------------------------------------------------------------- 1 | 2 | --Progress Bar widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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('ui_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 | -------------------------------------------------------------------------------- /ui_scrollbox.lua: -------------------------------------------------------------------------------- 1 | 2 | --Scrollbar and Scrollbox Widgets. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui'() 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 | 142 | function grip.drag(grip, dx, dy) 143 | grip.x = clamp(0, grip.x + dx, self.cw - grip.w) 144 | self:transition('offset', self:grip_offset(), 0) 145 | end 146 | 147 | return grip 148 | end 149 | 150 | --scroll state 151 | 152 | scrollbar:stored_property'content_length' 153 | scrollbar:stored_property'view_length' 154 | scrollbar:stored_property'offset' 155 | 156 | function scrollbar:clamp_and_snap_offset(offset) 157 | local max_offset = self.content_length - self.view_length 158 | offset = clamp(offset, 0, math.max(max_offset, 0)) 159 | return snap_offset(offset, self.step) 160 | end 161 | 162 | function scrollbar:set_offset(offset) 163 | local old_offset = self._offset 164 | offset = self:clamp_and_snap_offset(offset) 165 | self._offset = offset 166 | self:settag(':empty', self:empty(), true) 167 | if offset ~= old_offset then 168 | self:fire('offset_changed', offset, old_offset) 169 | end 170 | end 171 | 172 | function scrollbar:after_set_content_length() self.offset = self.offset end 173 | function scrollbar:after_set_view_length() self.offset = self.offset end 174 | 175 | function scrollbar:reset(content_length, view_length, offset) 176 | self._content_length = content_length 177 | self._view_length = view_length 178 | self.offset = offset 179 | end 180 | 181 | function scrollbar:empty() 182 | return self.content_length <= self.view_length 183 | end 184 | 185 | scrollbar:init_ignore{content_length=1, view_length=1, offset=1} 186 | 187 | function scrollbar:after_init(t) 188 | self:reset(t.content_length, t.view_length, t.offset) 189 | self.grip = self:create_grip() 190 | end 191 | 192 | --visibility state 193 | 194 | function scrollbar:check_visible(...) 195 | return self.visible 196 | and (not self.autohide_empty or not self:empty()) 197 | and (not self.autohide or self:check_visible_autohide(...)) 198 | end 199 | 200 | --scroll API 201 | 202 | function scrollbar:scroll_to(offset, duration) 203 | if self:check_visible() == 'hit_test' then 204 | self:settag(':near', true) 205 | self:sync() 206 | self:settag(':near', false) 207 | end 208 | offset = self:clamp_and_snap_offset(offset) 209 | --^we want to animate the clamped length! 210 | self:transition('offset', offset, duration) 211 | end 212 | 213 | function scrollbar:scroll_to_view(x, w, duration) 214 | local sx = self:end_value'offset' 215 | local sw = self.view_length 216 | self:scroll_to(clamp(sx, x + w - sw, x), duration) 217 | end 218 | 219 | function scrollbar:scroll(delta, duration) 220 | self:scroll_to(self:end_value'offset' + delta, duration) 221 | end 222 | 223 | function scrollbar:scroll_pages(pages, duration) 224 | self:scroll(self.view_length * (pages or 1), duration) 225 | end 226 | 227 | --mouse interaction: grip dragging 228 | 229 | grip.mousedown_activate = true 230 | grip.draggable = true 231 | 232 | --mouse interaction: clicking on the track 233 | 234 | scrollbar.mousedown_activate = true 235 | 236 | --TODO: mousepress or mousehold 237 | function scrollbar:mousedown(mx, my) 238 | local delta = self.click_scroll_length * (mx < self.grip.x and -1 or 1) 239 | self:transition('offset', self:end_value'offset' + delta) 240 | end 241 | 242 | --autohide feature 243 | 244 | scrollbar:stored_property'autohide' 245 | function scrollbar:after_set_autohide(autohide) 246 | self:settag('autohide', autohide) 247 | end 248 | 249 | function scrollbar:hit_test_near(mx, my) --mx,my in window space 250 | if not mx then 251 | return 'hit_test' 252 | end 253 | mx, my = self:from_window(mx, my) 254 | return box2d.hit(mx, my, 255 | box2d.offset(self.autohide_distance, self:client_rect())) 256 | end 257 | 258 | function scrollbar:check_visible_autohide(mx, my) 259 | return self.grip.active 260 | or (not self.ui.active_widget and self:hit_test_near(mx, my)) 261 | end 262 | 263 | function scrollbar:after_set_parent() 264 | if not self.window then return end 265 | self.window:on({'mousemove', self}, function(win, mx, my) 266 | self:settag(':near', self:check_visible(mx, my)) 267 | end) 268 | self.window:on({'mouseleave', self}, function(win) 269 | local visible = self:check_visible() 270 | if visible == 'hit_test' then visible = false end 271 | self:settag(':near', visible) 272 | end) 273 | end 274 | 275 | --drawing: rotate matrix for vertical scrollbar 276 | 277 | function scrollbar:override_rel_matrix(inherited) 278 | local mt = inherited(self) 279 | if self._vertical then 280 | mt:rotate(math.rad(90)):translate(0, -self.h) 281 | end 282 | return mt 283 | end 284 | 285 | --drawing: sync grip geometry; sync :near tag. 286 | 287 | function scrollbar:before_sync_layout_children() 288 | local g = self.grip 289 | g.x, g.y, g.w, g.h = self:grip_rect() 290 | 291 | local visible = self:check_visible() 292 | if visible ~= 'hit_test' then 293 | self:settag(':near', visible, true) 294 | end 295 | end 296 | 297 | --scrollbox ------------------------------------------------------------------ 298 | 299 | local scrollbox = ui.layer:subclass'scrollbox' 300 | ui.scrollbox = scrollbox 301 | scrollbox.iswidget = true 302 | 303 | scrollbox.view_class = ui.layer 304 | scrollbox.content_class = ui.layer 305 | scrollbox.vscrollbar_class = scrollbar 306 | scrollbox.hscrollbar_class = scrollbar 307 | 308 | function scrollbox:after_init(t) 309 | 310 | self.vscrollbar = self:vscrollbar_class({ 311 | tags = 'vscrollbar', 312 | scrollbox = self, 313 | vertical = true, 314 | iswidget = false, 315 | }, self.scrollbar, self.vscrollbar) 316 | 317 | self.hscrollbar = self:hscrollbar_class({ 318 | tags = 'hscrollbar', 319 | scrollbox = self, 320 | vertical = false, 321 | iswidget = false, 322 | }, self.scrollbar, self.hscrollbar) 323 | 324 | --make autohide scrollbars to show and hide in sync. 325 | --TODO: remove the brk anti-recursion barrier hack. 326 | local vs = self.vscrollbar 327 | local hs = self.hscrollbar 328 | function vs:override_check_visible_autohide(inherited, mx, my, brk) 329 | return inherited(self, mx, my) 330 | or (not brk and hs.autohide and hs:check_visible(mx, my, true)) 331 | end 332 | function hs:override_check_visible_autohide(inherited, mx, my, brk) 333 | return inherited(self, mx, my) 334 | or (not brk and vs.autohide and vs:check_visible(mx, my, true)) 335 | end 336 | 337 | --NOTE: the view is created last so it is freed first, so that the 338 | --content can still access the scrollbox on its dying breath! 339 | self.view = self:view_class({ 340 | tags = 'scrollbox_view', 341 | clip_content = true, --we want to pad the content, but not clip it 342 | sync_layout = noop, --prevent auto-sync'ing content's layout 343 | }, self.view) 344 | 345 | if not self.content or not self.content.islayer then 346 | self.content = self.content_class(self.ui, { 347 | tags = 'scrollbox_content', 348 | parent = self.view, 349 | }, self.content) 350 | elseif self.content then 351 | self.content.parent = self.view 352 | end 353 | 354 | end 355 | 356 | --mouse interaction: wheel scrolling 357 | 358 | scrollbox.vscrollable = true 359 | scrollbox.hscrollable = true 360 | scrollbox.wheel_scroll_length = 50 --pixels per scroll wheel notch 361 | 362 | function scrollbox:mousewheel(delta) 363 | self.vscrollbar:scroll(-delta * self.wheel_scroll_length) 364 | end 365 | 366 | --drawing 367 | 368 | scrollbox:forward_properties('view', 'view_', { 369 | padding=1, 370 | padding_left=1, 371 | padding_right=1, 372 | padding_top=1, 373 | padding_bottom=1, 374 | }) 375 | 376 | function scrollbox:after_init(t) 377 | if t.padding ~= nil then self.view.padding = t.padding end 378 | if t.padding_left ~= nil then self.view.padding_left = t.padding_left end 379 | if t.padding_right ~= nil then self.view.padding_right = t.padding_right end 380 | if t.padding_top ~= nil then self.view.padding_top = t.padding_top end 381 | if t.padding_bottom ~= nil then self.view.padding_bottom = t.padding_bottom end 382 | end 383 | 384 | --stretch content to the view size to avoid scrolling on that dimension. 385 | scrollbox.auto_h = false 386 | scrollbox.auto_w = false 387 | 388 | function scrollbox:sync_layout_children() 389 | 390 | local vs = self.vscrollbar 391 | local hs = self.hscrollbar 392 | local view = self.view 393 | local content = self.content 394 | content.parent = view 395 | 396 | local w, h = self:client_size() 397 | 398 | local vs_margin = vs.margin or 0 399 | local hs_margin = hs.margin or 0 400 | 401 | local vs_overlap = vs.autohide or vs.overlap or not vs.visible 402 | local hs_overlap = hs.autohide or hs.overlap or not hs.visible 403 | 404 | local sw = vs.h + vs_margin 405 | local sh = hs.h + hs_margin 406 | 407 | --for `auto_w`, lay out the content with `min_w` set to view's cw, and then 408 | --get its size. if the content overflows vertically, another layout pass 409 | --is necessary, this time with a smaller `min_w`, making room for the 410 | --needed vertical scrollbar, under the assumption that the content will 411 | --still overflow vertically under the smaller `min_w`. the same logic 412 | --applies symmetrically for `auto_h`. 413 | local cw0 = self.auto_w and w - ((vs_overlap or vs.autohide_empty) and 0 or sw) 414 | local ch0 = self.auto_h and h - ((hs_overlap or hs.autohide_empty) and 0 or sh) 415 | 416 | if cw0 and ch0 then 417 | self.ui:warn'both auto_w and auto_h specified. auto_h ignored.' 418 | ch0 = nil 419 | end 420 | 421 | ::reflow:: 422 | 423 | if cw0 or ch0 then 424 | content:sync_layout_separate_axes(cw0 and 'xy' or 'yx', cw0, ch0) 425 | else 426 | content:sync_layout() 427 | end 428 | 429 | local cw, ch = content:size() 430 | 431 | --compute view dimensions by deciding which scrollbar is either hidden 432 | --or is overlapping the view box so it takes no space of its own. 433 | local vs_nospace, hs_nospace 434 | 435 | local vs_nospace = vs_overlap 436 | or (vs.autohide_empty and ch <= h and (ch <= h - sh or 'depends')) 437 | 438 | local hs_nospace = hs_overlap 439 | or (hs.autohide_empty and cw <= w and (cw <= w - sw or 'depends')) 440 | 441 | if (vs_nospace == 'depends' and not hs_nospace) 442 | or (hs_nospace == 'depends' and not vs_nospace) 443 | then 444 | vs_nospace = false 445 | hs_nospace = false 446 | end 447 | 448 | view.w = w - (vs_nospace and 0 or sw) 449 | view.h = h - (hs_nospace and 0 or sh) 450 | 451 | --if the view's `cw` is smaller than the preliminary `w` on which content 452 | --reflowing was based on for `auto_w`, then do it again with the real `cw`. 453 | --the same applies for `ch` for `auto_h`. 454 | if cw0 and view.cw < cw0 then 455 | cw0 = view.cw 456 | goto reflow 457 | elseif ch0 and view.ch < ch0 then 458 | ch0 = view.ch 459 | goto reflow 460 | end 461 | 462 | --reset the scrollbars state. 463 | hs:reset(cw, view.cw, hs.offset) 464 | vs:reset(ch, view.ch, vs.offset) 465 | 466 | --scroll the content layer. 467 | content.x = -hs.offset * content.w / cw -- content.pw1 468 | content.y = -vs.offset * content.h / ch -- content.ph1 469 | 470 | --compute scrollbar dimensions. 471 | vs.w = view.h - 2 * vs_margin --.w is its height! 472 | hs.w = view.w - 2 * hs_margin 473 | 474 | --check which scrollbars are visible and actually overlapping the view. 475 | --NOTE: scrollbars state must already be set here since we call `empty()`. 476 | local hs_overlapping = hs.visible and hs_overlap 477 | and (not hs.autohide_empty or not hs:empty()) 478 | 479 | local vs_overlapping = vs.visible and vs_overlap 480 | and (not vs.autohide_empty or not vs:empty()) 481 | 482 | --shorten the ends of scrollbars so they don't overlap each other. 483 | vs.w = vs.w - (vs_overlap and hs_overlapping and sh or 0) 484 | hs.w = hs.w - (hs_overlap and vs_overlapping and sw or 0) 485 | 486 | --compute scrollbar positions. 487 | vs.x = view.w - (vs_nospace and sw or 0) 488 | hs.y = view.h - (hs_nospace and sh or 0) 489 | vs.y = vs_margin 490 | hs.x = hs_margin 491 | 492 | for _,layer in ipairs(self) do 493 | if layer ~= content then 494 | layer:sync_layout() --recurse 495 | end 496 | end 497 | end 498 | 499 | --scroll API 500 | 501 | --x, y is in content's content space. 502 | function scrollbox:scroll_to_view(x, y, w, h) 503 | x, y = self.content:from_content(x, y) 504 | self.hscrollbar:scroll_to_view(x, w) 505 | self.vscrollbar:scroll_to_view(y, h) 506 | end 507 | 508 | --x, y, w, h is in own content space. 509 | function scrollbox:make_visible(x, y, w, h) 510 | x, y = self:to_other(self.content, x, y) 511 | self:scroll_to_view(x, y, w, h) 512 | end 513 | 514 | --multi-line editbox --------------------------------------------------------- 515 | 516 | local textarea = scrollbox:subclass'textarea' 517 | ui.textarea = textarea 518 | 519 | textarea.tags = 'standalone' 520 | 521 | textarea.auto_w = true 522 | textarea.view_padding_left = 0 523 | textarea.view_padding_right = 6 524 | 525 | local editbox = ui.layer:subclass'textarea_content' 526 | textarea.content_class = editbox 527 | 528 | editbox.layout = 'textbox' 529 | editbox.text_align_x = 'auto' 530 | editbox.text_align_y = 'top' 531 | editbox.focusable = true 532 | editbox.text_selectable = true 533 | editbox.text_editable = true 534 | editbox.clip_content = false 535 | 536 | function textarea:get_value() return self.editbox.value end 537 | function textarea:set_value(val) self.editbox.value = val end 538 | 539 | textarea:init_ignore{value=1} 540 | 541 | function textarea:after_init(t) 542 | self.editbox = self.content 543 | self.value = t.value 544 | end 545 | 546 | --demo ----------------------------------------------------------------------- 547 | 548 | if not ... then require('ui_demo')(function(ui, win) 549 | 550 | win.view.item_align_x = 'center' 551 | win.view.item_align_y = 'center' 552 | 553 | ui:style('scrollbox', { 554 | border_width = 1, 555 | border_color = '#f00', 556 | }) 557 | 558 | local function mkcontent(w, h) 559 | return ui:layer{ 560 | w = w or 2000, 561 | h = h or 32000, 562 | border_width = 20, 563 | border_color = '#ff0', 564 | background_type = 'gradient', 565 | background_colors = {'#ff0', 0.5, '#00f'}, 566 | background_x2 = 100, 567 | background_y2 = 100, 568 | background_extend = 'repeat', 569 | } 570 | end 571 | 572 | local s = [[ 573 | 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. 574 | 575 | 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.]] 576 | 577 | --not autohide, custom bar metrics 578 | ui:scrollbox{ 579 | parent = win, 580 | min_cw = 180, min_ch = 180, 581 | content = mkcontent(), 582 | vscrollbar = {h = 20, margin = 20}, 583 | hscrollbar = {h = 30, margin = 10}, 584 | } 585 | 586 | --overlap, custom bar metrics 587 | ui:scrollbox{ 588 | parent = win, 589 | min_cw = 180, min_ch = 180, 590 | content = mkcontent(), 591 | vscrollbar = {h = 20, margin = 20}, 592 | hscrollbar = {h = 30, margin = 10}, 593 | scrollbar = {overlap = true}, 594 | } 595 | 596 | --not autohide, autohide_empty vertical 597 | ui:scrollbox{ 598 | parent = win, 599 | min_cw = 180, min_ch = 180, 600 | content = mkcontent(nil, 165), 601 | } 602 | 603 | --not autohide, autohide_empty horizontal 604 | ui:scrollbox{ 605 | parent = win, 606 | min_cw = 180, min_ch = 180, 607 | content = mkcontent(165), 608 | autohide = true, 609 | } 610 | 611 | --not autohide, autohide_empty horizontal -> vertical 612 | ui:scrollbox{ 613 | parent = win, 614 | min_cw = 180, min_ch = 180, 615 | content = mkcontent(185, 175), 616 | autohide = true, 617 | } 618 | 619 | --not autohide, autohide_empty vertical -> horizontal 620 | ui:scrollbox{ 621 | parent = win, 622 | min_cw = 180, min_ch = 180, 623 | content = mkcontent(175, 185), 624 | autohide = true, 625 | } 626 | 627 | --autohide_empty case 628 | ui:scrollbox{ 629 | parent = win, 630 | min_cw = 180, min_ch = 180, 631 | content = mkcontent(180, 180), 632 | } 633 | 634 | --autohide 635 | ui:scrollbox{ 636 | parent = win, 637 | min_cw = 180, min_ch = 180, 638 | content = mkcontent(), 639 | scrollbar = { 640 | autohide = true, 641 | }, 642 | } 643 | 644 | --autohide, autohide_empty vertical 645 | ui:scrollbox{ 646 | parent = win, 647 | min_cw = 180, min_ch = 180, 648 | content = mkcontent(nil, 175), 649 | scrollbar = { 650 | autohide = true, 651 | } 652 | } 653 | 654 | --autohide, autohide_empty horizontal 655 | ui:scrollbox{ 656 | parent = win, 657 | min_cw = 180, min_ch = 180, 658 | content = mkcontent(175), 659 | scrollbar = { 660 | autohide = true, 661 | } 662 | } 663 | 664 | --autohide horizontal only 665 | ui:scrollbox{ 666 | parent = win, 667 | min_cw = 180, min_ch = 180, 668 | content = mkcontent(175), 669 | hscrollbar = { 670 | autohide = true, 671 | } 672 | } 673 | 674 | --auto_w 675 | ui:scrollbox{ 676 | parent = win, 677 | min_cw = 180, min_ch = 180, 678 | auto_w = true, 679 | content = { 680 | layout = 'textbox', 681 | text_align_x = 'left', 682 | text_align_y = 'top', 683 | text = s, 684 | }, 685 | } 686 | 687 | ui:textarea{ 688 | parent = win, 689 | min_cw = 180, min_ch = 180, 690 | value = s, 691 | } 692 | 693 | end) end 694 | -------------------------------------------------------------------------------- /ui_slider.lua: -------------------------------------------------------------------------------- 1 | 2 | --Slider widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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 | toggle:stored_property'value' 626 | function toggle:after_set_value(on) 627 | self.position = on and 1 or 0 628 | self:settag(':on', on) 629 | self:fire(on and 'option_enabled' or 'option_disabled') 630 | end 631 | toggle:track_changes'value' 632 | toggle:instance_only'value' 633 | 634 | function toggle:after_set_position() 635 | self.value = self.position == 1 636 | end 637 | 638 | function toggle:after_init() 639 | self.pin.drag_threshold = 1 --don't grab the pin right away 640 | self.pin:on('mouseup', function(pin) 641 | if pin.dragging then return end 642 | pin.animate = true 643 | self.position = 1 - self.position 644 | pin.animate = false 645 | end) 646 | end 647 | 648 | --demo ----------------------------------------------------------------------- 649 | 650 | if not ... then require('ui_demo')(function(ui, win) 651 | 652 | print(win.view.layout) 653 | win.view.item_align_x = 'left' 654 | 655 | ui:slider{ 656 | min_cw = 600, parent = win, 657 | position = 3, size = 10, 658 | step_labels = {Low = 0, Medium = 5, High = 10}, 659 | --pin = {style = {transition_duration = 2}}, 660 | step = 2, 661 | --snap_to_labels = false, 662 | } 663 | 664 | ui:slider{ 665 | min_cw = 200, parent = win, 666 | position = 0, 667 | min_position = 1.3, 668 | max_position = 8.3, 669 | step_start = .5, 670 | step = 2, 671 | } 672 | 673 | ui:slider{ 674 | min_cw = 200, parent = win, 675 | progress = .3, 676 | size = 1, 677 | } 678 | 679 | ui:toggle{ 680 | parent = win, 681 | option_changed = function(self, enabled) 682 | print(enabled and 'enabled' or 'disabled') 683 | end, 684 | } 685 | 686 | end) end 687 | -------------------------------------------------------------------------------- /ui_tablist.lua: -------------------------------------------------------------------------------- 1 | 2 | --Tab and Tablist widgets. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 6 | local glue = require'glue' 7 | local box2d = require'box2d' 8 | 9 | local indexof = glue.indexof 10 | local clamp = glue.clamp 11 | local round = glue.round 12 | 13 | --tab ------------------------------------------------------------------------ 14 | 15 | local tab = ui.layer:subclass'tab' 16 | ui.tab = tab 17 | tab.iswidget = true 18 | 19 | --tablist property: removing the selected tab selects the previous tab. 20 | --adding a tab selects the tab based on its stored selected state. 21 | 22 | tab.tablist = false 23 | tab:stored_property'tablist' 24 | function tab:before_set_tablist() 25 | if self.tablist then 26 | self.tablist:_remove_tab(self) 27 | end 28 | end 29 | function tab:after_set_tablist() 30 | if self.tablist then 31 | self.parent = self.tablist 32 | self.tablist:_add_tab(self, self._index) 33 | self.selected = self._selected 34 | end 35 | end 36 | tab:nochange_barrier'tablist' 37 | 38 | --index property: get/set a tab's positional index. 39 | 40 | tab._index = 1/0 --add to the tablist tail 41 | 42 | function tab:get_index() 43 | return self.tablist and self.tablist:tab_index(self) or self._index 44 | end 45 | 46 | function tab:set_index(index) 47 | if self.tablist then 48 | self.tablist:_move_tab(self, index) 49 | else 50 | self._index = index 51 | end 52 | end 53 | 54 | --visible state: hiding the selected tab selects the previous tab. 55 | 56 | tab._visible = tab.visible 57 | 58 | function tab:get_visible(visible) 59 | return self._visible 60 | end 61 | 62 | function tab:set_visible(visible) 63 | if self.tablist then 64 | if not visible then 65 | local prev_tab = self.tablist:prev_tab(self) 66 | if prev_tab then 67 | prev_tab:select() 68 | else 69 | self:unselect() 70 | end 71 | end 72 | self._visible = visible 73 | self.tablist:sync_tabs() 74 | end 75 | end 76 | tab:nochange_barrier'visible' 77 | 78 | --close() method for decoupling visibility from closing. 79 | 80 | function tab:close() 81 | if not self.closeable then return end 82 | if self:fire'closing' ~= false then 83 | self.visible = false 84 | self:fire'closed' 85 | end 86 | end 87 | 88 | --selected property: selecting a tab unselects the previously selected tab. 89 | 90 | tab._selected = false 91 | 92 | function tab:get_selected() 93 | if self.tablist then 94 | return self.tablist.selected_tab == self 95 | else 96 | return self._selected 97 | end 98 | end 99 | 100 | function tab:set_selected(selected) 101 | if selected then 102 | self:select() 103 | else 104 | self:unselect() 105 | end 106 | end 107 | 108 | function tab:select() 109 | if not self.tablist then 110 | self._selected = true 111 | return 112 | elseif not self.visible or not self.enabled then 113 | return 114 | end 115 | local stab = self.tablist.selected_tab 116 | if stab == self then return end 117 | if stab then 118 | stab:unselect() 119 | end 120 | self:settag(':selected', true) 121 | self:to_front() 122 | self:fire'tab_selected' 123 | self.parent:fire('tab_selected', self) 124 | self.tablist._selected_tab = self 125 | end 126 | 127 | function tab:unselect() 128 | if not self.tablist then 129 | self._selected = false 130 | return 131 | end 132 | local stab = self.tablist.selected_tab 133 | if stab ~= self then return end 134 | stab:settag(':selected', false) 135 | stab:fire'tab_unselected' 136 | self.tablist:fire('tab_unselected', stab) 137 | self.tablist._selected_tab = false 138 | end 139 | 140 | --init 141 | 142 | tab:init_ignore{tablist=1, visible=1, selected=1} 143 | 144 | function tab:after_init(t) 145 | if t.tablist then 146 | self.tablist = t.tablist 147 | end 148 | self.visible = t.visible 149 | self.selected = t.selected 150 | end 151 | 152 | function tab:before_free() 153 | self.tablist = false 154 | end 155 | 156 | --mouse interaction: drag & drop 157 | 158 | tab.mousedown_activate = true 159 | tab.draggable = true 160 | 161 | function tab:activated() 162 | self:select() 163 | end 164 | 165 | function tab:start_drag(button, mx, my, area) 166 | if area ~= 'header' then return end 167 | self.origin_tablist = self.tablist 168 | self.origin_tab_x = self.tab_x 169 | self.origin_tab_h = self.tab_h 170 | self.origin_index = self.index 171 | return self 172 | end 173 | 174 | function tab:drag(dx, dy) 175 | if self.tablist then 176 | local x = self.origin_tab_x + dx 177 | local vi = self.tablist:tab_visual_index_by_pos(x) 178 | self.index = self.tablist:tab_index_by_visual_index(vi) 179 | self:transition('tab_x', self.tablist:clamp_tab_pos(x), 0) 180 | self:transition('tab_w', self.tablist.live_tab_w) 181 | local x, w = self:snapxw(0, self.tablist.w) 182 | local y = self.tablist.tabs_side == 'top' and self.tab_h or 0 183 | local y, h = self:snapyh(y, self.tablist.ch - self.tablist.tab_h) 184 | self:transition('x', x, 0) 185 | self:transition('y', y, 0) 186 | self:transition('w', w, 0) 187 | self:transition('h', h, 0) 188 | else 189 | local x = self:snapx(self.x + dx) 190 | local y = self:snapy(self.y + dy) 191 | self:transition('tab_x', self.origin_tab_x, 0) 192 | self:transition('tab_h', self.origin_tab_h, 0) 193 | self:transition('x', x, 0) 194 | self:transition('y', y, 0) 195 | end 196 | end 197 | 198 | tab.draggable_outside = true --can be dragged out of the original tablist 199 | 200 | function tab:accept_drop_widget(widget) 201 | return self.draggable_outside or widget == self.origin_tablist 202 | end 203 | 204 | function tab:enter_drop_target(tablist) 205 | if not tab.draggable_outside and tablist ~= self.origin_tablist then 206 | return 207 | end 208 | self.tablist = tablist 209 | self:select() 210 | end 211 | 212 | function tab:leave_drop_target(tablist) 213 | if not tab.draggable_outside and tablist == self.origin_tablist then 214 | return 215 | end 216 | self.tablist = false 217 | self.parent = tablist.window 218 | self:to_front() 219 | end 220 | 221 | function tab:ended_dragging() 222 | if self.origin_tablist then 223 | self.x, self.y = self:to_other(self.origin_tablist, 0, 0) 224 | self.tablist = self.origin_tablist 225 | self.index = self.origin_index 226 | self:select() 227 | end 228 | self.origin_tablist = false 229 | self.origin_tab_x = false 230 | self.origin_index = false 231 | self.tablist:sync_tabs() 232 | end 233 | 234 | --mouse interaction: close-on-doubleclick 235 | 236 | tab.max_click_chain = 2 237 | 238 | function tab:doubleclick() 239 | self:close() 240 | end 241 | 242 | --keyboard interaction 243 | 244 | tab.focusable = true 245 | 246 | function tab:keypress(key) 247 | if key == 'enter' or key == 'space' then 248 | self:select() 249 | return true 250 | elseif key == 'left' or key == 'right' then 251 | local next_tab = self.tablist:next_tab(self, 252 | key == 'right' and 'next_index' or 'prev_index') 253 | if next_tab then 254 | next_tab:focus() 255 | end 256 | return true 257 | end 258 | end 259 | 260 | --drawing & hit-testing 261 | 262 | tab.clip_content = 'background' --TODO: find a way to set this to true 263 | tab.border_width = 1 264 | tab.border_color = '#222' 265 | tab.background_color = '#111' 266 | tab.padding = 5 267 | tab.corner_radius = 5 268 | 269 | ui:style('tab', { 270 | transition_tab_x = true, 271 | transition_tab_w = true, 272 | transition_duration_tab_x = .2, 273 | transition_duration_tab_w = .2, 274 | transition_x = true, 275 | transition_y = true, 276 | transition_w = true, 277 | transition_h = true, 278 | transition_duration_x = .5, 279 | transition_duration_y = .5, 280 | transition_duration_w = .5, 281 | transition_duration_h = .5, 282 | }) 283 | 284 | ui:style('tab :hot', { 285 | background_color = '#181818', 286 | transition_background_color = true, 287 | transition_duration = .2, 288 | }) 289 | 290 | ui:style('tab :selected', { 291 | background_color = '#222', 292 | border_color = '#333', 293 | transition_duration = 0, 294 | }) 295 | 296 | ui:style('tab :focused', { 297 | border_color = '#666', 298 | }) 299 | 300 | ui:style('tab :dragging', { 301 | opacity = .5, 302 | }) 303 | 304 | ui:style('tab :dropping', { 305 | opacity = 1, 306 | }) 307 | 308 | function tab:slant_widths() 309 | local h = self.tab_h 310 | local tablist = self.tablist or self.origin_tablist 311 | local s = tablist.tab_slant 312 | local sl = math.rad(tablist.tab_slant_left or s) 313 | local sr = math.rad(tablist.tab_slant_right or s) 314 | local wl = h / math.tan(sl) 315 | local wr = h / math.tan(sr) 316 | return wl, wr, sl, sr 317 | end 318 | 319 | local sides = {'top', 'right', 'bottom', 'left'} 320 | function tab:border_line_to(cr, x0, y0, q) 321 | local tablist = self.tablist or self.origin_tablist 322 | local side = q and sides[q] or tablist.tabs_side 323 | if side ~= tablist.tabs_side then return end 324 | local w, h = self.tab_w, self.tab_h 325 | local wl, wr, sl, sr = self:slant_widths() 326 | local r = tablist.tab_corner_radius 327 | local x = self.tab_x 328 | local x1 = x + wl 329 | local x2 = x + w - wr 330 | local x3 = x + w 331 | local x4 = x + 0 332 | local y = y0 333 | local y1 = y - h 334 | local y2 = y - h 335 | local y3 = y + 0 336 | local y4 = y + 0 337 | cr:save() 338 | if side == 'top' then 339 | --nothing 340 | elseif side == 'bottom' then 341 | cr:rotate_around(x + w / 2, y, math.pi) 342 | elseif side == 'left' then 343 | --TODO: 344 | elseif side == 'right' then 345 | --TODO: 346 | else 347 | self:warn('invalid tabs_side: %s', side) 348 | end 349 | if r > 0 then 350 | local q = math.pi/2 351 | local sl = q - sl 352 | local sr = q - sr 353 | local xl = math.tan(sl / 2) * r 354 | local xr = math.tan(sr / 2) * r 355 | cr:arc_negative(x4-r+xl, y4-r, r, q*1 , q*0+sl) 356 | cr:arc (x1+r-xl, y1+r, r, q*2+sl, q*3 ) 357 | cr:arc (x2-r+xr, y2+r, r, q*3 , q*4-sr) 358 | cr:arc_negative(x3+r-xr, y3-r, r, q*2-sr, q*1 ) 359 | else 360 | cr:line_to(x4, y4) 361 | cr:line_to(x1, y1) 362 | cr:line_to(x2, y2) 363 | cr:line_to(x3, y3) 364 | end 365 | cr:restore() 366 | end 367 | 368 | function tab:override_hit_test(inherited, x, y, ...) 369 | local widget, area = inherited(self, x, y, ...) 370 | if widget == self and area == 'background' and self.tablist then 371 | if box2d.hit(x, y, self.tablist:header_rect()) then 372 | return self, 'header' 373 | end 374 | end 375 | return widget, area 376 | end 377 | 378 | function tab:get_occluded() 379 | return not ( 380 | not self.tablist --dragging 381 | or self.selected --selected 382 | or self.tablist.foreground_fixed_tab == self --underneath dragging 383 | ) 384 | end 385 | 386 | --skip drawing the hidden part of the tab (notably it's background which can 387 | --be large) if we know for sure that the tab contents are entirely occluded. 388 | function tab:override_border_path(inherited, cr, ...) 389 | if not self.occluded then 390 | return inherited(self, cr, ...) 391 | end 392 | local x1, y1, w, h = self:border_rect(...) 393 | local x2, y2 = x1 + w, y1 + h 394 | local side = (self.tablist or self.origin_tablist).tabs_side 395 | if side == 'bottom' then 396 | y1 = y2 397 | elseif side == 'right' then 398 | --TODO: 399 | elseif side == 'left' then 400 | --TODO: 401 | end 402 | self:border_line_to(cr, x1, y1) 403 | cr:close_path() 404 | end 405 | 406 | --skip drawing the tab's contents if we know that they are entirely occluded. 407 | function tab:draw_children(cr) 408 | local occluded = self.occluded 409 | for i = 1, #self do 410 | local layer = self[i] 411 | if not occluded or layer == self.title or layer == self.close_button then 412 | layer:draw(cr) 413 | end 414 | end 415 | end 416 | 417 | --title 418 | 419 | local title = ui.layer:subclass'tab_title' 420 | tab.title_class = title 421 | 422 | title.text_align_x = 'left' 423 | title.padding_left = 2 424 | title.padding_right = 2 425 | title.text_color = '#ccc' 426 | title.nowrap = true 427 | title.activable = false 428 | title.clip_content = true 429 | 430 | ui:style('tab_title :focused', { 431 | font_weight = 'bold', 432 | }) 433 | 434 | function tab:create_title(title) 435 | return self.title_class(self.ui, { 436 | parent = self, 437 | iswidget = false, 438 | tab = self, 439 | }, self.title, title) 440 | end 441 | 442 | function tab:after_init() 443 | self.title = self:create_title() 444 | end 445 | 446 | function tab:before_sync_layout_children() 447 | local tablist = self.tablist or self.origin_tablist 448 | local t = self.title 449 | local wl, wr = self:slant_widths() 450 | local p = self.padding 451 | t.x = self.tab_x - (self.padding_left or p) + wl + tablist.title_margin 452 | if tablist.tabs_side == 'top' then 453 | t.y = self:snapy(-self.tab_h - (self.padding_top or p)) 454 | elseif tablist.tabs_side == 'bottom' then 455 | t.y = self:snapy(self.h - (self.padding_bottom or p)) 456 | end 457 | t.w = self.close_button.x - t.x 458 | t.h = self.tab_h 459 | end 460 | 461 | --close button 462 | 463 | tab.closeable = true --show close button and receive 'closing' event 464 | 465 | local xbutton = ui.button:subclass'tab_close_button' 466 | tab.close_button_class = xbutton 467 | 468 | xbutton.font = 'Ionicons,13' 469 | xbutton.text = '\u{f2c0}' 470 | xbutton.layout = false 471 | xbutton.w = 14 472 | xbutton.h = 14 473 | xbutton.padding_left = 0 474 | xbutton.padding_right = 0 475 | xbutton.padding_top = 0 476 | xbutton.padding_bottom = 0 477 | xbutton.corner_radius = 100 478 | xbutton.corner_radius_kappa = 1 479 | xbutton.focusable = false 480 | xbutton.border_width = 0 481 | xbutton.background_color = false 482 | xbutton.text_color = '#999' 483 | 484 | ui:style([[ 485 | tab_close_button, 486 | tab_close_button :hot, 487 | tab_close_button !:enabled 488 | ]], { 489 | background_color = false, 490 | transition_background_color = false, 491 | }) 492 | 493 | ui:style('tab_close_button :hot', { 494 | text_color = '#ddd', 495 | background_color = '#a00', 496 | }) 497 | 498 | ui:style('tab_close_button :over', { 499 | text_color = '#fff', 500 | }) 501 | 502 | function xbutton:pressed() 503 | self.tab:close() 504 | end 505 | 506 | function tab:create_close_button(button) 507 | return self.close_button_class(self.ui, { 508 | parent = self, 509 | iswidget = false, 510 | tab = self, 511 | }, self.close_button, button) 512 | end 513 | 514 | function tab:after_init() 515 | self.close_button = self:create_close_button() 516 | end 517 | 518 | function tab:before_sync_layout_children() 519 | local xb = self.close_button 520 | local wl, wr = self:slant_widths() 521 | local p = self.padding 522 | xb.x = self.tab_x + self.tab_w - xb.w - wr 523 | - (self.padding_left or p) 524 | - (self.tablist or self.origin_tablist).close_button_margin 525 | local side = (self.tablist or self.origin_tablist).tabs_side 526 | if side == 'top' then 527 | xb.cy = -math.ceil(self.tab_h / 2) - (self.padding_top or p) 528 | elseif side == 'bottom' then 529 | xb.cy = self.h + math.ceil(self.tab_h / 2) - (self.padding_bottom or p) 530 | end 531 | xb.visible = self.closeable 532 | end 533 | 534 | --tablist -------------------------------------------------------------------- 535 | 536 | local tablist = ui.layer:subclass'tablist' 537 | ui.tablist = tablist 538 | 539 | --tabs list 540 | 541 | function tablist:tab_index(tab) 542 | return indexof(tab, self.tabs) 543 | end 544 | 545 | function tablist:clamped_tab_index(index, add) 546 | return clamp(index, 1, math.max(1, #self.tabs + (add and 1 or 0))) 547 | end 548 | 549 | function tablist:_add_tab(tab, index) 550 | index = self:clamped_tab_index(index, true) 551 | table.insert(self.tabs, index, tab) 552 | self:sync_tabs() 553 | end 554 | 555 | function tablist:_remove_tab(tab) 556 | local select_tab = tab.visible and tab.selected and self:prev_tab(tab) 557 | tab:unselect() 558 | table.remove(self.tabs, self:tab_index(tab)) 559 | self:sync_tabs() 560 | if select_tab then 561 | select_tab.selected = true 562 | end 563 | end 564 | 565 | function ui.layer:_move_tab(tab, index) 566 | local old_index = self:tab_index(tab) 567 | local new_index = self:clamped_tab_index(index) 568 | if old_index ~= new_index then 569 | table.remove(self.tabs, old_index) 570 | table.insert(self.tabs, new_index, tab) 571 | self:sync_tabs() 572 | end 573 | end 574 | 575 | tablist.tab_class = tab 576 | 577 | function tablist:tab(tab) 578 | if not tab.istab then 579 | local class = tab.class or self.tab_class 580 | assert(class.istab) 581 | tab = class(self.ui, self[class], tab, {tablist = self}) 582 | end 583 | return tab 584 | end 585 | 586 | tablist:init_ignore{tabs = 1} 587 | 588 | function tablist:after_init(t) 589 | self.tabs = {} --{tab1,...} 590 | if t.tabs then 591 | for _,tab in ipairs(t.tabs) do 592 | self:tab(tab) 593 | end 594 | end 595 | if not self.selected_tab then 596 | if self.selected_tab_index then 597 | self.selected_tab = self.tabs[self.selected_tab_index] 598 | else 599 | self.selected_tab = self:next_tab(nil, 'next_index') 600 | end 601 | end 602 | end 603 | 604 | --selected tab state 605 | 606 | tablist._selected_tab = false 607 | 608 | function tablist:get_selected_tab() 609 | return self._selected_tab 610 | end 611 | 612 | function tablist:set_selected_tab(tab) 613 | if tab then 614 | assert(tab.tablist == self) 615 | tab:select() 616 | elseif self.selected_tab then 617 | self.selected_tab:unselect() 618 | end 619 | end 620 | 621 | --foreground tab that is either the selected tab or the tab underneath 622 | --the selected tab, if the selected tab is not yet in place (moving). 623 | function tablist:get_foreground_fixed_tab() 624 | local stab = self.selected_tab 625 | local moving_tab = stab 626 | and (stab:end_value'x' ~= stab.x 627 | or stab:end_value'y' ~= stab.y) 628 | and stab 629 | if stab and not moving_tab then 630 | return stab 631 | end 632 | for i = #self, 1, -1 do 633 | local tab = self[i] 634 | if tab.istab and tab.visible and tab ~= moving_tab then 635 | return tab 636 | end 637 | end 638 | end 639 | 640 | --visible tabs list 641 | 642 | function tablist:visible_tab_count() 643 | local n = 0 644 | for _,tab in ipairs(self.tabs) do 645 | if tab.visible then n = n + 1 end 646 | end 647 | return n 648 | end 649 | 650 | tablist.last_selected_order = false 651 | 652 | --modes: next_index, prev_index, next_layer_index, prev_layer_index. 653 | function tablist:next_tab(from_tab, mode, rotate, include_dragging) 654 | if mode == nil then 655 | mode = true 656 | end 657 | if type(mode) == 'boolean' then 658 | if self.last_selected_order then 659 | mode = not mode 660 | end 661 | mode = (mode and 'next' or 'prev') 662 | .. (self.last_selected_order and '_layer' or '') .. '_index' 663 | end 664 | 665 | local forward = mode:find'next' 666 | local tabs = mode:find'layer' and self or self.tabs 667 | 668 | local i0, i1, step = 1, #tabs, 1 669 | if not forward then 670 | i0, i1, step = i1, i0, -step 671 | end 672 | if from_tab then 673 | local index_field = tabs == self and 'layer_index' or 'index' 674 | i0 = from_tab[index_field] + (forward and 1 or -1) 675 | end 676 | for i = i0, i1, step do 677 | local tab = tabs[i] 678 | if tab.istab and tab.visible and tab.enabled 679 | and (include_dragging or not tab.dragging) 680 | then 681 | return tab 682 | end 683 | end 684 | if rotate then 685 | return self:next_tab(nil, mode) 686 | end 687 | end 688 | 689 | function tablist:prev_tab(tab) 690 | local stab = self.selected_tab 691 | local prev_tab 692 | if stab == tab then 693 | local is_first = (stab == self:next_tab(nil, 'next_index', nil, true)) 694 | local mode = self.last_selected_order and 'prev_layer_index' 695 | or (is_first and 'next_index' or 'prev_index') 696 | prev_tab = self:next_tab(tab, mode, nil, true) 697 | end 698 | return prev_tab 699 | end 700 | 701 | --keyboard interaction 702 | 703 | tablist.main_tablist = true --responds to tab/ctrl+tab globally 704 | 705 | function tablist:after_init() 706 | self.window:on({'keypress', self}, function(win, key) 707 | if self.main_tablist then 708 | if key == 'tab' and self.ui:key'ctrl' then 709 | local shift = self.ui:key'shift' 710 | local tab = self:next_tab(self.selected_tab, not shift, true) 711 | if tab then 712 | tab.selected = true 713 | end 714 | return true 715 | elseif self.selected_tab and key == 'W' and self.ui:key'ctrl' then 716 | self.selected_tab:close() 717 | return true 718 | end 719 | end 720 | end) 721 | end 722 | 723 | --drag & drop 724 | 725 | tablist.tablist_group = false 726 | 727 | function tablist:accept_drag_widget(widget, mx, my, area) 728 | if widget.istab then 729 | local group = widget.origin_tablist.tablist_group 730 | if not group or group == self.tablist_group then 731 | return not mx or area == 'header' 732 | end 733 | end 734 | end 735 | 736 | function tablist:drop(widget, mx, my, area) 737 | widget.origin_tablist = false 738 | end 739 | 740 | --drawing & hit-testing 741 | 742 | tablist.w = 400 743 | tablist.h = 400 744 | tablist.tab_h = 26 745 | tablist.tab_w = 150 746 | tablist.min_tab_w = 10 747 | tablist.tab_spacing = -10 748 | tablist.tab_slant = 70 --degrees 749 | tablist.tab_slant_left = false 750 | tablist.tab_slant_right = false 751 | tablist.tab_corner_radius = 0 752 | tablist.tabs_padding = 10 753 | tablist.tabs_padding_left = false 754 | tablist.tabs_padding_right = false 755 | tablist.tabs_side = 'top' --top, bottom, left, right 756 | 757 | tablist.title_margin = 4 758 | tablist.close_button_margin = 4 759 | 760 | function tablist:clamp_tab_pos(x) 761 | local p = self.tabs_padding 762 | return clamp(x, 763 | self.tabs_padding_left or p, 764 | self.w - self.live_tab_w - (self.tabs_padding_right or p)) 765 | end 766 | 767 | function tablist:sync_live_tab_w() 768 | local n = self:visible_tab_count() 769 | local w = self.w 770 | - (self.tabs_padding_left or self.tabs_padding) 771 | - (self.tabs_padding_right or self.tabs_padding) 772 | + self.tab_spacing * n 773 | local tw = math.min(self.tab_w + self.tab_spacing, math.floor(w / n)) 774 | local s = self.tab_slant 775 | local sl = self.tab_slant_left or s 776 | local sr = self.tab_slant_right or s 777 | local wl = self.tab_h / math.tan(math.rad(sl)) 778 | local wr = self.tab_h / math.tan(math.rad(sr)) 779 | local min_tw = wl + wr + self.min_tab_w 780 | self.live_tab_w = math.max(tw, min_tw) 781 | end 782 | 783 | function tablist:tab_pos_by_visual_index(index) 784 | return (self.tabs_padding_left or self.tabs_padding) + 785 | (index - 1) * (self.live_tab_w + self.tab_spacing) 786 | end 787 | 788 | function tablist:tab_visual_index_by_pos(x) 789 | local x = x - (self.tabs_padding_left or self.tabs_padding) 790 | return round(x / (self.live_tab_w + self.tab_spacing)) + 1 791 | end 792 | 793 | function tablist:tab_index_by_visual_index(vi) 794 | vi = math.max(1, vi) 795 | local vi1 = 1 796 | for i,tab in ipairs(self.tabs) do 797 | if tab.visible then 798 | if vi1 == vi then 799 | return i 800 | end 801 | vi1 = vi1 + 1 802 | end 803 | end 804 | return #self.tabs 805 | end 806 | 807 | function tablist:sync_tabs(...) 808 | self:sync_live_tab_w() 809 | local tab_w = self.live_tab_w 810 | local vi = 1 811 | local side = self.tabs_side 812 | for i,tab in ipairs(self.tabs) do 813 | if tab.visible then 814 | local x, w = self:snapxw(0, self.w) 815 | local y, h = self:snapyh( 816 | side == 'top' and self.tab_h or 0, 817 | self.h - self.tab_h) 818 | if not tab.dragging then 819 | tab:transition('tab_x', self:tab_pos_by_visual_index(vi), ...) 820 | tab:transition('tab_w', tab_w, ...) 821 | tab:transition('x', x, ...) 822 | tab:transition('y', y, ...) 823 | end 824 | tab:transition('w', w, ...) 825 | tab:transition('h', h, ...) 826 | tab:transition('tab_h', self.tab_h, ...) 827 | vi = vi + 1 828 | tab:sync_transitions() 829 | end 830 | end 831 | end 832 | 833 | function tablist:after_sync_layout() 834 | self:sync_tabs(0, nil, nil, nil, nil, 'replace_value') 835 | end 836 | 837 | function tablist:header_rect() --in content space 838 | local side = self.tabs_side 839 | if side == 'top' then 840 | return 0, 0, self.cw, self.tab_h 841 | elseif side == 'bottom' then 842 | return 0, self.ch - self.tab_h, self.cw, self.ch 843 | end 844 | end 845 | 846 | function tablist:override_hit_test(inherited, x, y, ...) --in parent content space 847 | local widget, area = inherited(self, x, y, ...) 848 | if widget == self and area == 'background' then 849 | local x, y = self:from_parent_to_box(x, y) 850 | local x, y = self:to_content(x, y) 851 | if box2d.hit(x, y, self:header_rect()) then 852 | return self, 'header' 853 | end 854 | end 855 | return widget, area 856 | end 857 | 858 | --demo ----------------------------------------------------------------------- 859 | 860 | if not ... then require('ui_demo')(function(ui, win) 861 | 862 | local color = require'color' 863 | 864 | win.view.grid_flow = 'x' 865 | win.view.grid_gap = 10 866 | win.view.grid_wrap = 2 867 | win.view.grid_min_lines = 2 868 | 869 | local w = (win.view.cw - 10) / 2 870 | local h = win.view.ch 871 | 872 | local tl1 = { 873 | tab_slant = 80, 874 | tab_corner_radius = 10, 875 | tabs_padding = 20, 876 | tab_h = 36, 877 | w = w, h = h, 878 | parent = win, 879 | tabs = {}, 880 | } 881 | 882 | local tl2 = { 883 | x = w + 10, w = w, h = h / 2, 884 | tab_slant_right = 80, 885 | parent = win, 886 | tabs = {}, 887 | tabs_side = 'bottom', 888 | } 889 | 890 | for i = 1, 10 do 891 | local visible = i ~= 3 and i ~= 8 892 | local enabled = i ~= 4 and i ~= 7 893 | local selected = i == 1 or i == 2 894 | local layer_index = 1 895 | local closeable = i ~= 5 896 | 897 | local tl = i % 2 == 0 and tl1 or tl2 898 | 899 | table.insert(tl.tabs, { 900 | tags = 'tab'..i, 901 | --index = 1, 902 | layer_index = layer_index, 903 | style = { 904 | font_slant = 'normal', 905 | }, 906 | title = {text = 'Tab '..i}, 907 | visible = visible, 908 | --selected = selected, 909 | enabled = enabled, 910 | closeable = closeable, 911 | closed = function(self) 912 | self:free() 913 | end, 914 | }) 915 | 916 | end 917 | 918 | tl1 = ui:tablist(tl1) 919 | tl2 = ui:tablist(tl2) 920 | 921 | end) end 922 | -------------------------------------------------------------------------------- /ui_todo.txt: -------------------------------------------------------------------------------- 1 | t.getters = { 2 | align_items_x = C.layer_get_align_items_x, 3 | align_items_y = C.layer_get_align_items_y, 4 | align_x = C.layer_get_align_x, 5 | align_y = C.layer_get_align_y, 6 | background_clip_border_offset = C.layer_get_background_clip_border_offset, 7 | background_color = C.layer_get_background_color, 8 | background_color_stop_count = C.layer_get_background_color_stop_count, 9 | background_extend = C.layer_get_background_extend, 10 | background_hittable = C.layer_get_background_hittable, 11 | background_image_format = C.layer_get_background_image_format, 12 | background_image_h = C.layer_get_background_image_h, 13 | background_image_pixels = C.layer_get_background_image_pixels, 14 | background_image_stride = C.layer_get_background_image_stride, 15 | background_image_w = C.layer_get_background_image_w, 16 | background_opacity = C.layer_get_background_opacity, 17 | background_operator = C.layer_get_background_operator, 18 | background_r1 = C.layer_get_background_r1, 19 | background_r2 = C.layer_get_background_r2, 20 | background_rotation = C.layer_get_background_rotation, 21 | background_rotation_cx = C.layer_get_background_rotation_cx, 22 | background_rotation_cy = C.layer_get_background_rotation_cy, 23 | background_scale = C.layer_get_background_scale, 24 | background_scale_cx = C.layer_get_background_scale_cx, 25 | background_scale_cy = C.layer_get_background_scale_cy, 26 | background_type = C.layer_get_background_type, 27 | background_x = C.layer_get_background_x, 28 | background_x1 = C.layer_get_background_x1, 29 | background_x2 = C.layer_get_background_x2, 30 | background_y = C.layer_get_background_y, 31 | background_y1 = C.layer_get_background_y1, 32 | background_y2 = C.layer_get_background_y2, 33 | border_color = C.layer_get_border_color, 34 | border_color_bottom = C.layer_get_border_color_bottom, 35 | border_color_left = C.layer_get_border_color_left, 36 | border_color_right = C.layer_get_border_color_right, 37 | border_color_top = C.layer_get_border_color_top, 38 | border_dash_count = C.layer_get_border_dash_count, 39 | border_dash_offset = C.layer_get_border_dash_offset, 40 | border_offset = C.layer_get_border_offset, 41 | border_width = C.layer_get_border_width, 42 | border_width_bottom = C.layer_get_border_width_bottom, 43 | border_width_left = C.layer_get_border_width_left, 44 | border_width_right = C.layer_get_border_width_right, 45 | border_width_top = C.layer_get_border_width_top, 46 | break_after = C.layer_get_break_after, 47 | break_before = C.layer_get_break_before, 48 | ch = C.layer_get_ch, 49 | child_count = C.layer_get_child_count, 50 | clip_content = C.layer_get_clip_content, 51 | corner_radius = C.layer_get_corner_radius, 52 | corner_radius_bottom_left = C.layer_get_corner_radius_bottom_left, 53 | corner_radius_bottom_right = C.layer_get_corner_radius_bottom_right, 54 | corner_radius_top_left = C.layer_get_corner_radius_top_left, 55 | corner_radius_top_right = C.layer_get_corner_radius_top_right, 56 | cw = C.layer_get_cw, 57 | cx = C.layer_get_cx, 58 | cy = C.layer_get_cy, 59 | final_h = C.layer_get_final_h, 60 | final_w = C.layer_get_final_w, 61 | final_x = C.layer_get_final_x, 62 | final_y = C.layer_get_final_y, 63 | flex_flow = C.layer_get_flex_flow, 64 | flex_wrap = C.layer_get_flex_wrap, 65 | fr = C.layer_get_fr, 66 | grid_col = C.layer_get_grid_col, 67 | grid_col_fr_count = C.layer_get_grid_col_fr_count, 68 | grid_col_gap = C.layer_get_grid_col_gap, 69 | grid_col_span = C.layer_get_grid_col_span, 70 | grid_flow = C.layer_get_grid_flow, 71 | grid_min_lines = C.layer_get_grid_min_lines, 72 | grid_row = C.layer_get_grid_row, 73 | grid_row_fr_count = C.layer_get_grid_row_fr_count, 74 | grid_row_gap = C.layer_get_grid_row_gap, 75 | grid_row_span = C.layer_get_grid_row_span, 76 | grid_wrap = C.layer_get_grid_wrap, 77 | h = C.layer_get_h, 78 | hardline_spacing = C.layer_get_hardline_spacing, 79 | hit_test_area = C.layer_get_hit_test_area, 80 | hit_test_layer = C.layer_get_hit_test_layer, 81 | hit_test_mask = C.layer_get_hit_test_mask, 82 | hit_test_text_cursor_which = C.layer_get_hit_test_text_cursor_which, 83 | hit_test_text_offset = C.layer_get_hit_test_text_offset, 84 | hit_test_x = C.layer_get_hit_test_x, 85 | hit_test_y = C.layer_get_hit_test_y, 86 | in_transition = C.layer_get_in_transition, 87 | index = C.layer_get_index, 88 | item_align_x = C.layer_get_item_align_x, 89 | item_align_y = C.layer_get_item_align_y, 90 | layout_type = C.layer_get_layout_type, 91 | lib = C.layer_get_lib, 92 | line_spacing = C.layer_get_line_spacing, 93 | min_ch = C.layer_get_min_ch, 94 | min_cw = C.layer_get_min_cw, 95 | opacity = C.layer_get_opacity, 96 | operator = C.layer_get_operator, 97 | padding = C.layer_get_padding, 98 | padding_bottom = C.layer_get_padding_bottom, 99 | padding_left = C.layer_get_padding_left, 100 | padding_right = C.layer_get_padding_right, 101 | padding_top = C.layer_get_padding_top, 102 | paragraph_spacing = C.layer_get_paragraph_spacing, 103 | parent = C.layer_get_parent, 104 | pixels_valid = C.layer_get_pixels_valid, 105 | pos_parent = C.layer_get_pos_parent, 106 | rotation = C.layer_get_rotation, 107 | rotation_cx = C.layer_get_rotation_cx, 108 | rotation_cy = C.layer_get_rotation_cy, 109 | scale = C.layer_get_scale, 110 | scale_cx = C.layer_get_scale_cx, 111 | scale_cy = C.layer_get_scale_cy, 112 | shadow_count = C.layer_get_shadow_count, 113 | snap_x = C.layer_get_snap_x, 114 | snap_y = C.layer_get_snap_y, 115 | span_count = C.layer_get_span_count, 116 | text = C.layer_get_text, 117 | text_align_x = C.layer_get_text_align_x, 118 | text_align_y = C.layer_get_text_align_y, 119 | text_cursor_count = C.layer_get_text_cursor_count, 120 | text_cursor_xs = C.layer_get_text_cursor_xs, 121 | text_cursor_xs_len = C.layer_get_text_cursor_xs_len, 122 | text_dir = C.layer_get_text_dir, 123 | text_len = C.layer_get_text_len, 124 | text_maxlen = C.layer_get_text_maxlen, 125 | text_utf8_len = C.layer_get_text_utf8_len, 126 | text_valid = C.layer_get_text_valid, 127 | top_layer = C.layer_get_top_layer, 128 | visible = C.layer_get_visible, 129 | w = C.layer_get_w, 130 | x = C.layer_get_x, 131 | y = C.layer_get_y, 132 | } 133 | t.setters = { 134 | align_items_x = C.layer_set_align_items_x, 135 | align_items_y = C.layer_set_align_items_y, 136 | align_x = C.layer_set_align_x, 137 | align_y = C.layer_set_align_y, 138 | background_clip_border_offset = C.layer_set_background_clip_border_offset, 139 | background_color = C.layer_set_background_color, 140 | background_color_stop_count = C.layer_set_background_color_stop_count, 141 | background_extend = C.layer_set_background_extend, 142 | background_hittable = C.layer_set_background_hittable, 143 | background_opacity = C.layer_set_background_opacity, 144 | background_operator = C.layer_set_background_operator, 145 | background_r1 = C.layer_set_background_r1, 146 | background_r2 = C.layer_set_background_r2, 147 | background_rotation = C.layer_set_background_rotation, 148 | background_rotation_cx = C.layer_set_background_rotation_cx, 149 | background_rotation_cy = C.layer_set_background_rotation_cy, 150 | background_scale = C.layer_set_background_scale, 151 | background_scale_cx = C.layer_set_background_scale_cx, 152 | background_scale_cy = C.layer_set_background_scale_cy, 153 | background_type = C.layer_set_background_type, 154 | background_x = C.layer_set_background_x, 155 | background_x1 = C.layer_set_background_x1, 156 | background_x2 = C.layer_set_background_x2, 157 | background_y = C.layer_set_background_y, 158 | background_y1 = C.layer_set_background_y1, 159 | background_y2 = C.layer_set_background_y2, 160 | border_color = C.layer_set_border_color, 161 | border_color_bottom = C.layer_set_border_color_bottom, 162 | border_color_left = C.layer_set_border_color_left, 163 | border_color_right = C.layer_set_border_color_right, 164 | border_color_top = C.layer_set_border_color_top, 165 | border_dash_count = C.layer_set_border_dash_count, 166 | border_dash_offset = C.layer_set_border_dash_offset, 167 | border_line_to = C.layer_set_border_line_to, 168 | border_offset = C.layer_set_border_offset, 169 | border_width = C.layer_set_border_width, 170 | border_width_bottom = C.layer_set_border_width_bottom, 171 | border_width_left = C.layer_set_border_width_left, 172 | border_width_right = C.layer_set_border_width_right, 173 | border_width_top = C.layer_set_border_width_top, 174 | break_after = C.layer_set_break_after, 175 | break_before = C.layer_set_break_before, 176 | ch = C.layer_set_ch, 177 | child_count = C.layer_set_child_count, 178 | clip_content = C.layer_set_clip_content, 179 | corner_radius = C.layer_set_corner_radius, 180 | corner_radius_bottom_left = C.layer_set_corner_radius_bottom_left, 181 | corner_radius_bottom_right = C.layer_set_corner_radius_bottom_right, 182 | corner_radius_top_left = C.layer_set_corner_radius_top_left, 183 | corner_radius_top_right = C.layer_set_corner_radius_top_right, 184 | cw = C.layer_set_cw, 185 | cx = C.layer_set_cx, 186 | cy = C.layer_set_cy, 187 | flex_flow = C.layer_set_flex_flow, 188 | flex_wrap = C.layer_set_flex_wrap, 189 | fr = C.layer_set_fr, 190 | grid_col = C.layer_set_grid_col, 191 | grid_col_fr_count = C.layer_set_grid_col_fr_count, 192 | grid_col_gap = C.layer_set_grid_col_gap, 193 | grid_col_span = C.layer_set_grid_col_span, 194 | grid_flow = C.layer_set_grid_flow, 195 | grid_min_lines = C.layer_set_grid_min_lines, 196 | grid_row = C.layer_set_grid_row, 197 | grid_row_fr_count = C.layer_set_grid_row_fr_count, 198 | grid_row_gap = C.layer_set_grid_row_gap, 199 | grid_row_span = C.layer_set_grid_row_span, 200 | grid_wrap = C.layer_set_grid_wrap, 201 | h = C.layer_set_h, 202 | hardline_spacing = C.layer_set_hardline_spacing, 203 | hit_test_mask = C.layer_set_hit_test_mask, 204 | in_transition = C.layer_set_in_transition, 205 | index = C.layer_set_index, 206 | item_align_x = C.layer_set_item_align_x, 207 | item_align_y = C.layer_set_item_align_y, 208 | layout_type = C.layer_set_layout_type, 209 | line_spacing = C.layer_set_line_spacing, 210 | min_ch = C.layer_set_min_ch, 211 | min_cw = C.layer_set_min_cw, 212 | opacity = C.layer_set_opacity, 213 | operator = C.layer_set_operator, 214 | padding = C.layer_set_padding, 215 | padding_bottom = C.layer_set_padding_bottom, 216 | padding_left = C.layer_set_padding_left, 217 | padding_right = C.layer_set_padding_right, 218 | padding_top = C.layer_set_padding_top, 219 | paragraph_spacing = C.layer_set_paragraph_spacing, 220 | parent = C.layer_set_parent, 221 | pos_parent = C.layer_set_pos_parent, 222 | rotation = C.layer_set_rotation, 223 | rotation_cx = C.layer_set_rotation_cx, 224 | rotation_cy = C.layer_set_rotation_cy, 225 | scale = C.layer_set_scale, 226 | scale_cx = C.layer_set_scale_cx, 227 | scale_cy = C.layer_set_scale_cy, 228 | shadow_count = C.layer_set_shadow_count, 229 | snap_x = C.layer_set_snap_x, 230 | snap_y = C.layer_set_snap_y, 231 | span_count = C.layer_set_span_count, 232 | text_align_x = C.layer_set_text_align_x, 233 | text_align_y = C.layer_set_text_align_y, 234 | text_cursor_count = C.layer_set_text_cursor_count, 235 | text_dir = C.layer_set_text_dir, 236 | text_maxlen = C.layer_set_text_maxlen, 237 | visible = C.layer_set_visible, 238 | w = C.layer_set_w, 239 | x = C.layer_set_x, 240 | y = C.layer_set_y, 241 | } 242 | t.methods = { 243 | background_image_invalidate = C.layer_background_image_invalidate, 244 | background_image_invalidate_rect = C.layer_background_image_invalidate_rect, 245 | child = C.layer_child, 246 | draw = C.layer_draw, 247 | free = C.layer_free, 248 | from_window = C.layer_from_window, 249 | get_background_color_stop_color = C.layer_get_background_color_stop_color, 250 | get_background_color_stop_offset = C.layer_get_background_color_stop_offset, 251 | get_border_dash = C.layer_get_border_dash, 252 | get_grid_col_fr = C.layer_get_grid_col_fr, 253 | get_grid_row_fr = C.layer_get_grid_row_fr, 254 | get_selected_text = C.layer_get_selected_text, 255 | get_selected_text_baseline = C.layer_get_selected_text_baseline, 256 | get_selected_text_color = C.layer_get_selected_text_color, 257 | get_selected_text_features = C.layer_get_selected_text_features, 258 | get_selected_text_font_face_index = C.layer_get_selected_text_font_face_index, 259 | get_selected_text_font_id = C.layer_get_selected_text_font_id, 260 | get_selected_text_font_size = C.layer_get_selected_text_font_size, 261 | get_selected_text_lang = C.layer_get_selected_text_lang, 262 | get_selected_text_len = C.layer_get_selected_text_len, 263 | get_selected_text_opacity = C.layer_get_selected_text_opacity, 264 | get_selected_text_operator = C.layer_get_selected_text_operator, 265 | get_selected_text_paragraph_dir = C.layer_get_selected_text_paragraph_dir, 266 | get_selected_text_script = C.layer_get_selected_text_script, 267 | get_selected_text_underline = C.layer_get_selected_text_underline, 268 | get_selected_text_underline_color = C.layer_get_selected_text_underline_color, 269 | get_selected_text_underline_opacity = C.layer_get_selected_text_underline_opacity, 270 | get_selected_text_utf8 = C.layer_get_selected_text_utf8, 271 | get_selected_text_utf8_len = C.layer_get_selected_text_utf8_len, 272 | get_selected_text_wrap = C.layer_get_selected_text_wrap, 273 | get_shadow_blur = C.layer_get_shadow_blur, 274 | get_shadow_color = C.layer_get_shadow_color, 275 | get_shadow_content = C.layer_get_shadow_content, 276 | get_shadow_inset = C.layer_get_shadow_inset, 277 | get_shadow_passes = C.layer_get_shadow_passes, 278 | get_shadow_x = C.layer_get_shadow_x, 279 | get_shadow_y = C.layer_get_shadow_y, 280 | get_span_baseline = C.layer_get_span_baseline, 281 | get_span_features = C.layer_get_span_features, 282 | get_span_font_face_index = C.layer_get_span_font_face_index, 283 | get_span_font_id = C.layer_get_span_font_id, 284 | get_span_font_size = C.layer_get_span_font_size, 285 | get_span_lang = C.layer_get_span_lang, 286 | get_span_offset = C.layer_get_span_offset, 287 | get_span_paragraph_dir = C.layer_get_span_paragraph_dir, 288 | get_span_script = C.layer_get_span_script, 289 | get_span_text_color = C.layer_get_span_text_color, 290 | get_span_text_opacity = C.layer_get_span_text_opacity, 291 | get_span_text_operator = C.layer_get_span_text_operator, 292 | get_span_underline = C.layer_get_span_underline, 293 | get_span_underline_color = C.layer_get_span_underline_color, 294 | get_span_underline_opacity = C.layer_get_span_underline_opacity, 295 | get_span_wrap = C.layer_get_span_wrap, 296 | get_text_caret_opacity = C.layer_get_text_caret_opacity, 297 | get_text_caret_thickness = C.layer_get_text_caret_thickness, 298 | get_text_cursor_offset = C.layer_get_text_cursor_offset, 299 | get_text_cursor_sel_offset = C.layer_get_text_cursor_sel_offset, 300 | get_text_cursor_sel_which = C.layer_get_text_cursor_sel_which, 301 | get_text_cursor_which = C.layer_get_text_cursor_which, 302 | get_text_cursor_x = C.layer_get_text_cursor_x, 303 | get_text_insert_mode = C.layer_get_text_insert_mode, 304 | get_text_selection_color = C.layer_get_text_selection_color, 305 | get_text_selection_opacity = C.layer_get_text_selection_opacity, 306 | get_text_utf8 = C.layer_get_text_utf8, 307 | hit_test = C.layer_hit_test, 308 | init = C.layer_init, 309 | insert_text_at_cursor = C.layer_insert_text_at_cursor, 310 | insert_text_utf8_at_cursor = C.layer_insert_text_utf8_at_cursor, 311 | layer = C.layer_layer, 312 | load_text_cursor_xs = C.layer_load_text_cursor_xs, 313 | release = C.layer_release, 314 | remove_selected_text = C.layer_remove_selected_text, 315 | selected_text_has_baseline = C.layer_selected_text_has_baseline, 316 | selected_text_has_color = C.layer_selected_text_has_color, 317 | selected_text_has_features = C.layer_selected_text_has_features, 318 | selected_text_has_font_face_index = C.layer_selected_text_has_font_face_index, 319 | selected_text_has_font_id = C.layer_selected_text_has_font_id, 320 | selected_text_has_font_size = C.layer_selected_text_has_font_size, 321 | selected_text_has_lang = C.layer_selected_text_has_lang, 322 | selected_text_has_opacity = C.layer_selected_text_has_opacity, 323 | selected_text_has_operator = C.layer_selected_text_has_operator, 324 | selected_text_has_paragraph_dir = C.layer_selected_text_has_paragraph_dir, 325 | selected_text_has_script = C.layer_selected_text_has_script, 326 | selected_text_has_underline = C.layer_selected_text_has_underline, 327 | selected_text_has_underline_color = C.layer_selected_text_has_underline_color, 328 | selected_text_has_underline_opacity = C.layer_selected_text_has_underline_opacity, 329 | selected_text_has_wrap = C.layer_selected_text_has_wrap, 330 | set_background_color_stop_color = C.layer_set_background_color_stop_color, 331 | set_background_color_stop_offset = C.layer_set_background_color_stop_offset, 332 | set_background_image = C.layer_set_background_image, 333 | set_border_dash = C.layer_set_border_dash, 334 | set_grid_col_fr = C.layer_set_grid_col_fr, 335 | set_grid_row_fr = C.layer_set_grid_row_fr, 336 | set_selected_text_baseline = C.layer_set_selected_text_baseline, 337 | set_selected_text_color = C.layer_set_selected_text_color, 338 | set_selected_text_features = C.layer_set_selected_text_features, 339 | set_selected_text_font_face_index = C.layer_set_selected_text_font_face_index, 340 | set_selected_text_font_id = C.layer_set_selected_text_font_id, 341 | set_selected_text_font_size = C.layer_set_selected_text_font_size, 342 | set_selected_text_lang = C.layer_set_selected_text_lang, 343 | set_selected_text_opacity = C.layer_set_selected_text_opacity, 344 | set_selected_text_operator = C.layer_set_selected_text_operator, 345 | set_selected_text_paragraph_dir = C.layer_set_selected_text_paragraph_dir, 346 | set_selected_text_script = C.layer_set_selected_text_script, 347 | set_selected_text_underline = C.layer_set_selected_text_underline, 348 | set_selected_text_underline_color = C.layer_set_selected_text_underline_color, 349 | set_selected_text_underline_opacity = C.layer_set_selected_text_underline_opacity, 350 | set_selected_text_wrap = C.layer_set_selected_text_wrap, 351 | set_shadow_blur = C.layer_set_shadow_blur, 352 | set_shadow_color = C.layer_set_shadow_color, 353 | set_shadow_content = C.layer_set_shadow_content, 354 | set_shadow_inset = C.layer_set_shadow_inset, 355 | set_shadow_passes = C.layer_set_shadow_passes, 356 | set_shadow_x = C.layer_set_shadow_x, 357 | set_shadow_y = C.layer_set_shadow_y, 358 | set_span_baseline = C.layer_set_span_baseline, 359 | set_span_features = C.layer_set_span_features, 360 | set_span_font_face_index = C.layer_set_span_font_face_index, 361 | set_span_font_id = C.layer_set_span_font_id, 362 | set_span_font_size = C.layer_set_span_font_size, 363 | set_span_lang = C.layer_set_span_lang, 364 | set_span_offset = C.layer_set_span_offset, 365 | set_span_paragraph_dir = C.layer_set_span_paragraph_dir, 366 | set_span_script = C.layer_set_span_script, 367 | set_span_text_color = C.layer_set_span_text_color, 368 | set_span_text_opacity = C.layer_set_span_text_opacity, 369 | set_span_text_operator = C.layer_set_span_text_operator, 370 | set_span_underline = C.layer_set_span_underline, 371 | set_span_underline_color = C.layer_set_span_underline_color, 372 | set_span_underline_opacity = C.layer_set_span_underline_opacity, 373 | set_span_wrap = C.layer_set_span_wrap, 374 | set_text = C.layer_set_text, 375 | set_text_caret_opacity = C.layer_set_text_caret_opacity, 376 | set_text_caret_thickness = C.layer_set_text_caret_thickness, 377 | set_text_cursor_offset = C.layer_set_text_cursor_offset, 378 | set_text_cursor_sel_offset = C.layer_set_text_cursor_sel_offset, 379 | set_text_cursor_sel_which = C.layer_set_text_cursor_sel_which, 380 | set_text_cursor_which = C.layer_set_text_cursor_which, 381 | set_text_cursor_x = C.layer_set_text_cursor_x, 382 | set_text_insert_mode = C.layer_set_text_insert_mode, 383 | set_text_selection_color = C.layer_set_text_selection_color, 384 | set_text_selection_opacity = C.layer_set_text_selection_opacity, 385 | set_text_utf8 = C.layer_set_text_utf8, 386 | text_cursor_move_near = C.layer_text_cursor_move_near, 387 | text_cursor_move_near_line = C.layer_text_cursor_move_near_line, 388 | text_cursor_move_near_page = C.layer_text_cursor_move_near_page, 389 | text_cursor_move_to = C.layer_text_cursor_move_to, 390 | text_cursor_move_to_point = C.layer_text_cursor_move_to_point, 391 | } 392 | 393 | 394 | PRIORITY --------------------------------------------------------------------- 395 | 396 | - REVIEW: dropdown! 397 | - REWRITE: colorpicker with grid/flexbox. 398 | 399 | LOW-LEVEL -------------------------------------------------------------------- 400 | 401 | - finish libpng 402 | - revive imagefile (libjpeg, libpng, bmp) 403 | - revive sg_* 404 | 405 | NW --------------------------------------------------------------------------- 406 | 407 | - BUG parent doesn't get repainted right after closing a child popup. 408 | - FEAT get a window's children in z-order so we can forward mouse wheel events 409 | to non-activable children. 410 | 411 | TR --------------------------------------------------------------------------- 412 | 413 | - tr: store paragraph base direction for all paragraphs. 414 | - text_run.align_x <- override for all enclosed lines/paragraphs. 415 | 416 | UI --------------------------------------------------------------------------- 417 | 418 | - design bug: widget module autoloading vs css decl. order conflict. 419 | - design bug: allow freeing self inside events (with a double-free barrier). 420 | 421 | EDITBOX ---------------------------------------------------------------------- 422 | 423 | - mask: 424 | - select/navigate text with the mask or without 425 | - 0 digit required 426 | - 9 digit or space, optional 427 | - # digit or space or +/- 428 | - L a-zA-Z 429 | 430 | - eye_icon for password mask. 431 | 432 | - make the caret a layer so it can be styled. 433 | --make selection rectangles layers so they can be styled. 434 | --drag & drop selection in/out of the editor and between editboxes. 435 | 436 | - IME integration. 437 | 438 | DROPDOWN --------------------------------------------------------------------- 439 | 440 | - maskedit dropdown with calendar. 441 | 442 | - autocomplete mode: while typing, the pickup grid filters the results 443 | and the editbox contain the rest of the text selected. 444 | 445 | TABLIST ---------------------------------------------------------------------- 446 | 447 | - vertical left/right 448 | 449 | BUTTON ----------------------------------------------------------------------- 450 | 451 | - profiles: text, outlined, contained, toggle 452 | 453 | - icon, icon_align left/right 454 | 455 | CHECKBOX --------------------------------------------------------------------- 456 | 457 | - tristate 458 | 459 | - children, indent 460 | 461 | SLIDER ----------------------------------------------------------------------- 462 | 463 | - label and/or numeric editbox on the left side or right side. 464 | 465 | CALENDAR --------------------------------------------------------------------- 466 | 467 | - review/add a date-math lib to luapower 468 | 469 | - make week start day configurable (ui.week_start_day = 'Mo' / 'Su') 470 | - make sunday column movable but only to position 1 or 7 471 | - add month & year above with left-right nav buttons 472 | - change month by keyboard left-right page-up/down navigation 473 | - change start week (scroll weeks vertically) by keyboard up/down navigation 474 | - scroll with scroll wheel too 475 | - alternate cell colors on consecutive months 476 | - change title to reflect month-in-view and surrounding months 477 | - we're always viewing 2 or 3 months 478 | - multi-cell select restricted to consecutive days 479 | - make it work with vertical scrolling as well 480 | - allow multiple restricted multi-cell selections 481 | - left bar with week-of-the-year number 482 | 483 | COLOR PICKER ----------------------------------------------------------------- 484 | 485 | - editboxes: hsL 486 | - parse the text and change the display accordingly 487 | - display chosen color + complementary hues 488 | - color history / select color from history 489 | - history name so we can have diff. histories depending on usage context 490 | - color dropper tool from anywhere on the desktop 491 | - hue wheel with configurable granularity + lum/sat triangle 492 | - hue/sat square with lum ramp instead of sat/lum with hue ramp 493 | 494 | MENU BAR --------------------------------------------------------------------- 495 | 496 | - 497 | 498 | MENU ------------------------------------------------------------------------- 499 | 500 | - 501 | 502 | POPUP MENU ------------------------------------------------------------------- 503 | 504 | - 505 | 506 | LINEAR CALENDAR -------------------------------------------------------------- 507 | 508 | - 509 | 510 | GRID ------------------------------------------------------------------------- 511 | 512 | 513 | - cell navigation: go to next/prev row on left-right nav 514 | - cell navigation: tab to advance cell 515 | 516 | --ctrl+page-up/page-down navigation based on fixed pages 517 | 518 | - multiple row move 519 | - row move with animation 520 | 521 | - col %-size 522 | 523 | - cell/col border collapse option 524 | 525 | - cell formatting: format-string/class/function 526 | 527 | - tooltip display on hover for clipped cells 528 | 529 | - editable 530 | - immediate mode (click) or click-click mode 531 | - tab goes to next cell 532 | - right/left goes to next/prev cell 533 | - automatic cell advancing 534 | - cell advancing with tab 535 | 536 | - tree-column 537 | - expand/collapse-all nodes option 538 | - row moving to another parent via drag & drop 539 | 540 | - col colapse/show 541 | 542 | - col shrink/expand-to-widest-row option on double-clicking between columns 543 | 544 | 545 | LATER/DATASET 546 | - sorting 547 | - sort by multiple columns 548 | ? client-side sorting with collation 549 | - filtering 550 | - quick filter (i.e. by value list) 551 | - search-in-column filter 552 | - result can be highlighting or hiding 553 | - filter by range 554 | - grouping: 555 | - group-by hierarchy 556 | - group-by multiple fields 557 | - expand/collapse all children 558 | - hide/show grouping header 559 | - invert selection 560 | - row moving to another group via drag&drop 561 | 562 | 563 | LATER/HARD 564 | - column bands 565 | - summary row 566 | - save/load grid configurations 567 | 568 | LATER/NEVER 569 | - auto row height based on wrapped text 570 | - multi-line cell text with word-wrapping 571 | - cell auto-ellipsis 572 | - vertical grid with bands 573 | 574 | 575 | DOCKING TABLIST -------------------------------------------------------------- 576 | 577 | - define docking layers and auto-accept tabs on the same docking_group. 578 | - drag tab to dock sides to split a dock tablist horizontally or vertically. 579 | 580 | - later: move tab outside its window to wrap in a popup. 581 | - later: move a popup over the window to dock it back. 582 | 583 | SETTINGS WIDGET -------------------------------------------------------------- 584 | 585 | - group options by category and show a scroll-following list of categories on the side. 586 | - filter/highlight options which have changed from default and show the default. 587 | - option type definitions for booleans, numbers, strings, multiple options etc. 588 | - boolean -> toggle, checkbox 589 | - number + range + step -> editbox, slider 590 | - single option -> choicebutton, slider, dropdown, radio buttons, grid with checkbox column 591 | - multiple options -> checkboxes 592 | - multiple options grouped -> checkbox tree 593 | - text -> editbox, multiline editbox 594 | - records -> grid, grid + CRUD, 595 | - color -> color picker 596 | - color scheme (i.e. list of colors) -> little wizard 597 | - embelishments: 598 | - description 599 | - warning popup when changing the option 600 | - 601 | - image 602 | - buttons 603 | - use an object with r/w properties to get/set the settings. 604 | - cascading hierarchy of options files: 605 | - "project" options file (look into dir and all parent dirs). 606 | - "home" options file (look in all HOME locations -- put that in fs or path). 607 | - other custom option files. 608 | - show options file hierarchy and select which file to affect changes to. 609 | 610 | 611 | RTL -------------------------------------------------------------------------- 612 | 613 | - menu bar right alignment and reverse item order 614 | - tablist right alignment and reverse item order 615 | - menu right alignment 616 | - button bar right alignment and mirror specific mirrorable icons 617 | - status bar reverse item order 618 | - use auto-reversible hierarchical flow layouts to layout: 619 | - label + actionable pairs, eg: 620 | - label + editbox combinations 621 | - icon + title + x-button on tablist items 622 | - a row of buttons 623 | - entire sections of the UI 624 | 625 | 626 | BETTER TAB-ORDER ALGORITHM --------------------------------------------------- 627 | 628 | --TODO: make a new tab-order-assigning algorithm based on horizontal and 629 | --vertical overlap between widgets and the vertical/horizontal distance 630 | --between them. 631 | 632 | --TIPS: make a weighted DAG from inspecting all positiblities 633 | --(that's O(n^2) btw) and then sort the nodes at each level based on the 634 | --weights and walk the graph in toplogical+weighted order. 635 | 636 | --[[ 637 | local function overlap( 638 | ax1, ay1, ax2, ay2, 639 | bx1, by1, bx2, by2, 640 | t1, t2 641 | ) 642 | local overlap_v = math.min(ay2, by2) - math.max(ay1, by1) 643 | local max_overlap_v = math.min(ay2 - ay1, by2 - by1) 644 | local distance_h = bx1 - ax2 645 | 646 | print( 647 | t1.id or t1.parent.id, 648 | t2.id or t2.parent.id, 649 | string.format('%.2f\t%.2f', overlap_v, distance_h), 650 | not (overlap_v < 0 or distance_h < 0) and '!!!' or '' 651 | ) 652 | 653 | if overlap_v < 0 or distance_h < 0 then 654 | return 0 655 | end 656 | assert(max_overlap_v > 0) 657 | 658 | return (overlap_v / max_overlap_v) / distance_h 659 | end 660 | 661 | print() 662 | print() 663 | print() 664 | 665 | if self.iswindow_view then 666 | for i,t in ipairs(t) do 667 | local x1, y1 = t:to_window(0, 0) 668 | local x2, y2 = t:to_window(t.w, t.h) 669 | print( 670 | t.id or t.parent.id, 671 | x1, y1 672 | ) 673 | end 674 | end 675 | 676 | ]] 677 | 678 | -------------------------------------------------------------------------------- /ui_zoomcalendar.lua: -------------------------------------------------------------------------------- 1 | 2 | --Linear Zoom Calendar widget. 3 | --Written by Cosmin Apreutesei. Public Domain. 4 | 5 | local ui = require'ui' 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('ui_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 | --------------------------------------------------------------------------------