├── Readme.md ├── data ├── button.png ├── buttonflat.png ├── buttonright.png ├── buttontop.png ├── circle.png ├── down.png ├── gradient-left.png ├── gradient-right.png ├── headerbg.png ├── heart.png ├── info.png ├── left.png ├── mainbg.png ├── menuarrows.png ├── movecircle.png ├── null.jpg ├── pause.png ├── play.png ├── right.png ├── settings.png ├── sliderbg.png ├── splitterbgdown.png ├── splitterbgup.png ├── star.png ├── transparent.png └── up.png ├── demo.gif ├── fileselect.gif ├── keyboard.gif ├── main.py ├── menu.gif ├── popup.gif ├── recycleviewselect.gif ├── smoothsetting.gif ├── snu ├── app.py ├── button.py ├── filebrowser.py ├── label.py ├── layouts.py ├── navigation.py ├── popup.py ├── recycleview.py ├── roulettescroll │ ├── LICENSE │ └── __init__.py ├── scrollview.py ├── settings.py ├── slider.py ├── smoothsetting.py ├── songplayer.py ├── stencilview.py └── textinput.py ├── test.kv └── theme.gif /Readme.md: -------------------------------------------------------------------------------- 1 | # General Info 2 | Snu Kivy Template is a collection of classes and widgets that make it easier to create theme-able, clean, and a bit flashy apps in Kivy. Please note that this depends entirely on Kivy, and will not be usable without Kivy installed first. 3 | 4 | This is a gift to the Kivy community, a culmination of what I have learned over the years when developing several kivy-based apps. Use these classes as a basis for your own, or just use them directly, I impose no limitations on the code or images enclosed in this archive. 5 | 6 | To use this template, copy the 'snu' folder to your app directory, and start importing stuff. See the main.py and test.kv files for examples on how to use the various widgets. 7 | 8 | These widgets mostly depend on the 'NormalApp' class found in 'snu.app.NormalApp'. To use the various widgets, you must make your app a subclass of this, not the basic kivy App class. 9 | 10 | Here is a demo of some of the features implemented: 11 | ![Demo](demo.gif) 12 | 13 | # snu.app.NormalApp Functionality 14 | 15 | This class is a subclass of the 'kivy.app.App' class. Please note that to set the following variables to custom values, you can pass them in when creating and running your app like this: 16 | 17 | MyApp(theme_index=1).run() 18 | 19 | This class also implements the following extra features: 20 | 21 | ## Themes 22 | Two default themes are included in the 'themes' variable, this is simply a list of dictionaries with a specific set of keys. Copy one of the theme variables and modify the color values to create your own. 23 | 24 | #### NormalApp.button_scale 25 | Numeric Property, this controls the overall scale of interface, sane values should be between 50 and 150. 26 | This value is loaded from the app config file, 'buttonscale'. 27 | When the config is changed, or when this value is changed, the app will automatically adjust scale. 28 | 29 | #### NormalApp.text_scale 30 | Relative scaling of text, sane values are between 50 and 150. 31 | This value is loaded from the app config file, 'textscale'. 32 | When the config is changed, or when this value is changed, the app will automatically adjust scale. 33 | 34 | #### NormalApp.scaling_mode 35 | Option Property, this must be set to either 'divisions' or 'pixels'. This determines how the interface will be scaled for all included widgets. See 'NormalApp.scale_amount' for more information on each option. 36 | 37 | #### NormalApp.scale_amount 38 | Numeric Property. How this is interpreted will vary depending on how the 'scaling_mode' is set. This acts as a multiplier for the button_scale variable. Defaults to 15. 39 | * If scaling_mode is set to 'divisions', this will define the number of buttons that will be vertically visible in the window at a button scale of 100%. 40 | * If scaling_mode is set to 'pixels', this variable will set the height of the standard button in pixels. 41 | 42 | A good way to make the interface scale well to different screen sizes is to use pixel scaling mode, and set the value using a function from kivy.metrics, like so: 43 | 44 | from kivy.metrics import cm 45 | MyApp(scaling_mode='pixels', scale_amount=cm(1)).run() 46 | 47 | #### NormalApp.theme_index 48 | Numeric Property, this defines the index of the theme from the 'themes' variable to be loaded on app creation. Override this variable in your app to set a different theme index to start with. 49 | ![Theme](theme.gif) 50 | 51 | #### NormalApp.load_theme(Integer) 52 | Load a specific theme index from the themes variable. This will cause the theme to 'fade' from the current theme. 53 | 54 | Tip: Try creating a fully black or fully white theme as the default, then loading your default theme in your on_start for a nice fade-in! 55 | 56 | #### NormalApp.data_to_theme(Dictionary) 57 | Load a theme dictionary as the theme for this app. Note that if any variables are missing from the dictionary, the current variables will not be changed. 58 | 59 |
60 | 61 | ## Other App Features 62 | 63 | #### NormalApp.popup_x 64 | Numeric property that sets the default width in pixels of auto-generated popups. Defaults to 640. 65 | 66 | #### NormalApp.animations 67 | Boolean Property, defaults to True. Setting this to False will disable all animations in the custom widgets. 68 | 69 | #### NormalApp.animation_length 70 | Numeric Property, defaults to 0.2. This will define the length of the animations in all custom widgets. 71 | 72 | #### NormalApp.clickfade(Widget, mode='opacity') 73 | This function will create a quick colored overlay in the shape and size of the passed-in widget. This can be used to bring the user's attention to an important widget, or to show that something has been clicked on. 74 | The 'mode' argument can be set to 'height' to cause the clickfade to fade out by shrinking in height instead of fading out (default behavior). 75 | 76 | #### NormalApp.message(String, timeout=20) 77 | This function will create a user feedback message that will automatically be displayed by all snu.label.InfoLabel widgets. 78 | This message will blink for a few seconds then vanish. 79 | Pass in a Float to the timeout variable to change the time this text is displayed. 80 | 81 | #### NormalApp.clear_message() 82 | This function will instantly clear the app message shown in the InfoLabel widgets. 83 | 84 | #### NormalApp.about() 85 | This function will open a popup that shows the about this app message. 86 | 87 | #### NormalApp.about_text 88 | String Property, this is the text that will be shown in the popup generated by the about() function shown above. 89 | 90 | #### NormalApp.message_popup(String, title='Notification') 91 | This function will open a simple message popup that displays the passed in String along with a basic 'OK' button. 92 | ![Popup](popup.gif) 93 | 94 | 95 | 96 |
97 | 98 | ## Keyboard Navigation 99 | Keyboard and joystick/gamepad navigation is implemented through some functions in the NormalApp class, and the snu.navigation.Navigation class. To use this functionality, you must first activate it in your app's on_start() function, then you must use classes that utilize the Navigation class as a mixin. Relevant classes in this template include this mixin already (all buttons, all text inputs, slider, smoothsetting and filebrowser list items). 100 | 101 | ![Keyboard](keyboard.gif) 102 | 103 | To activate navigation in your app, you must do two things: 104 | * Call one of the built-in start functions to activate it 105 | * Use widgets that mix in the snu.navigation.Navigation class 106 | 107 | To navigate between selected items, press the down or up arrow keys (or joystick down or up, or down or up on a gamepad dpad). When an item is selected, it will be highlighted by adding a colored square to the canvas.after commands. 108 | 109 | When a widget is selected, press enter, or the first button on the joystick/gamepad to activate the widget (for buttons this will press it, for text inputs this will focus it). 110 | 111 | For widgets that can be adjusted with different values such as sliders or smoothsetting widgets, pressing the left/right arrow keys, or left/right on the joystick or gamepad dpad will cause this widget to raise or lower its variable. 112 | 113 | For recycleviews with a lot of elements, it can be benefitial to jump out of the list, press the tab key to immediately jump to the next selectable widget out of the recycleview. 114 | 115 | #### NormalApp.start_keyboard_navigation() 116 | Calling this (suggested from your app's on_start() function) will tell the app to start listening to keyboard commands for navigating widgets. 117 | 118 | #### NormalApp.start_joystick_navigation() 119 | Calling this (also suggested from your app's on_start() function) will tell the app to start listening for joystick commands for navigation. 120 | 121 | #### NormalApp.navigation_enabled 122 | Boolean Property, defaults to True. Set this to False to disable all navigation keyboard intercepts temporarily. This can be useful if you wish to use the keyboard for something else temporarily. 123 | 124 | #### NormalApp.selected_next() 125 | This function will go to the next selectable item in the widget tree. 126 | 127 | #### NormalApp.selected_prev() 128 | This function will go to the previous selectable item in the widget tree. 129 | 130 | #### NormalApp.selected_left() 131 | This function will attempt to activate the 'decrease' function for the current selected widget if there is one. 132 | 133 | #### NormalApp.selected_right() 134 | This function will attempt to activate the 'increase' function for the current selected widget if there is one. 135 | 136 | #### NormalApp.selected_skip() 137 | This function will skip to the next recycleview if inside one, otherwise it will go to the next selectable item in the tree. 138 | 139 | #### NormalApp.selected_clear() 140 | This function will properly unset the current selected item. Please do not simply set "NormalApp.selected_object = None" as this will not tell the widget it has been deselected. 141 | 142 | #### NormalApp.navigation_next 143 | This is a list containing keyboard or joystick scancodes to use for navigating to the next selectable widget down in the tree. This defaults to arrow key down, joystick down, and dpad down. You can customize your keyboard or joystick controls by changing this list in your app to whatever scancodes you wish. 144 | 145 | #### NormalApp.navigation_prev 146 | This is a list containing scancodes to use for navigating to the previous selectable widget in the tree. This defaults to arrow key up, joystick up, and dpad up. 147 | 148 | #### NormalApp.navigation_activate 149 | This is a list containing scancodes to use for activating the currently selected widget. This defaults to enter, and the first button on the joystick or gamepad. 150 | 151 | #### NormalApp.navigation_right 152 | This is a list containing scancodes to use for increasing the value of a selected widget that supports this. This defaults to arrow key right, joystick right, and dpad right. 153 | 154 | #### NormalApp.navigation_left 155 | This is a list containing scancodes to use for decreasing the value of a selected widget that supports this. This defaults to arrow key left, joystick left, and dpad left. 156 | 157 | #### NormalApp.navigation_jump 158 | This is a list containing scancodes to use for jumping out of a recycleview list of selectable widgets. This defaults to the keyboard Tab key. 159 | 160 | #### NormalApp.joystick_deadzone 161 | This is a numeric property, range of 0-1. This value represents the percentage of the joystick axis that is ignored. Defaults to 0.25. 162 | 163 | #### snu.navigation.Navigation mixin class 164 | To have your own classes selectable by the navigation functions, please mix this class into your own like this: 165 | 166 | from snu.navigation import Navigation 167 | class MyButton(Button, Navigation): 168 | pass 169 | 170 | The navigation functions will also call several functions when navigation related things happen, you may override these function to customize how your widget behaves under keyboard navigation. 171 | 172 | * on_navigation_activate() 173 | This function is called when the widget is selected and the 'navigation_activate' key is pressed. 174 | 175 | * on_navigation_increase() 176 | This function is called when the widget is selected and the 'navigation_right' key is pressed. 177 | 178 | * on_navigation_decrease() 179 | This function is called when the widget is selected and the 'navigation_left' key is pressed. 180 | 181 | * on_navigation_next() 182 | This function is called on the selected widget before the next widget in the tree is navigated to. Have this function return True to 'stick' the keyboard navigation to this widget, this is useful for enabling custom navigation of child widgets. When the children have been navigated, have this function return False. 183 | 184 | * on_navigation_prev() 185 | This function is called on the selected widget before the previous widget in the tree is navigated to. Have this function return True to 'stick' the keyboard navigation to this widget, this is useful for enabling custom navigation of child widgets. When the children have been navigated, have this function return False. 186 | 187 | * on_navigation_select() 188 | This function is called when a widget is selected. This should not need to be overriden normally, but widgets with custom canvas.after code may have issues with the default function. 189 | 190 | * on_navigation_deselect() 191 | This function is called when a widget is de-selected. Like the select function, this should not need to be overriden normally. 192 | 193 | Possible Problems: 194 | Note that by default the enter key is the activate key, this can cause problems with multi-line inputs, be ready to change this function, or override the on_navigation_activate for any multiline text inputs. 195 | Extremely complex widget trees, or multi-layer recycleviews may cause problems, I simply cannot test all combinations, please submit a bug report if you have any issues. 196 | 197 |
198 | 199 | ## Crashlog Saving 200 | A couple of helper functions to assist with saving logs for crashes are included. 201 | To use these functions, you should wrap your main app loop in a try/except like so: 202 | 203 | if __name__ == '__main__': 204 | try: 205 | MyApp().run() 206 | except Exception as e: 207 | try: 208 | MyApp().save_crashlog() 209 | except: 210 | pass 211 | os._exit(-1) 212 | 213 | Please note that by default, this will only save the most recent crash log, and you are responsible for providing the user a method to view/save this log. 214 | 215 | #### NormalApp.get_crashlog_file() 216 | This function will return the default file location for the crash log. This should end up saved in the same folder as your app will save it's settings file. 217 | You can use this function to return the file for saving where the user will have better access to it (or, for instance, to send via email). 218 | Be warned that this file may not exist yet! 219 | 220 | #### NormalApp.save_crashlog() 221 | This function should be called immediately after the app crashes, it and it will save the kivy log along with any traceback information with the filename returned by get_crashlog_file(). 222 | 223 |
224 | 225 | # snu.button Classes 226 | 227 | #### Theme Colors 228 | This button will automatically use the current theme's colors for background and text, and will animate between them for nice smooth button presses. 229 | 230 | #### Theme Sizes 231 | All buttons will default to being app.button_scale height. 232 | All button text will default to being the app.text_scale size. 233 | 234 | #### ButtonBase.warn 235 | Boolean Property, setting this to True will cause this button to be the theme's button_warn colors instead of the standard colors. 236 | 237 | #### snu.button.ButtonBase 238 | All buttons are based on this class. 239 | 240 | #### snu.button.NormalButton 241 | Based on the ButtonBase class, this button will only be as wide as it needs to be to include the shown text. 242 | 243 | #### snu.button.WideButton 244 | Based on the ButtonBase class, this button has a size_hint_x of 1. 245 | 246 | #### snu.button.NormalMenuStarter 247 | Similar to the NormalButton above, but also shows a double-arrow graphic to show that this is a dropdown menu. 248 | 249 | #### snu.button.WideMenuStarter 250 | Similar to the WideButton above, but also shows the double-arrow graphic to indicate that this is a dropdown menu. 251 | 252 | #### snu.button.MenuButton 253 | Similar to the WideButton above, but using the menu button colors from the theme. 254 | 255 | #### snu.button.NormalToggle 256 | Similar to the NormalButton, but using the toggle button colors from the theme, and implementing toggle button functionality. 257 | 258 | #### snu.button.WideToggle 259 | Like NormalToggle, but with a size_hint_x of 1 260 | 261 | #### snu.button.SettingsButton 262 | Special button with the 'hamburger' icon and square-shaped. Clicking this button will open the app settings. 263 | 264 | #### snu.button.NormalDropDown 265 | Themed widget for DropDown menus, use instead of the standard DropDown class. This also includes a nice opening animation. 266 | ![Menu](menu.gif) 267 | 268 |
269 | 270 | # snu.label Classes 271 | 272 | #### Theme Colors 273 | All labels will use the theme colors for text color 274 | 275 | #### Theme Sizes 276 | All labels will default to being app.button_scale height. 277 | All labels' text will default to the app.text_scale size. 278 | 279 | #### snu.label.NormalLabel 280 | Standard label class, label is full width and text will be horizontally centered. 281 | 282 | #### snu.label.ShortLabel 283 | This label will only be as wide as the text is. 284 | 285 | #### snu.label.LeftNormalLabel 286 | Full width label, text will be aligned to the left side. 287 | 288 | #### snu.label.HeaderLabel 289 | Larger font size and colored using theme's header_text value for the color. 290 | 291 | #### snu.label.InfoLabel 292 | Special label that is filled with the app.infotext text, will also flash when the text changes. 293 | 294 | #### snu.label.TickerLabel 295 | Label that scrolls text back and forth when the text is larger than the label size. Set 'ticker_delay' to adjust delay in seconds before label scrolls. Set 'ticker_amount' to change the scroll pixel size per frame, this can be less than one for slower scrolling. 296 | 297 |
298 | 299 | # snu.layouts Classes 300 | Special classes that help with layouting. 301 | 302 | #### snu.layouts.SmallSpacer 303 | Empty widget that is 1/4 the button size in both width and height. 304 | 305 | #### snu.layouts.MediumSpacer 306 | Empty widget that is 1/2 the button size in both width and height. 307 | 308 | #### snu.layouts.LargeSpacer 309 | Empty widget that is the button size in both width and height. 310 | 311 | #### snu.layouts.Header 312 | Horizontal BoxLayout that is the button height, uses the headerbg image from the data folder as its background, and is colored based on the theme main_background color. 313 | 314 | #### snu.layouts.Holder 315 | Similar to the Header class, but with no background or coloring. 316 | 317 | #### snu.layouts.MainArea 318 | Vertical BoxLayout that uses the mainbg image from the data directory as a background, and is colored based on the theme main_background variable. 319 | 320 |
321 | 322 | # snu.popup Classes 323 | Popups can be easily created and themed using these classes. 324 | ``` 325 | def delete_function(content, answer): 326 | popup.dismiss() 327 | if answer == "yes": 328 | do_delete(content.data) 329 | 330 | content = ConfirmPopupContent(text="Are You Sure?", yes_text="Absolutely!", no_text="Wait...", warn_yes=True, data=stuff_to_delete) 331 | content.bind(on_answer=delete_function) 332 | popup = NormalPopup(title="Delete This?", content=content) 333 | popup.open() 334 | ``` 335 | 336 | 337 | #### snu.popup.NormalPopup 338 | Themed popup class using the panelbg image from the data directory and theme's menu_background color. 339 | 340 | #### snu.popup.MessagePopupContent 341 | Basic popup content that has a message and a close button. 342 | 343 | #### snu.popup.InputPopupContent 344 | Basic popup content that has a labeled textinput and ok/cancel buttons. 345 | 346 | #### snu.popup.ConfirmPopupContent 347 | Basic popup content that has a message and ok and cancel buttons. 348 | 349 |
350 | 351 | # snu.recycleview Classes 352 | 353 | #### snu.recycleview.NormalRecycleView 354 | Themed RecycleView class using theme settings for scrollbar size and colors. 355 | 356 | #### snu.recycleview.SelectableRecycleBoxLayout 357 | Subclass of RecycleBoxLayout that implements selection behavior when paired with RecycleItem subclasses. 358 | Warning: not using a RecycleItem subclass for the viewclass will not allow for selection behavior. 359 | Set the 'multiselect' variable to True to enable multi select mode: Shift-click to select a range of items, Ctrl-click to select multiple items. 360 | ![Recycleview](recycleviewselect.gif) 361 | 362 | #### snu.recycleview.SelectableRecycleGridLayout 363 | Same as SelectableRecycleBoxLayout, but in a gridlayout. By default it will attempt to reflow the number of columns based on a width of 4 times the button scale. 364 | 365 | #### snu.recycleview.RecycleItem 366 | Specialized class that is designed to be mixed with other classes and placed in a recycleview. 367 | This class allows recycleview items to be selected, removed in an animated fashion, and have alternating colors to make rows easier to see. 368 | 369 | #### snu.recycleview.RecycleItemLabel 370 | Subclass of RecycleItem that includes a NormalLabel class, shown as an example for mixing other classes with RecycleItem, and provided for convenience. 371 | 372 |
373 | 374 | # snu.scrollview Classes 375 | 376 | ## snu.scrollview.Scroller 377 | Themed subclass of ScrollView, scrollbar will be sized and colored based on theme settings. 378 | 379 | ## snu.scrollview.ScrollViewCentered 380 | Subclass of Scroller, begins in a centered position. 381 | 382 | ## snu.scrollview.ScrollWrapper 383 | Subclass of Scroller, allows ScrollView clasess to be placed inside of it and still respond to touches. Internal ScrollViews must be added to the 'masks' property, for example: 384 | 385 | : 386 | masks: [subscroller] 387 | Scroller: 388 | id: subscroller 389 | 390 | ## snu.scrollview.TouchScroller 391 | Subclass of ScrollView, removes scrollbars and allows for finer control over touch events. 392 | 393 | #### TouchScroller.allow_middle_mouse 394 | Set this to True to enable scrolling with the middle mouse button (blocks middle mouse clicks on child widgets). 395 | 396 | #### TouchScroller.allow_flick 397 | Set this to True to enable touch 'flicks' to scroll the view (after touch release, scrolling will continue for a while). 398 | 399 | #### TouchScroller.allow_drag 400 | Set this to True to enable click-n-drag scrolling within the scrollview itself, stanndard touch-style scrolling. 401 | 402 | #### TouchScroller.allow_wheel 403 | Set this to True to enable scrolling via the mouse wheel or two-finger swipe scrolling. 404 | 405 | #### TouchScroller.masks 406 | ListProperty, add any child widgets to this, and they will receive all touches on them, blocking any touch controlls of this widget within their bounds. 407 | 408 | ## snu.scrollview.ScrollBarX 409 | Implements a scrollbar as its own widget, completely separate from the scrollview. This is intended to be used with TouchScroller, but can be used with any ScrollView-based class. 410 | This scrollbar also includes some convencience features like clicking in the open area to scroll to that point, and limiting the minimum size of the scroll control to be no smaller than its width. 411 | 412 | #### ScrollBarX.scroller 413 | Should point to the ScrollView based widget that this scrollbar will control. 414 | 415 | #### ScrollBarX.rounding 416 | Apply a rounding of this number of pixels to the corners of the scroller control. 417 | 418 | #### ScrollBarX.is_active 419 | Will be set to True if the scroll area is large enough to be scrolled. 420 | 421 | #### ScrollBarX.autohide 422 | If set to True, this widget will shrink to 0 width if the scroll area is not large enough to be scrolled 423 | 424 | 425 | 426 |
427 | 428 | # snu.slider Classes 429 | 430 | #### snu.slider.SpecialSlider 431 | Custom subclass of kivy.uix.slider.Slider, implements a double-click reset function. 432 | A function must be bound to the 'reset_value()' function to allow this to work. 433 | For example, in kvlang: 434 | 435 | SpecialSlider: 436 | reset_value: root.reset_function 437 | 438 | 439 | #### snu.slider.NormalSlider 440 | Themed slider based on SpecialSlider. Uses colors from the theme and the sliderbg image from the data directory. 441 | 442 |
443 | 444 | # snu.filebrowser.FileBrowser 445 | A widget that displays a navigable file browser layout complete with a sidebar for system folders and shortcuts. Two events are called, on_select and on_cancel when the user clicks the select and cancel buttons. 446 | ![Fileselect](fileselect.gif) 447 | Due to the many different uses for a file browser, there are a lot of options available to control the behavior of this widget. Unfortunately, some of these options can conflict with each other, so here are some examples. 448 | The default settings are good for opening a single file, just use: 449 | 450 | FileBrowser() 451 | 452 | To select a folder to export a pre-defined file, use: 453 | 454 | FileBrowser(file_select=False, folder_select=True, show_files=False, show_filename=False) 455 | 456 | To let the user select a folder to export a file that they define, use (note that you should check the 'edited_selected' value, not 'selected'): 457 | 458 | FileBrowser(edit_filename=True, clear_filename=False, file_select=False, default_filename='default.txt') 459 | 460 | #### FileBrowser.selected 461 | List Property. This is the files or directories that the dialog selected. Only the filename will be included, not the full path. 462 | 463 | #### FileBrowser.edited_selected 464 | String Property. When edit_filename is enabled, and multi_select is disabled, this variable will be set to the text in the filename input field, allowing the user to provide a custom filename. 465 | 466 | #### FileBrowser.folder 467 | String Property. This is the folder that the dialog is currently displaying. Set this to start in a specific folder, and read it to find out what folder the user ended up in. 468 | 469 | #### FileBrowser.filetypes_filter 470 | List Property. This is a list of extensions to display. Set it to empty to display all files. This expects standard wildcard style matches, such as: 471 | 472 | FileBrowser(filetypes_filter=['*.png', '*.jpg']) 473 | 474 | #### FileBrowser.default_filename 475 | String Property. When the widget is created, the filename field will be set to this value. This can be used to display the filename that will be written, or to give the user a starting point for a filename. 476 | 477 | #### FileBrowser.file_select 478 | Boolean Property, defaults to True. When this is True, the dialog can be used for selecting files. Note that files will still be displayed by default, unless you hide them. 479 | 480 | #### FileBrowser.folder_select 481 | Boolean Property, defaults to False. When this is True, the dialog can be used for selecting folders. Keep in mind that this will not disable file_select, if you only want to select folders please disable that variable. 482 | 483 | #### FileBrowser.multi_select 484 | Boolean Property, defaults to False. Setting this to True allows multiple files to be selected. Clicking a file will toggle selection, and a range of files can be selected with shift-click. Please note that only one folder may be selected, even if this is set to True. 485 | 486 | #### FileBrowser.show_files 487 | Boolean Property, defaults to True. Display files in the browser. Setting this to False will result in only folders being shown. 488 | 489 | #### FileBrowser.show_hidden 490 | Boolean Property, defaults to True. Display hidden files when show_files is enabled. 491 | 492 | #### FileBrowser.require_filename 493 | Boolean Property, defaults to True. When this is True, the select button cannot be clicked if no file or folder is selected or provided. 494 | 495 | #### FileBrowser.edit_filename 496 | Boolean Property, defaults to False. Setting this to True allows the user to enter a filename manually, or to edit existing filenames. This can only be used when multi_select is disabled. The edited filename will be stored in the variable 'edited_selected', be sure to check this if you wish to use the user's edit. 497 | Be warned: enabling this may result in the 'selected' variable being empty, and only the 'edited_selected' variable containing a valid value. 498 | 499 | #### FileBrowser.clear_filename 500 | Boolean Property, defaults to True. When this is True, the filename will be cleared when in file select mode and the folder is changed. 501 | 502 | #### FileBrowser.autoselect_files 503 | Boolean Property, defaults to False. Setting this to True will cause all files to be selected in the folder when each folder is opened. This variable only works when multi_select is enabled. 504 | 505 | #### FileBrowser.cancel_text 506 | String Property, defaults to 'Cancel'. This is the text shown on the cancel button. 507 | 508 | #### FileBrowser.select_text 509 | String Property, defaults to 'Select'. This is the text shown on the confirm or ok button. 510 | 511 | #### FileBrowser.shortcuts_size 512 | Numeric Property, defaults to 0.5. This is the size_hint_x variable for the shortcuts area on the right side, also controls the width of the ok and cancel buttons. 513 | 514 | #### FileBrowser.show_cancel 515 | Boolean Property, defaults to True. Setting this to False will hide the cancel button. Hide this if you have a different method for canceling the file selection. 516 | 517 | #### FileBrowser.show_select 518 | Boolean Property, defaults to True. Setting this to False will hide the ok button. Hide this if you have a different way of confirming a file selection. 519 | 520 | #### FileBrowser.show_folder_edit 521 | Boolean Property, defaults to True. Setting this to False will hide the new folder and delete folder buttons. Hide these if you do not want the user to modify the folder structure. 522 | 523 | #### FileBrowser.show_filename 524 | Boolean Property, defaults to True. Setting this to False will hide the selected filename field. This may be useful to hide for export dialogs, or for folder selection dialogs. 525 | 526 |
527 | 528 | # snu.songplayer Classes 529 | 530 | #### snu.songplayer.SongPlayer 531 | Class based on kivy's kivy.uix.videoplayer.VideoPlayer class, but meant for playing audio. Includes a stop, play/pause, volume buttons, and a slider for displaying and seeking the song position. Most variables and functions that make sense are ported over from the VideoPlayer, see kivy's documentation for more information on these. 532 | * Variables for setting, seeing the song: source, duration, position, volume, state 533 | * Variables for theme: image_play, image_stop, image_pause, image_volumehigh, image_volumemedium, image_volumelow, image_volumemuted 534 | * Functions: seek() 535 | 536 | # snu.stencilview Classes 537 | 538 | #### snu.stencilview.StencilViewTouch 539 | Subclass of kivy.uix.stencilview that limits touches to the stenciled area only. 540 | 541 |
542 | 543 | # snu.textinput Classes 544 | All text inputs default to being the standard button height, and are themed based on the theme colors. 545 | 546 | ## snu.textinput.NormalInput 547 | Themed TextInput with some standard convenience settings. By default provides no limitations on text entered, implements a right-click/long-press context menu for standard clipboard operations. 548 | 549 | #### NormalInput.underline_pos 550 | Horizontal position (float) from where the underline will grow from. 0 Will result in the line starting on the left, 0.5 from the center, and 1 from the right side. 551 | 552 | #### NormalInput.activate_time 553 | Time in seconds for the animate in effect. 554 | 555 | #### NormalInput.deactivate_time 556 | Time in seconds for the animate out effect. 557 | 558 | #### NormalInput.background_color 559 | Color that the input background will be when not active. 560 | 561 | #### NormalInput.background_color_active 562 | Color that the background will fade to when the text input is focused. 563 | 564 | #### NormalInput.background_border_color 565 | Color of the border line around the background. 566 | 567 | #### NormalInput.background_border_width 568 | Thickness of the border line around the background. 569 | 570 | #### NormalInput.rounded 571 | Radius of rounded corners on background. 572 | 573 | #### NormalInput.animate_hint 574 | Boolean variable. When set to True, the hint text will animate to the top of the text input and remain visible. 575 | 576 | #### NormalInput.allow_mode 577 | Set this to 'float', 'integer', 'filename' or 'url' to limit allowed characters to those modes. See FloatInput and IntegerInput for more information. 578 | 579 | #### NormalInput.allow_negative 580 | Boolean variable. When allow_mode is set to 'float' or 'integer', this will allow negative numbers when set to True, or disallow when set to False. 581 | 582 | #### NormalInput.press_enter(String) 583 | This function is called when the 'Enter' key is pressed in the text input field. This function will be passed the textinput widget, and the current text in the widget. You can overwrite the function in your own subclass, or bind it to another function like so: 584 | 585 | NormalInput: 586 | press_enter: root.search 587 | 588 | ## snu.textinput.FloatInput 589 | Themed TextInput widget that limits inputted text to only numbers and a single period. 590 | Features 'allow_negative' boolean option, set to False to prevent negative numbers. 591 | 592 | ## snu.textinput.IntegerInput 593 | Themed TextInput widget that limits inputted text to numbers only. 594 | Features 'allow_negative' boolean option, set to False to prevent negative numbers. 595 | 596 |
597 | 598 | # snu.smoothsetting.SmoothSetting Class 599 | This is a touch and mouse-friendly horizontal list selection widget. It is designed to show a scrollable list of elements that can be selected with a swipe, drag, or clicking the left/right buttons. This widget uses the 'Roulette Scroll Effect' class originally from the kivy garden (https://github.com/kivy-garden/garden.roulettescroll) to create a 'snapping' scroll effect. 600 | ![Smoothsetting](smoothsetting.gif) 601 | 602 | #### SmoothSetting.content 603 | List Property. This should contain a list of strings that define the names of the elements to show. 604 | 605 | #### SmoothSetting.start_on 606 | Numeric Property, defaults to 0. This is the index number in the 'content' that this widget will display on initialization. 607 | 608 | #### SmoothSetting.active 609 | Numeric Property. This is the currently set index of the content list that the selector is set to. Read this value to determine what the user has picked. 610 | 611 | #### SmoothSetting.repeat_length 612 | NumericProperty, defaults to 1. This is how long in seconds that the left or right button should be held down before it starts being repeated. 613 | 614 | #### SmoothSetting.repeat_minimum 615 | NumericProperty, defaults to 0.1. This is the minimum repeat time in seconds that will be accelerated to. 616 | 617 | #### SmoothSetting.item_width 618 | NumericProperty, defaults to the widget's height. This is the width of each element in the list. Be sure to set this higher if you are using longer strings for your content! 619 | 620 | #### SmoothSetting.control_width 621 | NumericProperty, defaults the widget's height. This is the width of the left/right buttons. Setting this to 0 will completely hide and disable the buttons. 622 | 623 | #### SmoothSetting.left_image/SmoothSetting.right_image 624 | String Property. This points to the filename for the image used for the left/right buttons to change the setting. Defaults to a simple arrow. 625 | 626 | #### SmoothSetting.gradient_transparency 627 | NumericProperty, defaults to 0.5. This widget has a gradient overlay to fade out the non-selected numbers by default, the transparency of this gradient can be set here. Set to 0 for no gradient, 1 for a very heavy gradient. 628 | 629 | #### SmoothSetting.rounding 630 | Numeric Property, defaults to 10. This is the number of pixels to apply rounding to the corners of the widget, increase this value for a more rounded widget, or set to 0 for a square widget. 631 | 632 |
633 | 634 | # snu.settings 635 | Fully themed settings screen that follows the colors of the rest of the app. 636 | Implements an 'aboutbutton' settings item that shows a button that opens the app's about popup. 637 | -------------------------------------------------------------------------------- /data/button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/button.png -------------------------------------------------------------------------------- /data/buttonflat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/buttonflat.png -------------------------------------------------------------------------------- /data/buttonright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/buttonright.png -------------------------------------------------------------------------------- /data/buttontop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/buttontop.png -------------------------------------------------------------------------------- /data/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/circle.png -------------------------------------------------------------------------------- /data/down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/down.png -------------------------------------------------------------------------------- /data/gradient-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/gradient-left.png -------------------------------------------------------------------------------- /data/gradient-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/gradient-right.png -------------------------------------------------------------------------------- /data/headerbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/headerbg.png -------------------------------------------------------------------------------- /data/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/heart.png -------------------------------------------------------------------------------- /data/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/info.png -------------------------------------------------------------------------------- /data/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/left.png -------------------------------------------------------------------------------- /data/mainbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/mainbg.png -------------------------------------------------------------------------------- /data/menuarrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/menuarrows.png -------------------------------------------------------------------------------- /data/movecircle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/movecircle.png -------------------------------------------------------------------------------- /data/null.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/null.jpg -------------------------------------------------------------------------------- /data/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/pause.png -------------------------------------------------------------------------------- /data/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/play.png -------------------------------------------------------------------------------- /data/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/right.png -------------------------------------------------------------------------------- /data/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/settings.png -------------------------------------------------------------------------------- /data/sliderbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/sliderbg.png -------------------------------------------------------------------------------- /data/splitterbgdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/splitterbgdown.png -------------------------------------------------------------------------------- /data/splitterbgup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/splitterbgup.png -------------------------------------------------------------------------------- /data/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/star.png -------------------------------------------------------------------------------- /data/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/transparent.png -------------------------------------------------------------------------------- /data/up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/data/up.png -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/demo.gif -------------------------------------------------------------------------------- /fileselect.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/fileselect.gif -------------------------------------------------------------------------------- /keyboard.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/keyboard.gif -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from snu.app import * 3 | from snu.button import * 4 | from snu.label import * 5 | from snu.layouts import * 6 | from snu.popup import * 7 | from snu.scrollview import * 8 | from snu.slider import * 9 | from snu.stencilview import * 10 | from snu.textinput import * 11 | from snu.filebrowser import * 12 | from snu.recycleview import * 13 | from snu.songplayer import * 14 | from snu.smoothsetting import * 15 | from kivy.base import EventLoop 16 | from kivy.core.window import Window 17 | from kivy.properties import ObjectProperty, ListProperty 18 | from kivy.uix.screenmanager import ScreenManager, Screen 19 | app = None 20 | 21 | Window.size = (800, 600) 22 | 23 | 24 | class RecycleItemButton(RecycleItem, NormalButton): 25 | pass 26 | 27 | 28 | class MainScreen(Screen): 29 | """Example Screen Widget""" 30 | 31 | menu = ObjectProperty() 32 | recycle_data_1 = ListProperty() 33 | recycle_data_2 = ListProperty() 34 | 35 | def filebrowser_popup(self): 36 | if app.popup: 37 | app.popup.dismiss() 38 | content = FileBrowser() 39 | #content = FileBrowser(file_select=False, folder_select=True, show_files=False, show_filename=False) 40 | #content = FileBrowser(edit_filename=True, clear_filename=False, file_select=False, default_filename='default.txt') 41 | content.bind(on_select=self.filebrowser_select) 42 | content.bind(on_cancel=self.filebrowser_cancel) 43 | app.popup = NormalPopup(title='Select A File', content=content, size_hint=(1, 1)) 44 | app.popup.open() 45 | 46 | def filebrowser_select(self, browser): 47 | app.message('Selected: '+';'.join(browser.selected)) 48 | #app.message('Selected: '+browser.edited_selected) 49 | app.popup.dismiss() 50 | 51 | def filebrowser_cancel(self, browser): 52 | app.message('Cancelled file selection') 53 | app.popup.dismiss() 54 | 55 | def input_popup(self): 56 | if app.popup: 57 | app.popup.dismiss() 58 | content = InputPopupContent(text='Input A Value') 59 | content.bind(on_answer=self.answer_popup) 60 | app.popup = NormalPopup(title='Input Popup', content=content, size_hint=(None, None), size=(app.popup_x, app.button_scale * 5)) 61 | app.popup.open() 62 | 63 | def question_popup(self): 64 | if app.popup: 65 | app.popup.dismiss() 66 | content = ConfirmPopupContent(text='Are You Sure?', warn_yes=True) 67 | content.bind(on_answer=self.answer_popup) 68 | app.popup = NormalPopup(title='Question Popup', content=content, size_hint=(None, None), size=(app.popup_x, app.button_scale * 4)) 69 | app.popup.open() 70 | 71 | def answer_popup(self, instance, answer): 72 | app.popup.dismiss() 73 | app.message('Popup Gave Answer: '+answer) 74 | 75 | def message(self, instance): 76 | self.menu.dismiss() 77 | app.message('Called Menu Item: ' + instance.text) 78 | 79 | def on_enter(self): 80 | self.recycle_data_1 = [{'text': 'First'}, {'text': 'Second'}, {'text': 'Third'}, {'text': 'Fourth'}, {'text': 'Fifth'}, {'text': 'Sixth'}, {'text': 'Seventh'}] 81 | self.recycle_data_2 = self.recycle_data_1 82 | self.menu = NormalDropDown() 83 | for menu_button_text in ['First', 'Second', 'Third', 'Fourth']: 84 | menu_button = MenuButton(text=menu_button_text) 85 | menu_button.bind(on_release=self.message) 86 | self.menu.add_widget(menu_button) 87 | 88 | 89 | class Test(NormalApp): 90 | screen_manager = ObjectProperty() 91 | 92 | def build(self): 93 | """Called when app is initialized, kv files are not loaded, but other data is""" 94 | 95 | global app 96 | app = self 97 | self.screen_manager = ScreenManager() 98 | self.main() 99 | return self.screen_manager 100 | 101 | def on_start(self): 102 | """Called when the app is started, after kv files are loaded""" 103 | 104 | self.start_keyboard_navigation() 105 | self.start_joystick_navigation() 106 | self.load_theme(1) 107 | EventLoop.window.bind(on_keyboard=self.hook_keyboard) 108 | 109 | def on_pause(self): 110 | """Called when the app is suspended or paused, need to make sure things are saved because it might not come back""" 111 | 112 | self.config.write() 113 | return True 114 | 115 | def on_stop(self): 116 | """Called when the app is about to be ended""" 117 | 118 | self.config.write() 119 | 120 | def hook_keyboard(self, window, key, *_): 121 | """This function receives keyboard events, such as the 'back' or 'escape' key.""" 122 | 123 | del window 124 | if key == 27: #Escape/Back key 125 | if Window.keyboard_height > 0: 126 | Window.release_all_keyboards() 127 | return True 128 | elif not self.screen_manager.current_screen: 129 | return False 130 | elif self.screen_manager.current != 'main': 131 | self.main() 132 | return True 133 | 134 | def main(self): 135 | """Switches the screen manager to the 'main' screen layout. 136 | Uses 'lazy' loading to ensure first startup is as quick as possible.""" 137 | 138 | if 'main' not in self.screen_manager.screen_names: 139 | self.screen_manager.add_widget(MainScreen(name='main')) 140 | self.screen_manager.current = 'main' 141 | 142 | 143 | if __name__ == '__main__': 144 | theme = { 145 | "name": "White", 146 | "button_down": [1, 1, 1, 0], 147 | "button_up": [1, 1, 1, 0], 148 | "button_text": [1, 1, 1, 0], 149 | "button_warn_down": [1, 1, 1, 0], 150 | "button_warn_up": [1, 1, 1, 0], 151 | "button_toggle_true": [1, 1, 1, 0], 152 | "button_toggle_false": [1, 1, 1, 0], 153 | "button_menu_up": [1, 1, 1, 0], 154 | "button_menu_down": [1, 1, 1, 0], 155 | "button_disabled": [1, 1, 1, 0], 156 | "button_disabled_text": [1, 1, 1, 0], 157 | "header_background": [1, 1, 1, 0], 158 | "header_text": [1, 1, 1, 0], 159 | "info_text": [1, 1, 1, 0], 160 | "info_background": [1, 1, 1, 0], 161 | "input_background": [1, 1, 1, 0], 162 | "scroller": [1, 1, 1, 0], 163 | "scroller_selected": [1, 1, 1, 0], 164 | "sidebar_resizer": [1, 1, 1, 0], 165 | "slider_grabber": [1, 1, 1, 0], 166 | "slider_background": [1, 1, 1, 0], 167 | "main_background": [1, 1, 1, 0], 168 | "menu_background": [1, 1, 1, 0], 169 | "area_background": [1, 1, 1, 0], 170 | "text": [1, 1, 1, 0], 171 | "disabled_text": [1, 1, 1, 0], 172 | "selected": [1, 1, 1, 0], 173 | "background": [1, 1, 1, 1], 174 | "selected_overlay": [1, 1, 1, 0], 175 | } 176 | themes.insert(0, theme) 177 | try: 178 | Test().run() 179 | except Exception as e: 180 | try: 181 | Test().save_crashlog() 182 | except: 183 | print(e) 184 | os._exit(-1) 185 | -------------------------------------------------------------------------------- /menu.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/menu.gif -------------------------------------------------------------------------------- /popup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/popup.gif -------------------------------------------------------------------------------- /recycleviewselect.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/recycleviewselect.gif -------------------------------------------------------------------------------- /smoothsetting.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/smoothsetting.gif -------------------------------------------------------------------------------- /snu/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import json 4 | from kivy.app import App 5 | from kivy.clock import Clock, mainthread 6 | from kivy.uix.widget import Widget 7 | from kivy.core.window import Window 8 | from kivy.logger import Logger, LoggerHistory 9 | from kivy.properties import ListProperty, ObjectProperty, NumericProperty, StringProperty, BooleanProperty, OptionProperty 10 | from kivy.uix.recycleview import RecycleView 11 | from kivy.uix.dropdown import DropDown 12 | from kivy.uix.modalview import ModalView 13 | from kivy.uix.screenmanager import ScreenManager 14 | from kivy.uix.settings import Settings 15 | from kivy.utils import platform 16 | from .navigation import Navigation 17 | from .textinput import InputMenu 18 | from .popup import MessagePopupContent, NormalPopup 19 | from .button import ClickFade 20 | from .settings import * 21 | 22 | desktop = platform in ['win', 'linux', 'macosx', 'unknown'] 23 | 24 | themes = [ 25 | { 26 | "name": "Clean And Bright", 27 | "button_down": [0.651, 0.651, 0.678, 1.0], 28 | "button_up": [0.8, 0.8, 0.8, 1.0], 29 | "button_text": [0.0, 0.0, 0.0, 1.0], 30 | "button_warn_down": [0.78, 0.33, 0.33, 1.0], 31 | "button_warn_up": [1.0, 0.493, 0.502, 1.0], 32 | "button_toggle_true": [0.31, 0.68, 0.46, 1.0], 33 | "button_toggle_false": [0.678, 0.651, 0.678, 1.0], 34 | "button_menu_up": [0.686, 0.686, 0.686, 1.0], 35 | "button_menu_down": [0.511, 0.52, 0.52, 1.0], 36 | "button_disabled": [0.686, 0.695, 0.721, 0.669], 37 | "button_disabled_text": [1.0, 1.0, 1.0, 0.748], 38 | "header_background": [1.0, 1.0, 1.0, 0.397], 39 | "header_text": [0.107, 0.099, 0.099, 1.0], 40 | "info_text": [0.0, 0.0, 0.0, 1.0], 41 | "info_background": [1.0, 1.0, 0.0, 0.75], 42 | "input_background": [1.0, 1.0, 1.0, 0.651], 43 | "scroller": [0.7, 0.7, 0.7, 0.388], 44 | "scroller_selected": [0.7, 0.7, 0.7, 0.9], 45 | "sidebar_resizer": [0.862, 1.0, 0.897, 1.0], 46 | "slider_grabber": [0.45, 0.45, 0.458, 1.0], 47 | "slider_background": [1.0, 1.0, 1.0, 1.0], 48 | "main_background": [0.616, 0.616, 0.616, 0.32], 49 | "menu_background": [0.529, 0.537, 0.537, 1.0], 50 | "text": [0.0, 0.011, 0.0, 1.0], 51 | "disabled_text": [0.0, 0.0, 0.0, 0.572], 52 | "selected": [0.239, 1.0, 0.344, 0.634], 53 | "active": [1.0, 0.239, 0.344, 0.5], 54 | "background": [1.0, 1.0, 1.0, 1.0], 55 | "selected_overlay": [.8, 1, .8, .33], 56 | }, 57 | { 58 | "name": "Blue And Green", 59 | "button_down": [0.48, 0.59, 0.62, 1.0], 60 | "button_up": [0.35, 0.49, 0.53, 1.0], 61 | "button_text": [1.0, 1.0, 1.0, 0.9], 62 | "button_warn_down": [0.78, 0.33, 0.33, 1.0], 63 | "button_warn_up": [0.8, 0.08, 0.08, 1.0], 64 | "button_toggle_true": [0.31, 0.68, 0.46, 1.0], 65 | "button_toggle_false": [0.38, 0.38, 0.38, 1.0], 66 | "button_menu_up": [0.14, 0.42, 0.35, 1.0], 67 | "button_menu_down": [0.15, 0.84, 0.67, 1.0], 68 | "button_disabled": [0.28, 0.28, 0.28, 1.0], 69 | "button_disabled_text": [1.0, 1.0, 1.0, 0.5], 70 | "header_background": [0.739, 0.739, 0.8, 1.0], 71 | "header_text": [1.0, 1.0, 1.0, 1.0], 72 | "info_text": [0.0, 0.0, 0.0, 1.0], 73 | "info_background": [1.0, 1.0, 0.0, 0.75], 74 | "input_background": [0.18, 0.18, 0.27, 1.0], 75 | "scroller": [0.7, 0.7, 0.7, 0.4], 76 | "scroller_selected": [0.7, 0.7, 0.7, 0.9], 77 | "slider_grabber": [0.5098, 0.8745, 0.6588, 1.0], 78 | "slider_background": [0.546, 0.59, 0.616, 1.0], 79 | "main_background": [0.5, 0.5, 0.634, 0.292], 80 | "menu_background": [0.26, 0.29, 0.31, 1.0], 81 | "text": [1.0, 1.0, 1.0, 0.9], 82 | "disabled_text": [1.0, 1.0, 1.0, 0.5], 83 | "selected": [0.5098, 0.8745, 0.6588, 0.5], 84 | "active": [1.0, 0.239, 0.344, 0.5], 85 | "background": [0.0, 0.0, 0.0, 1.0], 86 | "selected_overlay": [.8, 1, .8, .33], 87 | } 88 | ] 89 | 90 | 91 | class Theme(Widget): 92 | """Theme class that stores all the colors used in the interface.""" 93 | 94 | button_down = ListProperty() 95 | button_up = ListProperty() 96 | button_text = ListProperty() 97 | button_warn_down = ListProperty() 98 | button_warn_up = ListProperty() 99 | button_toggle_true = ListProperty() 100 | button_toggle_false = ListProperty() 101 | button_menu_up = ListProperty() 102 | button_menu_down = ListProperty() 103 | button_disabled = ListProperty() 104 | button_disabled_text = ListProperty() 105 | 106 | header_background = ListProperty() 107 | header_text = ListProperty() 108 | info_text = ListProperty() 109 | info_background = ListProperty() 110 | 111 | input_background = ListProperty() 112 | scroller = ListProperty() 113 | scroller_selected = ListProperty() 114 | slider_grabber = ListProperty() 115 | slider_background = ListProperty() 116 | 117 | main_background = ListProperty() 118 | menu_background = ListProperty() 119 | text = ListProperty() 120 | disabled_text = ListProperty() 121 | selected = ListProperty() 122 | active = ListProperty() 123 | background = ListProperty() 124 | selected_overlay = ListProperty() 125 | 126 | def data_to_theme(self, data): 127 | """Converts a theme dictionary into the theme object that is used for displaying colors""" 128 | 129 | for color in data: 130 | if hasattr(self, color): 131 | new_color = data[color] 132 | r = float(new_color[0]) 133 | g = float(new_color[1]) 134 | b = float(new_color[2]) 135 | a = float(new_color[3]) 136 | new_color = [r, g, b, a] 137 | setattr(self, color, new_color) 138 | 139 | 140 | class SimpleTheme(Theme): 141 | button_down = ListProperty() 142 | button_up = ListProperty() 143 | text = ListProperty() 144 | selected = ListProperty() 145 | active = ListProperty() 146 | background = ListProperty() 147 | 148 | def on_background(self, *_): 149 | self.header_background = self.background[:3]+[0.4] 150 | self.info_background = self.background[:3]+[0.7] 151 | self.main_background = self.background[:3]+[0.3] 152 | self.menu_background = self.background 153 | self.slider_background = self.background 154 | 155 | def on_text(self, *_): 156 | self.button_text = self.text 157 | self.info_text = self.text 158 | self.header_text = self.text 159 | self.button_disabled_text = self.text[:3]+[0.5] 160 | self.disabled_text = self.text[:3]+[0.5] 161 | 162 | def on_button_up(self, *_): 163 | self.input_background = self.button_up 164 | self.button_menu_up = self.button_up 165 | self.button_toggle_false = self.button_up 166 | self.slider_grabber = self.button_up 167 | 168 | def on_button_down(self, *_): 169 | self.button_disabled = self.button_down 170 | self.button_menu_down = self.button_down 171 | self.scroller = self.button_down[:3]+[0.4] 172 | self.button_warn_down = self.button_down 173 | 174 | def on_selected(self, *_): 175 | self.selected = self.selected[:3]+[0.7] 176 | self.scroller_selected = self.selected[:3]+[0.9] 177 | self.button_toggle_true = self.selected[:3]+[1] 178 | self.selected_overlay = self.selected[:3]+[0.33] 179 | 180 | def on_active(self, *_): 181 | self.active = self.active[:3]+[0.5] 182 | self.button_warn_up = self.active[:3]+[1] 183 | 184 | 185 | class NormalApp(App): 186 | theme_index = NumericProperty(0) #Override this to create an app with a different theme index 187 | popup_x = NumericProperty(640) #Override this to set the default width of popups 188 | popup_size_hint_x = NumericProperty(None, allownone=True) 189 | about_title = "About This App" #Title that will appear in the about popup 190 | about_text = 'About' #Override this to change the text that appears in the the about popup in the settings screen 191 | animations = BooleanProperty(True) #Set this to disable animations in the app 192 | animation_length = NumericProperty(0.2) #Set this to change the length in seconds that animations will take 193 | scaling_mode = OptionProperty("divisions", options=["divisions", "pixels"]) 194 | scale_amount = NumericProperty(15) 195 | 196 | list_background_odd = ListProperty([0, 0, 0, 0]) 197 | list_background_even = ListProperty([0, 0, 0, .1]) 198 | button_scale = NumericProperty(100) 199 | scrollbar_scale = NumericProperty(50) 200 | text_scale = NumericProperty(100) 201 | display_padding = NumericProperty(8) 202 | display_border = NumericProperty(16) 203 | settings_cls = AppSettings 204 | 205 | window_top = None 206 | window_left = None 207 | window_width = None 208 | window_height = None 209 | window_maximized = BooleanProperty(False) 210 | 211 | bubble = ObjectProperty(allownone=True) 212 | 213 | clickfade_object = ObjectProperty() 214 | infotext = StringProperty('') 215 | infotext_setter = ObjectProperty() 216 | popup = ObjectProperty(allownone=True) 217 | theme = ObjectProperty() 218 | button_update = BooleanProperty(False) 219 | 220 | navigation_enabled = BooleanProperty(True) 221 | selected_object = ObjectProperty(allownone=True) 222 | last_joystick_axis = NumericProperty(0) 223 | joystick_deadzone = NumericProperty(.25) 224 | navigation_next = [274, 12, -12] 225 | navigation_prev = [273, 11, -11] 226 | navigation_activate = [13, 0, 21] 227 | navigation_left = [276, 13, -13] 228 | navigation_right = [275, 14, -14] 229 | navigation_jump = [9] 230 | 231 | def __init__(self, **kwargs): 232 | super().__init__(**kwargs) 233 | self.theme = Theme() 234 | self.load_theme(self.theme_index) 235 | Window.bind(on_resize=self.window_on_size) 236 | Window.bind(on_draw=self.window_on_draw) 237 | Window.bind(on_maximize=self.set_maximized) 238 | Window.bind(on_restore=self.unset_maximized) 239 | 240 | def on_popup(self, *_): 241 | self.selected_overlay_set(None) 242 | 243 | def start_keyboard_navigation(self): 244 | Window.bind(on_key_down=self.nav_key_down) 245 | Window.bind(on_key_up=self.nav_key_up) 246 | 247 | def start_joystick_navigation(self): 248 | Window.bind(on_joy_button_down=self.nav_joy_down) 249 | Window.bind(on_joy_button_up=self.nav_joy_up) 250 | Window.bind(on_joy_axis=self.nav_joy_axis) 251 | Window.bind(on_joy_hat=self.nav_joy_hat) 252 | 253 | def nav_joy_down(self, window, padindex, button): 254 | self.nav_key_down(window, scancode=(0 - button)) 255 | 256 | def nav_joy_up(self, window, padindex, button): 257 | self.nav_key_up(window, scancode=(0 - button)) 258 | 259 | def nav_joy_axis(self, window, stickid, axisid, axis): 260 | axis = axis / 32768 261 | if axis == 0: 262 | self.last_joystick_axis = 0 263 | current_time = time.time() 264 | if current_time - self.last_joystick_axis > 1: 265 | if axisid == 0: 266 | if axis < (0 - self.joystick_deadzone): 267 | self.nav_key_down(window, scancode=276) 268 | self.last_joystick_axis = current_time 269 | elif axis > self.joystick_deadzone: 270 | self.nav_key_down(window, scancode=275) 271 | self.last_joystick_axis = current_time 272 | else: 273 | self.nav_key_up(window, scancode=276) 274 | self.nav_key_up(window, scancode=275) 275 | elif axisid == 1: 276 | if axis < (0 - self.joystick_deadzone): 277 | self.nav_key_down(window, scancode=273) 278 | self.last_joystick_axis = current_time 279 | elif axis > self.joystick_deadzone: 280 | self.nav_key_down(window, scancode=274) 281 | self.last_joystick_axis = current_time 282 | else: 283 | self.nav_key_up(window, scancode=273) 284 | self.nav_key_up(window, scancode=274) 285 | 286 | def nav_joy_hat(self, window, stickid, axisid, axis): 287 | axis_x, axis_y = axis 288 | 289 | if axis_x < 0: 290 | self.nav_key_down(window, scancode=276) 291 | elif axis_x > 0: 292 | self.nav_key_down(window, scancode=275) 293 | elif axis_x == 0: 294 | self.nav_key_up(window, scancode=276) 295 | self.nav_key_up(window, scancode=275) 296 | if axis_y > 0: 297 | self.nav_key_down(window, scancode=273) 298 | elif axis_y < 0: 299 | self.nav_key_down(window, scancode=274) 300 | elif axis_x == 0: 301 | self.nav_key_up(window, scancode=273) 302 | self.nav_key_up(window, scancode=274) 303 | 304 | def nav_key_down(self, window, scancode=None, *_): 305 | """Detects navigation-based key presses""" 306 | 307 | if not self.navigation_enabled: 308 | return False 309 | if scancode in self.navigation_activate: 310 | self.selected_activate() 311 | return True 312 | elif scancode in self.navigation_next: 313 | self.selected_next() 314 | return True 315 | elif scancode in self.navigation_prev: 316 | self.selected_prev() 317 | return True 318 | elif scancode in self.navigation_left: 319 | self.selected_left() 320 | return True 321 | elif scancode in self.navigation_right: 322 | self.selected_right() 323 | return True 324 | elif scancode in self.navigation_jump: 325 | self.selected_skip() 326 | return True 327 | 328 | def nav_key_up(self, window, scancode=None, *_): 329 | pass 330 | 331 | def selected_activate(self): 332 | #Attempts to activate the current selected_object. 333 | if self.selected_object: 334 | self.selected_object.on_navigation_activate() 335 | 336 | def selected_next(self, lookin=None): 337 | #Convenience function for selecting the next widget in the tree 338 | if self.selected_object: 339 | if self.selected_object.on_navigation_next(): 340 | return 341 | self.selected_item(lookin, True) 342 | 343 | def selected_prev(self, lookin=None): 344 | #Convenience function for selecting the previous widget in the tree 345 | if self.selected_object: 346 | if self.selected_object.on_navigation_prev(): 347 | return 348 | self.selected_item(lookin, False) 349 | 350 | def selected_left(self): 351 | if self.selected_object: 352 | self.selected_object.on_navigation_decrease() 353 | 354 | def selected_right(self): 355 | if self.selected_object: 356 | self.selected_object.on_navigation_increase() 357 | 358 | def selected_skip(self, lookin=None): 359 | self.selected_item(lookin, True, skip=True) 360 | 361 | def selected_can_select(self, widget): 362 | if isinstance(widget, Navigation) and widget.navigation_selectable: 363 | return True 364 | return False 365 | 366 | def selected_find_active(self, root_widget, forward, found, skip=False): 367 | if root_widget == self.selected_object and not found: 368 | #The root widget is the current active! If True is returned when recursively searching, the next recursion level up will try to find the next available widget in the tree 369 | return True 370 | 371 | is_recycle = False 372 | recycle_layout = None 373 | if isinstance(root_widget, DropDown): 374 | pass 375 | 376 | if isinstance(root_widget, RecycleView): 377 | #Recycleview, need to do special stuff to account for some children not currently existing 378 | is_recycle = True 379 | recycle_layout = root_widget.children[0] 380 | children = sorted(recycle_layout.children, key=lambda x: (-x.x, x.y)) 381 | elif isinstance(root_widget, ScreenManager): 382 | #Screen manager widget, we only want to iterate through the current displayed screen 383 | children = [root_widget.current_screen] 384 | else: 385 | #Other types of layouts, just iterate through the child list 386 | children = list(root_widget.children) 387 | if forward: 388 | children.reverse() 389 | for index, child in enumerate(children): 390 | is_selectable = self.selected_can_select(child) 391 | if is_recycle and is_selectable and found: 392 | self.selected_scroll_to_item(root_widget) 393 | if found and not child.disabled and is_selectable: 394 | #last widget in the tree was the old active, now this child becomes the current active 395 | return child 396 | found_active = self.selected_find_active(child, forward, found, skip=skip) 397 | if found_active is True: 398 | #This child or one of its children is selected, need to find the next possible 399 | found = True 400 | if is_recycle and is_selectable: 401 | #self.selected_scroll_to_item(root_widget) 402 | if skip: 403 | #Skip out of this recycleview and go to the next selected 404 | return True 405 | if index + 1 >= len(children): 406 | #This recycleview has no more children to switch to, it could be on the last child, or it may need to be scrolled 407 | scrollable_x = recycle_layout.width - root_widget.width #how many pixels of scrolling is available in the x direction 408 | scrollable_y = recycle_layout.height - root_widget.height 409 | if root_widget.do_scroll_x and scrollable_x > 0: #root can scroll in horizontal 410 | if forward and root_widget.scroll_x > 0: #root can and should scroll forward 411 | root_widget.scroll_x = max(root_widget.scroll_x - (self.selected_object.width / scrollable_x), 0) 412 | return self.selected_object 413 | elif not forward and root_widget.scroll_x < 1: #root can and should scroll backward 414 | root_widget.scroll_x = min(root_widget.scroll_x + (self.selected_object.width / scrollable_x), 1) 415 | return self.selected_object 416 | elif root_widget.do_scroll_y and scrollable_y > 0: #root can scroll in vertical 417 | if forward and root_widget.scroll_y > 0: #root can and should scroll forward 418 | root_widget.scroll_y = max(root_widget.scroll_y - (self.selected_object.height / scrollable_y), 0) 419 | return self.selected_object 420 | elif not forward and root_widget.scroll_y < 1: #root can and should scroll backward 421 | root_widget.scroll_y = min(root_widget.scroll_y + (self.selected_object.height / scrollable_y), 1) 422 | return self.selected_object 423 | 424 | elif found_active is None: 425 | #active not found in this child or its children, move on to the next child 426 | continue 427 | else: 428 | #the next active was found, return it to go up one recursion level 429 | return found_active 430 | 431 | if found: 432 | #Tried to find the next active but couldnt, continue search in the next tree up 433 | return True 434 | return None #The active was not found in this tree at all 435 | 436 | def selected_get_root(self): 437 | #determine the best root widget to look for items to navigate 438 | 439 | root_window = self.root.get_parent_window() 440 | if len(root_window.children) == 1: 441 | return root_window.children[0] 442 | for item in reversed(root_window.children): 443 | if isinstance(item, DropDown): 444 | return item 445 | for item in reversed(root_window.children): 446 | if isinstance(item, ModalView): 447 | return item 448 | for item in reversed(root_window.children): 449 | if isinstance(item, Settings): 450 | return item 451 | return root_window 452 | 453 | def selected_item(self, lookin, forward, skip=None): 454 | if lookin is None: 455 | lookin = self.selected_get_root() 456 | active = self.selected_find_active(lookin, forward, False, skip=skip) 457 | if active is True or active is None: 458 | active = self.selected_find_active(lookin, forward, True, skip=skip) 459 | self.selected_overlay_set(active) 460 | 461 | def selected_clear(self): 462 | self.selected_overlay_set(None) 463 | 464 | def selected_overlay_set(self, widget): 465 | #This function will actually set the given widget as the current selected, also ensures it is scrolled to if in a Scroller 466 | 467 | if self.selected_object is not None: 468 | self.selected_object.on_navigation_deselect() 469 | self.selected_object = widget 470 | if widget is None: 471 | return 472 | self.selected_object.on_navigation_select() 473 | self.selected_scroll_to_item(widget) 474 | 475 | def selected_scroll_to_item(self, widget): 476 | parent = widget.parent 477 | while parent is not None: 478 | if parent.parent == parent: 479 | break 480 | if hasattr(parent, 'scroll_y'): 481 | try: 482 | if parent.children[0].height < parent.height: 483 | parent.scroll_y = 1 484 | else: 485 | parent.scroll_to(widget, animate=False, padding=20) 486 | except: 487 | pass 488 | break 489 | parent = parent.parent 490 | 491 | def clickfade(self, widget, mode='opacity'): 492 | try: 493 | Window.remove_widget(self.clickfade_object) 494 | except: 495 | pass 496 | if self.clickfade_object is None: 497 | self.clickfade_object = ClickFade() 498 | self.clickfade_object.size = widget.size 499 | self.clickfade_object.pos = widget.to_window(*widget.pos) 500 | self.clickfade_object.begin(mode) 501 | Window.add_widget(self.clickfade_object) 502 | 503 | def load_theme(self, theme): 504 | """Load and display a theme from the current presets""" 505 | 506 | try: 507 | data = themes[theme] 508 | except: 509 | data = theme 510 | self.theme.data_to_theme(data) 511 | self.button_update = not self.button_update 512 | 513 | def set_maximized(self, *_): 514 | self.window_maximized = True 515 | 516 | def unset_maximized(self, *_): 517 | self.window_maximized = False 518 | 519 | def window_init_position(self, *_): 520 | self.window_top = int(self.config.get('Settings', 'window_top')) 521 | self.window_left = int(self.config.get('Settings', 'window_left')) 522 | Window.left = self.window_left 523 | Window.top = self.window_top 524 | 525 | @mainthread 526 | def window_on_size(self, *_): 527 | #called when Window.on_resize happens 528 | 529 | if self.window_height is None: 530 | #app just started, window is uninitialized, load in stored size if enabled 531 | if self.config.getboolean("Settings", "remember_window") and desktop: 532 | self.window_maximized = self.config.getboolean('Settings', 'window_maximized') 533 | self.window_width = int(self.config.get('Settings', 'window_width')) 534 | self.window_height = int(self.config.get('Settings', 'window_height')) 535 | Window.size = (self.window_width, self.window_height) 536 | if self.window_maximized: 537 | Window.maximize() 538 | else: 539 | Clock.schedule_once(self.window_init_position) #Need to delay this to ensure window has time to resize first 540 | else: 541 | self.window_width = Window.width 542 | self.window_height = Window.height 543 | self.rescale_interface() 544 | else: 545 | #Window is resized 546 | self.config.set("Settings", "window_maximized", 1 if self.window_maximized else 0) 547 | self.check_window_width() 548 | self.check_window_height() 549 | 550 | def check_window_width(self, *_): 551 | self.popup_x = min(Window.width, 640) 552 | if Window.width != self.window_width and self.window_width is not None: 553 | if not self.window_maximized: 554 | self.window_width = Window.width 555 | self.config.set('Settings', 'window_width', self.window_width) 556 | 557 | def check_window_height(self, *_): 558 | if Window.height != self.window_height and self.window_height is not None: 559 | if not self.window_maximized: 560 | self.window_height = Window.height 561 | self.config.set('Settings', 'window_height', self.window_height) 562 | self.rescale_interface() 563 | 564 | def check_window_top(self, *_): 565 | if Window.top != self.window_top and self.window_top is not None: 566 | if not self.window_maximized: 567 | self.window_top = Window.top 568 | self.config.set('Settings', 'window_top', self.window_top) 569 | 570 | def check_window_left(self, *_): 571 | if Window.left != self.window_left and self.window_left is not None: 572 | if not self.window_maximized: 573 | self.window_left = Window.left 574 | self.config.set('Settings', 'window_left', self.window_left) 575 | 576 | @mainthread 577 | def window_on_draw(self, *_): 578 | if self.window_height is None: 579 | #trigger this just in case window hasnt triggered the on resize event 580 | self.window_on_size() 581 | self.check_window_left() 582 | self.check_window_top() 583 | 584 | def rescale_interface(self, *_): 585 | """Updates variables dependent on screen height""" 586 | if self.scaling_mode == 'divisions': 587 | self.button_scale = int((Window.height / self.scale_amount) * int(self.config.get("Settings", "buttonsize")) / 100) 588 | elif self.scaling_mode == 'pixels': 589 | self.button_scale = int(self.scale_amount * (int(self.config.get("Settings", "buttonsize")) / 100)) 590 | self.text_scale = int((self.button_scale / 3) * int(self.config.get("Settings", "textsize")) / 100) 591 | self.scrollbar_scale = int(((self.button_scale / 2) * (int(self.config.get("Settings", "scrollersize")) / 100))) 592 | self.display_border = self.button_scale / 3 593 | self.display_padding = self.button_scale / 4 594 | 595 | def popup_bubble(self, text_input, pos): 596 | """Calls the text input right-click popup menu""" 597 | 598 | self.close_bubble() 599 | text_input.unfocus_on_touch = False 600 | self.bubble = InputMenu(owner=text_input) 601 | window = self.root_window 602 | window.add_widget(self.bubble) 603 | posx = pos[0] 604 | posy = pos[1] 605 | #check position to ensure its not off screen 606 | if posx + self.bubble.width > window.width: 607 | posx = window.width - self.bubble.width 608 | if posy + self.bubble.height > window.height: 609 | posy = window.height - self.bubble.height 610 | self.bubble.pos = [posx, posy] 611 | 612 | def close_bubble(self, *_): 613 | """Closes the text input right-click popup menu""" 614 | 615 | if self.bubble: 616 | self.bubble.owner.unfocus_on_touch = True 617 | window = self.root_window 618 | window.remove_widget(self.bubble) 619 | self.bubble = None 620 | 621 | def message(self, text, timeout=20): 622 | """Sets the app.infotext variable to a specific message, and clears it after a set amount of time.""" 623 | 624 | self.infotext = text 625 | if self.infotext_setter: 626 | self.infotext_setter.cancel() 627 | self.infotext_setter = Clock.schedule_once(self.clear_message, timeout) 628 | 629 | def clear_message(self, *_): 630 | """Clear the app.infotext variable""" 631 | 632 | self.infotext = '' 633 | 634 | def about(self): 635 | """Opens a special message popup with the app's about text in it""" 636 | 637 | if self.popup: 638 | self.popup.dismiss() 639 | self.popup = AboutPopup(size_hint=(self.popup_size_hint_x, None), width=self.popup_x) 640 | self.popup.open() 641 | #self.message_popup(text, title=title) 642 | 643 | def message_popup(self, text, title='Notification'): 644 | """Opens a basic message popup with an ok button""" 645 | 646 | if self.popup: 647 | self.popup.dismiss() 648 | content = MessagePopupContent(text=text) 649 | self.popup = NormalPopup(title=title, content=content, size_hint=(self.popup_size_hint_x, None), size=(self.popup_x, self.button_scale * 4)) 650 | self.popup.open() 651 | 652 | def build_config(self, config): 653 | """Setup config file if it is not found""" 654 | 655 | config.setdefaults( 656 | 'Settings', { 657 | 'remember_window': 1, 658 | 'buttonsize': 100, 659 | 'textsize': 100, 660 | 'scrollersize': 100, 661 | 'window_maximized': 0, 662 | 'window_top': 50, 663 | 'window_left': 100, 664 | 'window_width': 800, 665 | 'window_height': 600, 666 | }) 667 | 668 | def build_settings(self, settings): 669 | """Kivy settings dialog panel 670 | settings types: title, bool, numeric, options, string, path""" 671 | 672 | settingspanel = [] 673 | settingspanel.append({ 674 | "type": "aboutbutton", 675 | "title": "", 676 | "section": "Settings", 677 | "key": "buttonsize" 678 | }) 679 | settingspanel.append({ 680 | "type": "title", 681 | "title": "General Settings" 682 | }) 683 | settingspanel.append({ 684 | "type": "numeric", 685 | "title": "Button Scale", 686 | "desc": "Scale percentage for interface elements", 687 | "section": "Settings", 688 | "key": "buttonsize" 689 | }) 690 | settingspanel.append({ 691 | "type": "numeric", 692 | "title": "Text Scale", 693 | "desc": "Scale percentage for text in the interface", 694 | "section": "Settings", 695 | "key": "textsize" 696 | }) 697 | settingspanel.append({ 698 | "type": "numeric", 699 | "title": "Scrollbar Scale", 700 | "desc": "Scale percentage for scrollbars, 100% is half the button size", 701 | "section": "Settings", 702 | "key": "scrollersize" 703 | }) 704 | if desktop: 705 | settingspanel.append({ 706 | "type": "bool", 707 | "title": "Remember Window", 708 | "desc": "Recall the last used window size and position on startup", 709 | "section": "Settings", 710 | "key": "remember_window" 711 | }) 712 | settings.add_json_panel('Settings', self.config, data=json.dumps(settingspanel)) 713 | 714 | def on_config_change(self, config, section, key, value): 715 | """Called when the configuration file is changed""" 716 | 717 | self.rescale_interface() 718 | 719 | def get_crashlog_file(self): 720 | """Returns the crashlog file path and name""" 721 | 722 | savefolder_loc = os.path.split(self.get_application_config())[0] 723 | crashlog = os.path.join(savefolder_loc, 'testapp_crashlog.txt') 724 | return crashlog 725 | 726 | def save_crashlog(self): 727 | """Saves the just-generated crashlog to the current default location""" 728 | 729 | import traceback 730 | crashlog = self.get_crashlog_file() 731 | log_history = reversed(LoggerHistory.history) 732 | crashlog_file = open(crashlog, 'w') 733 | for log_line in log_history: 734 | log_line = log_line.msg 735 | crashlog_file.write(log_line+'\n') 736 | traceback_text = traceback.format_exc() 737 | print(traceback_text) 738 | crashlog_file.write(traceback_text) 739 | crashlog_file.close() 740 | -------------------------------------------------------------------------------- /snu/button.py: -------------------------------------------------------------------------------- 1 | from .navigation import Navigation 2 | from kivy.app import App 3 | from kivy.properties import ListProperty, ObjectProperty, BooleanProperty, NumericProperty 4 | from kivy.clock import Clock 5 | from kivy.animation import Animation 6 | from kivy.uix.button import Button 7 | from kivy.uix.dropdown import DropDown 8 | from kivy.uix.togglebutton import ToggleButton 9 | from kivy.uix.modalview import ModalView 10 | from kivy.lang.builder import Builder 11 | Builder.load_string(""" 12 | <-Button,-ToggleButton>: 13 | state_image: self.background_normal if self.state == 'normal' else self.background_down 14 | disabled_image: self.background_disabled_normal if self.state == 'normal' else self.background_disabled_down 15 | canvas: 16 | Color: 17 | rgba: self.background_color 18 | BorderImage: 19 | display_border: [app.display_border, app.display_border, app.display_border, app.display_border] 20 | border: self.border 21 | pos: self.pos 22 | size: self.size 23 | source: self.disabled_image if self.disabled else self.state_image 24 | Color: 25 | rgba: 1, 1, 1, 1 26 | Rectangle: 27 | texture: self.texture 28 | size: self.texture_size 29 | pos: int(self.center_x - self.texture_size[0] / 2.), int(self.center_y - self.texture_size[1] / 2.) 30 | 31 | : 32 | canvas: 33 | Color: 34 | rgba: app.theme.selected 35 | Rectangle: 36 | size: self.size 37 | pos: root.pos 38 | background: 'data/transparent.png' 39 | size_hint: None, None 40 | opacity: 0 41 | 42 | : 43 | mipmap: True 44 | font_size: app.text_scale 45 | size_hint_y: None 46 | height: app.button_scale 47 | background_normal: 'data/button.png' 48 | background_down: 'data/button.png' 49 | background_disabled_down: 'data/button.png' 50 | background_disabled_normal: 'data/button.png' 51 | button_update: app.button_update 52 | 53 | : 54 | width: self.texture_size[0] + app.button_scale 55 | size_hint_x: None 56 | font_size: app.text_scale 57 | 58 | : 59 | text_size: self.size 60 | halign: 'center' 61 | valign: 'middle' 62 | 63 | : 64 | menu: True 65 | size_hint_x: 1 66 | 67 | : 68 | canvas.after: 69 | Color: 70 | rgba: self.color 71 | Rectangle: 72 | pos: (root.pos[0]+root.width-(root.height/1.5)), root.pos[1] 73 | size: root.height/2, root.height 74 | source: 'data/menuarrows.png' 75 | menu: True 76 | size_hint_x: None 77 | width: self.texture_size[0] + (app.button_scale * 1.5) 78 | 79 | : 80 | canvas.after: 81 | Color: 82 | rgba: self.color 83 | Rectangle: 84 | pos: (root.pos[0]+root.width-(root.height/1.5)), root.pos[1] 85 | size: root.height/2, root.height 86 | source: 'data/menuarrows.png' 87 | menu: True 88 | text_size: self.size 89 | halign: 'center' 90 | valign: 'middle' 91 | size_hint_x: 1 92 | 93 | : 94 | toggle: True 95 | size_hint_x: None 96 | width: self.texture_size[0] + app.button_scale 97 | 98 | : 99 | toggle: True 100 | 101 | : 102 | canvas: 103 | Color: 104 | rgba: self.background_color 105 | BorderImage: 106 | border: self.border 107 | pos: self.pos 108 | size: self.size 109 | source: 'data/settings.png' 110 | text: '' 111 | border: (0, 0, 0, 0) 112 | size_hint_x: None 113 | width: self.height 114 | background_normal: 'data/transparent.png' 115 | background_down: self.background_normal 116 | on_release: app.open_settings() 117 | 118 | : 119 | canvas.before: 120 | Color: 121 | rgba: app.theme.menu_background 122 | BorderImage: 123 | display_border: [app.display_border, app.display_border, app.display_border, app.display_border] 124 | size: root.width, root.height * root.show_percent 125 | pos: root.pos[0], root.pos[1] + (root.height * (1 - root.show_percent)) if root.invert else root.pos[1] 126 | source: 'data/buttonflat.png' 127 | """) 128 | 129 | 130 | class ClickFade(ModalView): 131 | animation = None 132 | 133 | def begin(self, mode='opacity'): 134 | app = App.get_running_app() 135 | self.opacity = 0 136 | 137 | if app.animations: 138 | if self.animation: 139 | self.animation.cancel(self) 140 | if mode == 'height': 141 | self.animation = Animation(opacity=1, duration=(app.animation_length / 4)) + Animation(height=0, pos=(self.pos[0], self.pos[1]+self.height), duration=(app.animation_length / 2)) 142 | else: 143 | self.animation = Animation(opacity=1, duration=(app.animation_length / 4)) + Animation(opacity=0, duration=(app.animation_length / 2)) 144 | self.animation.start(self) 145 | self.animation.bind(on_complete=self.finish_animation) 146 | else: 147 | self.finish_animation() 148 | 149 | def finish_animation(self, *_): 150 | self.animation = None 151 | try: 152 | self.parent.remove_widget(self) 153 | except: 154 | pass 155 | 156 | 157 | class ButtonBase(Button, Navigation): 158 | """Button widget that includes theme options and a variety of small additions over a basic button.""" 159 | 160 | warn = BooleanProperty(False) 161 | target_background = ListProperty() 162 | target_text = ListProperty() 163 | background_animation = ObjectProperty() 164 | text_animation = ObjectProperty() 165 | last_disabled = False 166 | menu = BooleanProperty(False) 167 | toggle = BooleanProperty(False) 168 | 169 | button_update = BooleanProperty() 170 | 171 | def __init__(self, **kwargs): 172 | self.background_animation = Animation() 173 | self.text_animation = Animation() 174 | app = App.get_running_app() 175 | self.background_color = app.theme.button_up 176 | self.target_background = self.background_color 177 | self.color = app.theme.button_text 178 | self.target_text = self.color 179 | super(ButtonBase, self).__init__(**kwargs) 180 | 181 | def on_button_update(self, *_): 182 | Clock.schedule_once(lambda x: self.set_color()) 183 | 184 | def set_color(self, instant=False): 185 | app = App.get_running_app() 186 | if self.disabled: 187 | self.set_text(app.theme.button_disabled_text, instant=instant) 188 | self.set_background(app.theme.button_disabled, instant=instant) 189 | else: 190 | self.set_text(app.theme.button_text, instant=instant) 191 | if self.menu: 192 | if self.state == 'down': 193 | self.set_background(app.theme.button_menu_down, instant=True) 194 | else: 195 | self.set_background(app.theme.button_menu_up, instant=instant) 196 | elif self.toggle: 197 | if self.state == 'down': 198 | self.set_background(app.theme.button_toggle_true, instant=instant) 199 | else: 200 | self.set_background(app.theme.button_toggle_false, instant=instant) 201 | 202 | elif self.warn: 203 | if self.state == 'down': 204 | self.set_background(app.theme.button_warn_down, instant=True) 205 | else: 206 | self.set_background(app.theme.button_warn_up, instant=instant) 207 | else: 208 | if self.state == 'down': 209 | self.set_background(app.theme.button_down, instant=True) 210 | else: 211 | self.set_background(app.theme.button_up, instant=instant) 212 | 213 | def on_disabled(self, *_): 214 | self.set_color() 215 | 216 | def on_menu(self, *_): 217 | self.set_color(instant=True) 218 | 219 | def on_toggle(self, *_): 220 | self.set_color(instant=True) 221 | 222 | def on_warn(self, *_): 223 | self.set_color(instant=True) 224 | 225 | def on_state(self, *_): 226 | self.set_color() 227 | 228 | def set_background(self, color, instant=False): 229 | if self.target_background == color: 230 | return 231 | app = App.get_running_app() 232 | self.background_animation.stop(self) 233 | if app.animations and not instant: 234 | self.background_animation = Animation(background_color=color, duration=app.animation_length) 235 | self.background_animation.start(self) 236 | else: 237 | self.background_color = color 238 | self.target_background = color 239 | 240 | def set_text(self, color, instant=False): 241 | if self.target_text == color: 242 | return 243 | app = App.get_running_app() 244 | self.text_animation.stop(self) 245 | if app.animations and not instant: 246 | self.text_animation = Animation(color=color, duration=app.animation_length) 247 | self.text_animation.start(self) 248 | else: 249 | self.color = color 250 | self.target_text = color 251 | 252 | 253 | class ToggleBase(ToggleButton, ButtonBase): 254 | """Basic toggle button widget""" 255 | pass 256 | 257 | 258 | class NormalToggle(ToggleBase): 259 | pass 260 | 261 | 262 | class WideToggle(ToggleBase): 263 | pass 264 | 265 | 266 | class NormalButton(ButtonBase): 267 | """Basic button widget.""" 268 | pass 269 | 270 | 271 | class WideButton(ButtonBase): 272 | """Full width button widget.""" 273 | pass 274 | 275 | 276 | class MenuButton(ButtonBase): 277 | """Basic class for a drop-down menu button item.""" 278 | pass 279 | 280 | 281 | class NormalMenuStarter(ButtonBase): 282 | pass 283 | 284 | 285 | class WideMenuStarter(ButtonBase): 286 | pass 287 | 288 | 289 | class NormalDropDown(DropDown): 290 | """Dropdown menu class with some nice animations.""" 291 | 292 | show_percent = NumericProperty(1) 293 | invert = BooleanProperty(False) 294 | basic_animation = BooleanProperty(False) 295 | 296 | def open(self, *args, **kwargs): 297 | if self.parent: 298 | self.dismiss() 299 | return 300 | app = App.get_running_app() 301 | super(NormalDropDown, self).open(*args, **kwargs) 302 | 303 | self.container.do_layout() 304 | self._reposition() 305 | if app.animations: 306 | if self.basic_animation: 307 | #Dont do fancy child opacity animation 308 | self.opacity = 0 309 | self.show_percent = 1 310 | anim = Animation(opacity=1, duration=app.animation_length) 311 | anim.start(self) 312 | else: 313 | #determine if we opened up or down 314 | attach_to_window = self.attach_to.to_window(*self.attach_to.pos) 315 | if attach_to_window[1] > self.pos[1]: 316 | self.invert = True 317 | children = reversed(self.container.children) 318 | else: 319 | self.invert = False 320 | children = self.container.children 321 | 322 | #Animate background 323 | self.opacity = 1 324 | self.show_percent = 0 325 | anim = Animation(show_percent=1, duration=app.animation_length) 326 | anim.start(self) 327 | 328 | if len(self.container.children) > 0: 329 | item_delay = app.animation_length / len(self.container.children) 330 | else: 331 | item_delay = 0 332 | 333 | for i, w in enumerate(children): 334 | anim = (Animation(duration=i * item_delay) + Animation(opacity=1, duration=app.animation_length)) 335 | w.opacity = 0 336 | anim.start(w) 337 | else: 338 | self.opacity = 1 339 | 340 | def dismiss(self, *args, **kwargs): 341 | app = App.get_running_app() 342 | if app.animations: 343 | anim = Animation(opacity=0, duration=app.animation_length) 344 | anim.start(self) 345 | anim.bind(on_complete=self.finish_dismiss) 346 | else: 347 | self.finish_dismiss() 348 | 349 | def finish_dismiss(self, *_): 350 | super(NormalDropDown, self).dismiss() 351 | -------------------------------------------------------------------------------- /snu/filebrowser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import datetime 4 | import fnmatch 5 | import string 6 | from kivy.app import App 7 | from kivy.clock import Clock 8 | from kivy.utils import platform 9 | from kivy.uix.boxlayout import BoxLayout 10 | from kivy.properties import BooleanProperty, StringProperty, ListProperty, NumericProperty, ObjectProperty 11 | from kivy.uix.filechooser import FileSystemLocal 12 | from .popup import NormalPopup, InputPopupContent, ConfirmPopupContent 13 | from .layouts import Holder 14 | from .recycleview import NormalRecycleView, SelectableRecycleBoxLayout, RecycleItem 15 | from .button import NormalButton, WideButton 16 | from .textinput import NormalInput 17 | from .label import NormalLabel, LeftNormalLabel 18 | from .navigation import Navigation 19 | from kivy.lang.builder import Builder 20 | if platform == 'win': 21 | from ctypes import windll, create_unicode_buffer 22 | 23 | Builder.load_string(""" 24 | : 25 | canvas.before: 26 | Color: 27 | rgba: self.bgcolor 28 | Rectangle: 29 | pos: self.pos 30 | size: self.size 31 | canvas.after: 32 | Color: 33 | rgba: app.theme.selected if root.selected == self else (1, 1, 1, 0) 34 | Rectangle: 35 | size: self.size 36 | pos: self.pos 37 | height: app.button_scale 38 | Image: 39 | size_hint_x: None 40 | width: app.button_scale 41 | source: 'atlas://data/images/defaulttheme/filechooser_%s' % ('folder' if root.type == 'folder' else 'file') 42 | NormalLabel: 43 | size_hint_y: None 44 | height: app.button_scale 45 | text_size: (self.width - 20, None) 46 | text: root.text 47 | halign: 'left' 48 | valign: 'center' 49 | NormalLabel: 50 | size_hint_x: 0 if root.is_folder else 0.25 51 | text: root.file_size 52 | NormalLabel: 53 | size_hint_x: 0 if root.is_folder else 0.333 54 | text: root.modified 55 | 56 | : 57 | orientation: 'horizontal' if self.width > self.height else 'vertical' 58 | BoxLayout: 59 | orientation: 'vertical' 60 | Holder: 61 | NormalButton: 62 | text: 'Go Up' 63 | on_release: root.go_up() 64 | TickerLabel: 65 | text: root.folder 66 | NormalButton: 67 | text: 'New Folder...' 68 | disabled: not root.show_folder_edit 69 | opacity: 0 if self.disabled else 1 70 | width: 0 if self.disabled else self.texture_size[0] + app.button_scale 71 | on_release: root.new_folder() 72 | NormalButton: 73 | text: 'Delete Folder' 74 | disabled: not root.show_folder_edit or not root.can_delete_folder 75 | opacity: 1 if root.show_folder_edit else 0 76 | width: self.texture_size[0] + app.button_scale if root.show_folder_edit else 0 77 | on_release: root.delete_folder() 78 | NormalRecycleView: 79 | viewclass: 'FileBrowserItem' 80 | id: fileList 81 | data: root.file_list_data 82 | SelectableRecycleBoxLayout: 83 | id: files 84 | multiselect: root.multi_select 85 | NormalInput: 86 | hint_text: '' 87 | id: fileInputArea 88 | disabled: not root.show_filename or not root.edit_filename 89 | opacity: 1 if root.show_filename else 0 90 | height: app.button_scale if root.show_filename else 0 91 | text: ';'.join(root.selected) 92 | on_text: root.set_edit(self.text) 93 | Widget: 94 | size_hint: None, None 95 | size: app.button_scale * 0.1, app.button_scale * 0.5 96 | BoxLayout: 97 | size_hint_x: root.shortcuts_size if root.width > root.height else 1 98 | size_hint_y: 1 99 | orientation: 'vertical' 100 | NormalLabel: 101 | text: 'Locations:' 102 | NormalRecycleView: 103 | viewclass: 'FileBrowserItem' 104 | id: locationsList 105 | data: root.shortcuts_data 106 | SelectableRecycleBoxLayout: 107 | WideButton: 108 | text: root.cancel_text 109 | disabled: not root.show_cancel 110 | opacity: 0 if self.disabled else 1 111 | height: 0 if self.disabled else app.button_scale 112 | on_release: root.dispatch('on_cancel') 113 | WideButton: 114 | text: root.select_text 115 | disabled: not root.show_select or not (len(root.selected) > 0 or root.edited_selected) 116 | opacity: 1 if root.show_select else 0 117 | height: app.button_scale if root.show_select else 0 118 | on_release: root.dispatch('on_select') 119 | """) 120 | 121 | def format_size(size): 122 | """Formats a file size in bytes to human-readable format. 123 | Accepts a numerical value, returns a string. 124 | """ 125 | 126 | if size >= 1024: 127 | size = size/1024 128 | if size >= 1024: 129 | size = size/1024 130 | if size >= 1024: 131 | size = size/1024 132 | return str(round(size, 2))+' GB' 133 | else: 134 | return str(round(size, 2))+' MB' 135 | else: 136 | return str(round(size, 2))+' KB' 137 | else: 138 | return str(round(size, 2))+' Bytes' 139 | 140 | def get_drives(): 141 | drives = [] 142 | if platform == 'win': 143 | for path in ['Desktop', 'Documents', 'Pictures']: 144 | drives.append((os.path.expanduser(u'~') + os.path.sep + path + os.path.sep, path)) 145 | bitmask = windll.kernel32.GetLogicalDrives() 146 | for letter in string.ascii_uppercase: 147 | if bitmask & 1: 148 | name = create_unicode_buffer(64) 149 | # get name of the drive 150 | drive = letter + u':' 151 | windll.kernel32.GetVolumeInformationW(drive + os.path.sep, name, 64, None, None, None, None, 0) 152 | drive_name = drive 153 | if name.value: 154 | drive_name = drive_name + '(' + name.value + ')' 155 | drives.append((drive + os.path.sep, drive_name)) 156 | bitmask >>= 1 157 | elif platform == 'linux': 158 | drives.append((os.path.sep, os.path.sep)) 159 | drives.append((os.path.expanduser(u'~') + os.path.sep, 'Home')) 160 | drives.append((os.path.sep + u'mnt' + os.path.sep, os.path.sep + u'mnt')) 161 | places = (os.path.sep + u'mnt' + os.path.sep, os.path.sep + u'media') 162 | for place in places: 163 | if os.path.isdir(place): 164 | for directory in next(os.walk(place))[1]: 165 | drives.append((place + os.path.sep + directory + os.path.sep, directory)) 166 | elif platform == 'macosx' or platform == 'ios': 167 | drives.append((os.path.expanduser(u'~') + os.path.sep, 'Home')) 168 | vol = os.path.sep + u'Volume' 169 | if os.path.isdir(vol): 170 | for drive in next(os.walk(vol))[1]: 171 | drives.append((vol + os.path.sep + drive + os.path.sep, drive)) 172 | elif platform == 'android': 173 | paths = [ 174 | ('/', 'Root'), 175 | ('/storage', 'Mounted Storage') 176 | ] 177 | from android.storage import primary_external_storage_path 178 | primary_ext_storage = primary_external_storage_path() 179 | if primary_ext_storage: 180 | paths.append((primary_ext_storage, 'Primary Storage')) 181 | 182 | from android.storage import secondary_external_storage_path 183 | secondary_ext_storage = secondary_external_storage_path() 184 | if secondary_ext_storage: 185 | paths.append((secondary_ext_storage, 'Secondary Storage')) 186 | 187 | for path in paths: 188 | realpath = os.path.realpath(path[0]) + os.path.sep 189 | if os.path.exists(realpath): 190 | drives.append((realpath, path[1])) 191 | 192 | return drives 193 | 194 | def tryint(s): 195 | try: 196 | return int(s) 197 | except ValueError: 198 | return s 199 | 200 | def alphanum_key(s): 201 | return [tryint(c) for c in re.split('([0-9]+)', s.lower())] 202 | 203 | def sort_nicely(l): 204 | return sorted(l, key=alphanum_key) 205 | 206 | 207 | class FileBrowserItem(RecycleItem, BoxLayout, Navigation): 208 | text = StringProperty() 209 | fullpath = StringProperty() 210 | type = StringProperty() 211 | file = StringProperty() 212 | owner = ObjectProperty(allownone=True) 213 | is_folder = BooleanProperty() 214 | selected = BooleanProperty(False) 215 | multi_select = BooleanProperty(False) 216 | selectable = BooleanProperty(False) 217 | file_size = StringProperty() 218 | modified = StringProperty() 219 | 220 | def on_navigation_activate(self): 221 | if self.selectable: 222 | self.parent.click_node(self) 223 | self.owner.single_click(self) 224 | 225 | def on_selected(self, *_): 226 | if self.type == 'folder' and self.multi_select and self.selected: 227 | self.selected = False 228 | if self.type == 'shortcut' and self.selected: 229 | self.selected = False 230 | self.set_color() 231 | 232 | def on_touch_down(self, touch): 233 | if not self.selectable: 234 | return 235 | super().on_touch_down(touch) 236 | if self.collide_point(*touch.pos): 237 | if not self.multi_select and touch.is_double_tap: 238 | self.owner.double_click(self) 239 | else: 240 | self.owner.single_click(self) 241 | 242 | 243 | class FileBrowser(BoxLayout): 244 | __events__ = ('on_cancel', 'on_select') 245 | selected = ListProperty() #List of currently selected filenames and folders in the dialog 246 | folder = StringProperty('\\') #Current opened folder in the dialog 247 | filetypes_filter = ListProperty() #Display only files with the given file extensions 248 | edited_selected = StringProperty('') 249 | default_filename = StringProperty('') 250 | 251 | #Theme variables: 252 | cancel_text = StringProperty('Cancel') 253 | select_text = StringProperty('Select') 254 | shortcuts_size = NumericProperty(0.5) #Size hint of shortcuts area 255 | show_cancel = BooleanProperty(True) #Show the cancel button 256 | show_select = BooleanProperty(True) #Show the select button 257 | show_folder_edit = BooleanProperty(True) #Display the folder creation/delete buttons 258 | show_filename = BooleanProperty(True) #Shows the selected filename(s) in a text field 259 | 260 | #Behavior settings: 261 | file_select = BooleanProperty(True) #Allows the dialog to select a filename 262 | folder_select = BooleanProperty(False) #Allows the dialog to select a folder 263 | multi_select = BooleanProperty(False) #Select multiple files or folders 264 | show_files = BooleanProperty(True) #Display files in the browser 265 | show_hidden = BooleanProperty(True) #Display hidden files in the browser 266 | require_filename = BooleanProperty(True) #If true, the ok button cannot be clicked in file select mode if no filename is given. 267 | edit_filename = BooleanProperty(False) #Allows the user to edit the filename(s) that are selected 268 | autoselect_files = BooleanProperty(False) #Automatically selects all files when a folder is entered 269 | clear_filename = BooleanProperty(True) #Automatically clear the filename(s) when a folder is changed 270 | 271 | #Internal variables: 272 | popup = ObjectProperty(allownone=True) 273 | shortcuts_data = ListProperty() 274 | file_list_data = ListProperty() 275 | folder_files = ListProperty() 276 | can_delete_folder = BooleanProperty(False) 277 | root_path = StringProperty() 278 | 279 | def __init__(self, **kwargs): 280 | Clock.schedule_once(self.refresh_all) 281 | super().__init__(**kwargs) 282 | if self.folder_select: 283 | self.selected = [self.folder] 284 | 285 | def on_select(self): 286 | pass 287 | 288 | def on_cancel(self): 289 | pass 290 | 291 | def on_default_filename(self, *_): 292 | Clock.schedule_once(lambda x: self.update_text_input(self.default_filename)) 293 | 294 | def update_text_input(self, text): 295 | self.ids['fileInputArea'].text = text 296 | 297 | def set_edit(self, text): 298 | if self.edit_filename and not self.multi_select: 299 | if self.edited_selected != text: 300 | self.edited_selected = text 301 | 302 | def single_click(self, clickedon): 303 | app = App.get_running_app() 304 | app.clickfade(clickedon) 305 | if clickedon.type == 'shortcut': 306 | self.folder = clickedon.fullpath 307 | self.refresh_folder() 308 | if self.folder_select: 309 | self.selected = [clickedon.fullpath] 310 | elif self.clear_filename: 311 | self.selected = [] 312 | elif clickedon.type == 'folder': 313 | self.folder = clickedon.fullpath 314 | self.refresh_folder() 315 | if self.folder_select: 316 | self.selected = [clickedon.fullpath] 317 | elif self.clear_filename: 318 | self.selected = [] 319 | else: 320 | #clickedon.type == 'file' 321 | if self.file_select: 322 | Clock.schedule_once(self.update_selected_files) 323 | 324 | def double_click(self, clickedon): 325 | app = App.get_running_app() 326 | app.clickfade(clickedon) 327 | if clickedon.type == 'file' and self.file_select: 328 | self.update_selected_files() 329 | self.dispatch('on_select') 330 | 331 | def update_selected_files(self, *_): 332 | #Reads the current selected files in the fileview and updates self.selected 333 | self.selected = [] 334 | file_data = [] 335 | fileslayout = self.ids['files'] 336 | for file in fileslayout.selects: 337 | if file['type'] == 'file': 338 | file_data.append(file['file']) 339 | self.selected = sort_nicely(file_data) 340 | 341 | def go_up(self): 342 | up_path = os.path.realpath(os.path.join(self.folder, '..')) 343 | if not up_path.endswith(os.path.sep): 344 | up_path += os.path.sep 345 | if up_path == self.folder: 346 | up_path = self.root_path 347 | self.folder = up_path 348 | if self.folder_select: 349 | self.selected = [] 350 | self.selected = [self.folder] 351 | elif self.clear_filename: 352 | self.selected = [] 353 | self.refresh_folder() 354 | 355 | def dismiss_popup(self, *_): 356 | """If this dialog has a popup, closes it and removes it.""" 357 | 358 | if self.popup: 359 | self.popup.dismiss() 360 | self.popup = None 361 | 362 | def new_folder(self): 363 | """Starts the add folder process, creates an input text popup.""" 364 | 365 | self.dismiss_popup() 366 | content = InputPopupContent(hint='Folder Name', text='Enter A Folder Name:') 367 | app = App.get_running_app() 368 | content.bind(on_answer=self.new_folder_answer) 369 | self.popup = NormalPopup(title='Create Folder', content=content, size_hint=(None, None), size=(app.popup_x, app.button_scale * 5), auto_dismiss=False) 370 | self.popup.open() 371 | 372 | def new_folder_answer(self, instance, answer): 373 | """Tells the app to rename the folder if the dialog is confirmed. 374 | Arguments: 375 | instance: The dialog that called this function. 376 | answer: String, if 'yes', the folder will be created, all other answers will just close the dialog. 377 | """ 378 | 379 | if answer == 'yes': 380 | text = instance.ids['input'].text.strip(' ') 381 | if text: 382 | app = App.get_running_app() 383 | folder = os.path.join(self.folder, text) 384 | created = False 385 | try: 386 | if not os.path.isdir(folder): 387 | os.makedirs(folder) 388 | created = True 389 | except: 390 | pass 391 | if created: 392 | app.message("Created the folder '" + folder + "'") 393 | self.folder = folder 394 | self.refresh_folder() 395 | else: 396 | app.message("Could Not Create Folder.") 397 | self.dismiss_popup() 398 | 399 | def delete_folder(self): 400 | """Starts the delete folder process, creates the confirmation popup.""" 401 | 402 | app = App.get_running_app() 403 | text = "Delete The Selected Folder?" 404 | content = ConfirmPopupContent(text=text, yes_text='Delete', no_text="Don't Delete", warn_yes=True) 405 | content.bind(on_answer=self.delete_folder_answer) 406 | self.popup = NormalPopup(title='Confirm Delete', content=content, size_hint=(None, None), size=(app.popup_x, app.button_scale * 4), auto_dismiss=False) 407 | self.popup.open() 408 | 409 | def delete_folder_answer(self, instance, answer): 410 | """Tells the app to delete the folder if the dialog is confirmed. 411 | Arguments: 412 | instance: The dialog that called this function. 413 | answer: String, if 'yes', the folder will be deleted, all other answers will just close the dialog. 414 | """ 415 | 416 | del instance 417 | if answer == 'yes': 418 | app = App.get_running_app() 419 | try: 420 | os.rmdir(self.folder) 421 | app.message("Deleted Folder: \"" + self.folder + "\"") 422 | self.go_up() 423 | except: 424 | app.message("Could Not Delete Folder...") 425 | self.dismiss_popup() 426 | 427 | def refresh_all(self, *_): 428 | self.refresh_shortcuts() 429 | self.refresh_folder() 430 | 431 | def refresh_shortcuts(self, *_): 432 | locations = get_drives() 433 | self.root_path = locations[0][0] 434 | data = [] 435 | for location in locations: 436 | data.append({ 437 | 'text': location[1], 438 | 'fullpath': location[0], 439 | 'type': 'shortcut', 440 | 'is_folder': True, 441 | 'owner': self, 442 | 'selectable': True, 443 | 'selected': False 444 | }) 445 | self.shortcuts_data = [] 446 | self.shortcuts_data = data 447 | 448 | def refresh_folder(self, *_): 449 | fileslayout = self.ids['files'] 450 | fileslayout.selects = [] 451 | fileslayout.selected = {} 452 | 453 | data = [] 454 | files = [] 455 | dirs = [] 456 | 457 | walk = os.walk 458 | for root, list_dirs, list_files in walk(self.folder, topdown=True): 459 | dirs = list_dirs[:] 460 | list_dirs.clear() 461 | files = list_files 462 | self.folder_files = files 463 | if dirs or files: 464 | self.can_delete_folder = False 465 | else: 466 | self.can_delete_folder = True 467 | dirs = sorted(dirs, key=lambda s: s.lower()) 468 | 469 | #Sort directory list 470 | for directory in dirs: 471 | fullpath = os.path.join(self.folder, directory) 472 | data.append({ 473 | 'text': directory, 474 | 'fullpath': fullpath, 475 | 'type': 'folder', 476 | 'file': '', 477 | 'owner': self, 478 | 'is_folder': True, 479 | 'selected': False, 480 | 'multi_select': self.multi_select, 481 | 'selectable': True, 482 | 'file_size': '', 483 | 'modified': '' 484 | }) 485 | #Sort file list 486 | if self.show_files: 487 | if self.filetypes_filter: 488 | filtered_files = [] 489 | for item in self.filetypes_filter: 490 | filtered_files += fnmatch.filter(files, item) 491 | files = filtered_files 492 | #files = sorted(files, key=lambda s: s.lower()) 493 | files = sort_nicely(files) 494 | if self.autoselect_files and self.multi_select: 495 | file_selected = True 496 | else: 497 | file_selected = False 498 | filesystem = FileSystemLocal() 499 | for file in files: 500 | fullpath = os.path.join(self.folder, file) 501 | if not self.show_hidden: 502 | if filesystem.is_hidden(fullpath): 503 | continue 504 | try: 505 | file_size = int(os.path.getsize(fullpath)) 506 | except: 507 | file_size = 0 508 | try: 509 | modified = int(os.path.getmtime(fullpath)) 510 | except: 511 | modified = 0 512 | file_data = { 513 | 'text': file, 514 | 'fullpath': fullpath, 515 | 'type': 'file', 516 | 'file': file, 517 | 'owner': self, 518 | 'is_folder': False, 519 | 'selected': file_selected, 520 | 'multi_select': self.multi_select, 521 | 'selectable': self.file_select, 522 | 'file_size': format_size(file_size), 523 | 'modified': datetime.datetime.fromtimestamp(modified).strftime('%Y-%m-%d, %I:%M%p') 524 | } 525 | data.append(file_data) 526 | if file_selected: 527 | fileslayout.selects.append(file_data) 528 | if file_selected: 529 | Clock.schedule_once(self.update_selected_files) 530 | 531 | self.file_list_data = data 532 | self.reset_folder_scroll() 533 | 534 | def reset_folder_scroll(self, *_): 535 | filelist = self.ids['fileList'] 536 | filelist.scroll_y = 1 537 | -------------------------------------------------------------------------------- /snu/label.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.properties import ListProperty, ObjectProperty, NumericProperty, StringProperty 3 | from kivy.clock import Clock 4 | from kivy.animation import Animation 5 | from kivy.uix.label import Label 6 | from kivy.lang.builder import Builder 7 | Builder.load_string(""" 8 | : 9 | mipmap: True 10 | color: app.theme.text 11 | font_size: app.text_scale 12 | size_hint_y: None 13 | height: app.button_scale 14 | 15 | : 16 | shorten: True 17 | shorten_from: 'right' 18 | size_hint_x: 1 19 | size_hint_max_x: self.texture_size[0] + 20 20 | 21 | : 22 | shorten: True 23 | shorten_from: 'right' 24 | text_size: self.size 25 | halign: 'left' 26 | valign: 'middle' 27 | 28 | : 29 | canvas.before: 30 | Color: 31 | rgba: root.bgcolor 32 | Rectangle: 33 | pos: self.pos 34 | size: self.size 35 | mipmap: True 36 | text: app.infotext 37 | color: app.theme.info_text 38 | 39 | <-TickerLabel>: 40 | canvas.before: 41 | StencilPush 42 | Rectangle: 43 | pos: self.pos 44 | size: self.size 45 | StencilUse 46 | canvas: 47 | Color: 48 | rgba: self.color 49 | Rectangle: 50 | texture: self.texture 51 | size: self.texture_size 52 | pos: self.x - self.ticker_offset, self.center_y - self.texture_size[1] / 2 53 | canvas.after: 54 | StencilUnUse 55 | Rectangle: 56 | pos: self.pos 57 | size: self.size 58 | StencilPop 59 | mipmap: True 60 | color: app.theme.text 61 | font_size: app.text_scale 62 | size_hint_y: None 63 | height: app.button_scale 64 | 65 | : 66 | mipmap: True 67 | color: app.theme.header_text 68 | font_size: int(app.text_scale * 1.5) 69 | size_hint_y: None 70 | height: app.button_scale 71 | bold: True 72 | """) 73 | 74 | 75 | class NormalLabel(Label): 76 | """Basic label widget""" 77 | pass 78 | 79 | 80 | class ShortLabel(NormalLabel): 81 | """Label widget that will remain the minimum width to still display its text""" 82 | pass 83 | 84 | 85 | class LeftNormalLabel(NormalLabel): 86 | """Label widget that displays text left-justified""" 87 | pass 88 | 89 | 90 | class InfoLabel(ShortLabel): 91 | """Special label widget that automatically displays a message from the app class and blinks when the text is changed.""" 92 | 93 | bgcolor = ListProperty([1, 1, 0, 0]) 94 | blinker = ObjectProperty() 95 | 96 | def on_text(self, instance, text): 97 | del instance 98 | app = App.get_running_app() 99 | if self.blinker: 100 | self.stop_blinking() 101 | if text: 102 | no_bg = [.5, .5, .5, 0] 103 | yes_bg = app.theme.info_background 104 | self.blinker = Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) + Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) + Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) + Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) + Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) + Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) + Animation(bgcolor=yes_bg, duration=0.33) + Animation(bgcolor=no_bg, duration=0.33) 105 | self.blinker.start(self) 106 | 107 | def stop_blinking(self, *_): 108 | if self.blinker: 109 | self.blinker.cancel(self) 110 | self.bgcolor = [1, 1, 0, 0] 111 | 112 | 113 | class TickerLabel(Label): 114 | """Label that will scroll a line of text back and forth if it is longer than the widget size""" 115 | ticker_delay = NumericProperty(1.5) #delay in seconds before ticker starts, also is pause before scrolling back 116 | ticker_amount = NumericProperty(0.5) #pixels to scroll by on each tick, can be less than 1 117 | ticker_transition = StringProperty('in_out_sine') #type of animation to be used, try 'linear' also 118 | ticker_offset = NumericProperty(0) 119 | ticker_animate = ObjectProperty(allownone=True) 120 | ticker_delayer = ObjectProperty(allownone=True) 121 | 122 | def on_text(self, *_): 123 | self.reset_ticker() 124 | 125 | def on_size(self, *_): 126 | self.reset_ticker() 127 | 128 | def reset_ticker(self): 129 | self.stop_animate() 130 | if self.ticker_delayer: 131 | self.ticker_delayer.cancel() 132 | self.ticker_delayer = None 133 | self.ticker_delayer = Clock.schedule_once(self.setup_animate, self.ticker_delay) 134 | 135 | def stop_animate(self, *_): 136 | if self.ticker_animate: 137 | self.ticker_animate.cancel(self) 138 | self.ticker_animate = None 139 | self.ticker_offset = 0 140 | 141 | def setup_animate(self, *_): 142 | if not self.texture: 143 | return 144 | if self.texture.size[0] > self.width: 145 | self.ticker_offset = 0 146 | ticker_per_tick = (self.texture_size[0] - self.width) / self.ticker_amount 147 | ticker_time = ticker_per_tick / 100 148 | self.ticker_animate = Animation(ticker_offset=self.texture.width - self.width, duration=ticker_time, t=self.ticker_transition) + Animation(duration=self.ticker_delay) + Animation(ticker_offset=0, duration=ticker_time, t=self.ticker_transition) + Animation(duration=self.ticker_delay) 149 | self.ticker_animate.repeat = True 150 | self.ticker_animate.start(self) -------------------------------------------------------------------------------- /snu/layouts.py: -------------------------------------------------------------------------------- 1 | from kivy.graphics.transformation import Matrix 2 | from kivy.properties import BooleanProperty 3 | from kivy.uix.scatterlayout import ScatterLayout 4 | from kivy.uix.boxlayout import BoxLayout 5 | from kivy.uix.widget import Widget 6 | 7 | from kivy.lang.builder import Builder 8 | Builder.load_string(""" 9 | : 10 | size_hint: None, None 11 | height: int(app.button_scale / 4) 12 | width: int(app.button_scale / 4) 13 | 14 | : 15 | size_hint: None, None 16 | height: int(app.button_scale / 2) 17 | width: int(app.button_scale / 2) 18 | 19 | : 20 | size_hint: None, None 21 | height: app.button_scale 22 | width: app.button_scale 23 | 24 | : 25 | size_hint_y: None 26 | orientation: 'horizontal' 27 | 28 | : 29 | orientation: 'horizontal' 30 | size_hint_y: None 31 | height: app.button_scale 32 | 33 |
: 34 | canvas.before: 35 | Color: 36 | rgba: app.theme.header_background 37 | Rectangle: 38 | size: self.size 39 | pos: self.pos 40 | source: 'data/headerbg.png' 41 | height: app.button_scale 42 | 43 | : 44 | canvas.before: 45 | Color: 46 | rgba: app.theme.main_background 47 | Rectangle: 48 | size: self.size 49 | pos: self.pos 50 | source: 'data/mainbg.png' 51 | size_hint: 1, 1 52 | orientation: 'vertical' 53 | """) 54 | 55 | 56 | class SmallSpacer(Widget): 57 | pass 58 | 59 | 60 | class MediumSpacer(Widget): 61 | pass 62 | 63 | 64 | class LargeSpacer(Widget): 65 | pass 66 | 67 | 68 | class HeaderBase(BoxLayout): 69 | pass 70 | 71 | 72 | class Header(HeaderBase): 73 | pass 74 | 75 | 76 | class Holder(BoxLayout): 77 | pass 78 | 79 | 80 | class MainArea(BoxLayout): 81 | pass 82 | 83 | 84 | class LimitedScatterLayout(ScatterLayout): 85 | """Custom ScatterLayout that won't allow sub-widgets to be moved out of the visible area, 86 | and will not respond to touches outside of the visible area. 87 | """ 88 | 89 | bypass = BooleanProperty(False) 90 | 91 | def on_bypass(self, instance, bypass): 92 | if bypass: 93 | self.transform = Matrix() 94 | 95 | def on_transform_with_touch(self, touch): 96 | """Modified to not allow widgets to be moved out of the visible area.""" 97 | 98 | width = self.bbox[1][0] 99 | height = self.bbox[1][1] 100 | scale = self.scale 101 | 102 | local_bottom = self.bbox[0][1] 103 | local_left = self.bbox[0][0] 104 | local_top = local_bottom+height 105 | local_right = local_left+width 106 | 107 | local_xmax = width/scale 108 | local_xmin = 0 109 | local_ymax = height/scale 110 | local_ymin = 0 111 | 112 | if local_right < local_xmax: 113 | self.transform[12] = local_xmin - (width - local_xmax) 114 | if local_left > local_xmin: 115 | self.transform[12] = local_xmin 116 | if local_top < local_ymax: 117 | self.transform[13] = local_ymin - (height - local_ymax) 118 | if local_bottom > local_ymin: 119 | self.transform[13] = local_ymin 120 | 121 | def on_touch_down(self, touch): 122 | """Modified to only register touches in visible area.""" 123 | 124 | if self.bypass: 125 | for child in self.children[:]: 126 | if child.dispatch('on_touch_down', touch): 127 | return True 128 | else: 129 | if self.collide_point(*touch.pos): 130 | super(LimitedScatterLayout, self).on_touch_down(touch) 131 | -------------------------------------------------------------------------------- /snu/navigation.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.uix.widget import Widget 3 | from kivy.properties import BooleanProperty 4 | from kivy.graphics import Color, Rectangle 5 | 6 | 7 | class Navigation(Widget): 8 | """Mixin class that adds keyboard/joystick navigation functionality to the mixed widget, when paired with the NormalApp class""" 9 | 10 | navigation_selectable = BooleanProperty(True) 11 | navigation_overlay_color = None 12 | navigation_overlay_box = None 13 | 14 | def on_navigation_activate(self): 15 | #Override this function for custom functionality of the 'enter' function of the keyboard navigation. 16 | try: 17 | self.trigger_action() 18 | except: 19 | pass 20 | try: 21 | self.focus = not self.focus 22 | except: 23 | pass 24 | 25 | def on_navigation_next(self): 26 | #Override this function and return True to 'lock' the next navigation function to this widget 27 | return False 28 | 29 | def on_navigation_prev(self): 30 | #Override this function and return True to 'lock' the previous navigation function to this widget 31 | return False 32 | 33 | def on_navigation_increase(self): 34 | #Override this function to allow the keyboard navigation 'right' to control this widget. 35 | pass 36 | 37 | def on_navigation_decrease(self): 38 | #Override this function to allow the keyboard navigation 'left' to control this widget. 39 | pass 40 | 41 | def on_navigation_select(self): 42 | app = App.get_running_app() 43 | self.navigation_overlay_color = Color(rgba=app.theme.selected_overlay) 44 | app.theme.bind(selected_overlay=self.set_navigation_overlay_color) 45 | self.navigation_overlay_box = Rectangle(size=self.size, pos=self.pos) 46 | self.bind(size=self.set_navigation_overlay_size) 47 | self.bind(pos=self.set_navigation_overlay_pos) 48 | self.canvas.after.add(self.navigation_overlay_color) 49 | self.canvas.after.add(self.navigation_overlay_box) 50 | 51 | def on_navigation_deselect(self): 52 | self.canvas.after.remove(self.navigation_overlay_color) 53 | self.canvas.after.remove(self.navigation_overlay_box) 54 | self.unbind(size=self.set_navigation_overlay_size) 55 | self.unbind(pos=self.set_navigation_overlay_pos) 56 | app = App.get_running_app() 57 | app.theme.unbind(selected_overlay=self.set_navigation_overlay_color) 58 | self.navigation_overlay_color = None 59 | self.navigation_overlay_box = None 60 | 61 | def set_navigation_overlay_size(self, instance, value): 62 | self.navigation_overlay_box.size = value 63 | 64 | def set_navigation_overlay_pos(self, instance, value): 65 | self.navigation_overlay_box.pos = value 66 | 67 | def set_navigation_overlay_color(self, instance, value): 68 | if self.navigation_overlay_color: 69 | self.navigation_overlay_color.rgba = value 70 | -------------------------------------------------------------------------------- /snu/popup.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.uix.popup import Popup 3 | from kivy.animation import Animation 4 | from kivy.properties import StringProperty, BooleanProperty, ObjectProperty 5 | from kivy.uix.gridlayout import GridLayout 6 | from kivy.lang.builder import Builder 7 | Builder.load_string(""" 8 | : 9 | background_color: app.theme.menu_background 10 | background: 'data/panelbg.png' 11 | separator_color: 1, 1, 1, .25 12 | title_size: app.text_scale * 1.25 13 | title_color: app.theme.header_text 14 | 15 | : 16 | canvas: 17 | Color: 18 | rgba: 0, 0, 0, .75 * self._anim_alpha 19 | Rectangle: 20 | size: self._window.size if self._window else (0, 0) 21 | Color: 22 | rgba: app.theme.menu_background 23 | Rectangle: 24 | size: self.size 25 | pos: self.pos 26 | source: 'data/panelbg.png' 27 | background_color: 1, 1, 1, 0 28 | background: 'data/transparent.png' 29 | separator_color: 1, 1, 1, .25 30 | title_size: app.text_scale * 1.25 31 | title_color: app.theme.header_text 32 | 33 | : 34 | cols:1 35 | NormalLabel: 36 | text_size: self.size 37 | size_hint_y: 1 38 | valign: 'top' 39 | text: root.text 40 | GridLayout: 41 | cols:1 42 | size_hint_y: None 43 | height: app.button_scale 44 | WideButton: 45 | id: button 46 | text: root.button_text 47 | on_release: root.close() 48 | 49 | : 50 | cols:1 51 | NormalLabel: 52 | text_size: self.size 53 | size_hint_y: 1 54 | valign: 'top' 55 | text: root.text 56 | NormalInput: 57 | id: input 58 | allow_mode: root.input_allow_mode 59 | multiline: False 60 | hint_text: root.hint 61 | text: root.input_text 62 | on_text: root.input_text = self.text 63 | focus: True 64 | GridLayout: 65 | cols: 2 66 | size_hint_y: None 67 | height: app.button_scale 68 | WideButton: 69 | text: 'OK' 70 | on_release: root.dispatch('on_answer','yes') 71 | WideButton: 72 | text: 'Cancel' 73 | on_release: root.dispatch('on_answer', 'no') 74 | 75 | : 76 | cols:1 77 | NormalLabel: 78 | text_size: self.size 79 | size_hint_y: 1 80 | valign: 'top' 81 | text: root.text 82 | GridLayout: 83 | cols: 2 84 | size_hint_y: None 85 | height: app.button_scale 86 | WideButton: 87 | text: root.yes_text 88 | on_release: root.dispatch('on_answer','yes') 89 | warn: root.warn_yes 90 | WideButton: 91 | text: root.no_text 92 | on_release: root.dispatch('on_answer', 'no') 93 | warn: root.warn_no 94 | """) 95 | 96 | 97 | class NormalPopup(Popup): 98 | """Popup widget that adds open and close animations.""" 99 | 100 | def open(self, *args, **kwargs): 101 | app = App.get_running_app() 102 | if app.animations: 103 | self.opacity = 0 104 | height = self.height 105 | self.height = 4 * self.height 106 | anim = Animation(opacity=1, height=height, duration=app.animation_length) 107 | anim.start(self) 108 | else: 109 | self.opacity = 1 110 | super(NormalPopup, self).open(*args, **kwargs) 111 | 112 | def dismiss(self, *args, **kwargs): 113 | app = App.get_running_app() 114 | if app.animations: 115 | anim = Animation(opacity=0, height=0, duration=app.animation_length) 116 | anim.start(self) 117 | anim.bind(on_complete=self.finish_dismiss) 118 | else: 119 | super(NormalPopup, self).dismiss() 120 | 121 | def finish_dismiss(self, *_): 122 | super(NormalPopup, self).dismiss() 123 | 124 | 125 | class MessagePopupContent(GridLayout): 126 | """Basic popup message with a message and 'ok' button.""" 127 | 128 | button_text = StringProperty('OK') 129 | text = StringProperty() 130 | data = ObjectProperty() #Generic variable that can store data to be passed in/out of popup 131 | 132 | def close(self, *_): 133 | app = App.get_running_app() 134 | app.popup.dismiss() 135 | 136 | 137 | class InputPopupContent(GridLayout): 138 | """Basic text input popup message. Calls 'on_answer' when either button is clicked.""" 139 | 140 | input_allow_mode = StringProperty() 141 | input_text = StringProperty() 142 | text = StringProperty() #Text that the user has input 143 | hint = StringProperty() #Grayed-out hint text in the input field 144 | data = ObjectProperty() #Generic variable that can store data to be passed in/out of popup 145 | 146 | def __init__(self, **kwargs): 147 | self.register_event_type('on_answer') 148 | super(InputPopupContent, self).__init__(**kwargs) 149 | 150 | def on_answer(self, *args): 151 | pass 152 | 153 | 154 | class ConfirmPopupContent(GridLayout): 155 | """Basic Yes/No popup message. Calls 'on_answer' when either button is clicked.""" 156 | 157 | text = StringProperty() 158 | yes_text = StringProperty('Yes') 159 | no_text = StringProperty('No') 160 | warn_yes = BooleanProperty(False) 161 | warn_no = BooleanProperty(False) 162 | data = ObjectProperty() #Generic variable that can store data to be passed in/out of popup 163 | 164 | def __init__(self, **kwargs): 165 | self.register_event_type('on_answer') 166 | super(ConfirmPopupContent, self).__init__(**kwargs) 167 | 168 | def on_answer(self, *args): 169 | pass 170 | -------------------------------------------------------------------------------- /snu/recycleview.py: -------------------------------------------------------------------------------- 1 | from kivy.app import App 2 | from kivy.properties import ObjectProperty, StringProperty, ListProperty, BooleanProperty, NumericProperty, DictProperty 3 | from kivy.animation import Animation 4 | from kivy.core.window import Window 5 | from kivy.uix.recycleview import RecycleView 6 | from kivy.uix.recycleview.views import RecycleDataViewBehavior 7 | from kivy.uix.recycleboxlayout import RecycleBoxLayout 8 | from kivy.uix.recyclegridlayout import RecycleGridLayout 9 | from kivy.uix.widget import Widget 10 | from .label import NormalLabel 11 | from kivy.lang.builder import Builder 12 | Builder.load_string(""" 13 | : 14 | canvas.before: 15 | Color: 16 | rgba: self.bgcolor 17 | Rectangle: 18 | size: self.size 19 | pos: self.pos 20 | 21 | : 22 | default_size_hint: 1, None 23 | default_size: self.width, app.button_scale 24 | size_hint_x: 1 25 | orientation: 'vertical' 26 | size_hint_y: None 27 | height: self.minimum_height 28 | 29 | : 30 | cols: max(1, int(self.width / ((app.button_scale * 4 * self.scale) + (app.button_scale / 2)))) 31 | focus: False 32 | default_size: app.button_scale * 4 * self.scale, app.button_scale * 4 * self.scale 33 | default_size_hint: None, None 34 | height: self.minimum_height 35 | size_hint_y: None 36 | 37 | : 38 | size_hint: 1, 1 39 | do_scroll_x: False 40 | do_scroll_y: True 41 | scroll_distance: 10 42 | scroll_timeout: 300 43 | bar_width: app.scrollbar_scale 44 | bar_color: app.theme.scroller_selected 45 | bar_inactive_color: app.theme.scroller 46 | scroll_type: ['bars', 'content'] 47 | """) 48 | 49 | 50 | class RecycleItem(RecycleDataViewBehavior): 51 | bgcolor = ListProperty([0, 0, 0, 0]) 52 | owner = ObjectProperty() 53 | text = StringProperty() 54 | selected = BooleanProperty(False) 55 | selectable = BooleanProperty(True) 56 | index = NumericProperty(0) 57 | data = {} 58 | remove_length = NumericProperty(.25) 59 | animation = ObjectProperty(allownone=True) 60 | o_pos = ListProperty() 61 | o_opacity = NumericProperty() 62 | 63 | def on_selected(self, *_): 64 | self.set_color() 65 | 66 | def set_color(self): 67 | app = App.get_running_app() 68 | 69 | if self.selected: 70 | self.bgcolor = app.theme.selected 71 | else: 72 | if self.index % 2 == 0: 73 | self.bgcolor = app.list_background_even 74 | else: 75 | self.bgcolor = app.list_background_odd 76 | 77 | def refresh_view_attrs(self, rv, index, data): 78 | self.index = index 79 | self.data = data 80 | if 'selected' not in self.data: 81 | self.data['selected'] = False 82 | if 'selectable' not in self.data: 83 | self.data['selectable'] = True 84 | self.set_color() 85 | return super(RecycleItem, self).refresh_view_attrs(rv, index, data) 86 | 87 | def on_touch_down(self, touch): 88 | if super(RecycleItem, self).on_touch_down(touch): 89 | return True 90 | if self.collide_point(*touch.pos): 91 | touch.grab(self) 92 | 93 | def on_touch_up(self, touch): 94 | if touch.grab_current == self: 95 | if self.collide_point(*touch.pos): 96 | touch.ungrab(self) 97 | self.parent.click_node(self) 98 | if 'shift' in Window.modifiers: 99 | self.parent.select_range(self.index, touch) 100 | return True 101 | 102 | def remove(self): 103 | if not self.animation: 104 | self.o_pos = self.pos 105 | self.o_opacity = self.opacity 106 | self.animation = Animation(opacity=0, duration=self.remove_length, pos=(self.pos[0]-self.width, self.pos[1])) 107 | self.animation.start(self) 108 | self.animation.bind(on_complete=self.remove_finish) 109 | 110 | def remove_finish(self, *_): 111 | self.animation = None 112 | self.opacity = self.o_opacity 113 | self.pos = self.o_pos 114 | if self.parent: 115 | self.parent.remove_node(self) 116 | 117 | 118 | class RecycleItemLabel(RecycleItem, NormalLabel): 119 | pass 120 | 121 | 122 | class SelectableRecycleLayout(Widget): 123 | """Adds selection and focus behavior to the view.""" 124 | owner = ObjectProperty() 125 | selected = DictProperty() 126 | selects = ListProperty() 127 | multiselect = BooleanProperty(False) 128 | 129 | def __init__(self, **kwargs): 130 | super().__init__(**kwargs) 131 | self.register_event_type('on_click_node') 132 | 133 | def clear_selects(self): 134 | self.selects = [] 135 | 136 | def refresh_selection(self): 137 | for node in self.children: 138 | try: #possible for nodes to not be synched with data 139 | data = self.parent.data[node.index] 140 | node.selected = data['selected'] 141 | except: 142 | pass 143 | 144 | def deselect_all(self): 145 | for data in self.parent.data: 146 | data['selected'] = False 147 | self.refresh_selection() 148 | self.selects = [] 149 | self.selected = {} 150 | 151 | def select_all(self): 152 | self.selects = [] 153 | selects = [] 154 | for data in self.parent.data: 155 | if data['selectable']: 156 | data['selected'] = True 157 | selects.append(data) 158 | self.selects = selects 159 | if selects: 160 | self.selected = selects[-1] 161 | else: 162 | self.selected = {} 163 | self.refresh_selection() 164 | 165 | def select_node(self, node): 166 | if not self.multiselect: 167 | self.deselect_all() 168 | node.selected = True 169 | self.selects.append(node.data) 170 | if node.data not in self.parent.data: 171 | return 172 | self.parent.data[self.parent.data.index(node.data)]['selected'] = True 173 | node.data['selected'] = True 174 | self.selected = node.data 175 | 176 | def deselect_node(self, node): 177 | if node.data in self.selects: 178 | self.selects.remove(node.data) 179 | if self.selected == node.data: 180 | if self.selects: 181 | self.selected = self.selects[-1] 182 | else: 183 | self.selected = {} 184 | if node.data in self.parent.data: 185 | parent_index = self.parent.data.index(node.data) 186 | parent_data = self.parent.data[parent_index] 187 | parent_data['selected'] = False 188 | node.selected = False 189 | node.data['selected'] = False 190 | 191 | def click_node(self, node): 192 | #Called by a child widget when it is clicked on 193 | if node.selected: 194 | if self.multiselect: 195 | self.deselect_node(node) 196 | else: 197 | if self.selected != node.data: 198 | self.selected = node.data 199 | #self.deselect_all() 200 | else: 201 | if not self.multiselect: 202 | self.deselect_all() 203 | self.select_node(node) 204 | self.selected = node.data 205 | self.dispatch('on_click_node', node) 206 | 207 | def on_click_node(self, node): 208 | pass 209 | 210 | def remove_node(self, node): 211 | self.parent.data.pop(node.index) 212 | 213 | def select_range(self, *_): 214 | if self.multiselect and self.selected and self.selected['selectable']: 215 | select_index = self.parent.data.index(self.selected) 216 | selected_nodes = [] 217 | if self.selects: 218 | for select in self.selects: 219 | if select['selectable']: 220 | if select not in self.parent.data: 221 | continue 222 | index = self.parent.data.index(select) 223 | if index != select_index: 224 | selected_nodes.append(index) 225 | else: 226 | selected_nodes = [0, len(self.parent.data)] 227 | if not selected_nodes: 228 | return 229 | closest_node = min(selected_nodes, key=lambda x: abs(x-select_index)) 230 | for index in range(min(select_index, closest_node), max(select_index, closest_node)): 231 | selected = self.parent.data[index] 232 | selected['selected'] = True 233 | if selected not in self.selects: 234 | self.selects.append(selected) 235 | self.parent.refresh_from_data() 236 | 237 | def toggle_select(self, *_): 238 | if self.multiselect: 239 | if self.selects: 240 | self.deselect_all() 241 | else: 242 | self.select_all() 243 | else: 244 | if self.selected: 245 | self.selected = {} 246 | 247 | 248 | class SelectableRecycleBoxLayout(SelectableRecycleLayout, RecycleBoxLayout): 249 | pass 250 | 251 | 252 | class SelectableRecycleGridLayout(SelectableRecycleLayout, RecycleGridLayout): 253 | scale = NumericProperty(1) 254 | 255 | 256 | class NormalRecycleView(RecycleView): 257 | pass 258 | -------------------------------------------------------------------------------- /snu/roulettescroll/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2015 Kivy Team and other contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /snu/roulettescroll/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | RouletteScrollEffect 3 | =================== 4 | 5 | This is a subclass of :class:`kivy.effects.ScrollEffect` that simulates the 6 | motion of a roulette, or a notched wheel (think Wheel of Fortune). It is 7 | primarily designed for emulating the effect of the iOS and android date pickers. 8 | 9 | Usage 10 | ----- 11 | 12 | Here's an example of using :class:`RouletteScrollEffect` for a 13 | :class:`kivy.uix.scrollview.ScrollView`:: 14 | 15 | if __name__ == '__main__': 16 | # example modified from the scrollview example 17 | 18 | from kivy.uix.gridlayout import GridLayout 19 | from kivy.uix.button import Button 20 | from kivy.uix.scrollview import ScrollView 21 | 22 | # preparing a gridlayout inside a scrollview 23 | layout = GridLayout(cols=1, padding=10, 24 | size_hint=(None, None), width=500) 25 | 26 | layout.bind(minimum_height=layout.setter('height')) 27 | 28 | for i in range(30): 29 | btn = Button(text=str(i), size=(480, 40), 30 | size_hint=(None, None)) 31 | layout.add_widget(btn) 32 | 33 | root = ScrollView(size_hint=(None, None), size=(500, 320), 34 | pos_hint={'center_x': .5, 'center_y': .5} 35 | , do_scroll_x=False) 36 | root.add_widget(layout) 37 | 38 | # preparation complete. Now add the new scroll effect! 39 | root.effect_y = RouletteScrollEffect(anchor=20, interval=40) 40 | 41 | runTouchApp(root) 42 | 43 | Here the :class:`ScrollView` scrolls through a series of buttons with height 44 | 40. We then attached a :class:`RouletteScrollEffect` with interval 40, 45 | corresponding to the button heights. This allows the scrolling to stop at 46 | the same offset no matter where it stops. The :attr:`RouletteScrollEffect.anchor` 47 | adjusts this offset. 48 | 49 | Customizations 50 | -------------- 51 | 52 | Other settings that can be played with include 53 | :attr:`RouletteScrollEffect.pull_duration`, 54 | :attr:`RouletteScrollEffect.coasting_alpha`, 55 | :attr:`RouletteScrollEffect.pull_back_velocity`, and 56 | :attr:`RouletteScrollEffect.terminal_velocity`. See their module documentations 57 | for details. 58 | 59 | :class:`RouletteScrollEffect` has one event ``on_coasted_to_stop`` that 60 | is fired when the roulette stops, "making a selection". It can be listened to 61 | for handling or cleaning up choice making. 62 | ''' 63 | 64 | from kivy.animation import Animation 65 | from kivy.clock import Clock 66 | from kivy.effects.scroll import ScrollEffect 67 | from kivy.properties import NumericProperty, AliasProperty, ObjectProperty 68 | from math import ceil, floor, exp 69 | 70 | class RouletteScrollEffect(ScrollEffect): 71 | __events__ = ('on_coasted_to_stop',) 72 | 73 | drag_threshold = NumericProperty(0) 74 | '''overrides :attr:`ScrollEffect.drag_threshold` to abolish drag threshold. 75 | 76 | .. note:: 77 | If using this with a :class:`Roulette` or other :class:`Tickline` 78 | subclasses, what matters is :attr:`Tickline.drag_threshold`, which 79 | is passed to this attribute in the end. 80 | ''' 81 | 82 | min = NumericProperty(-float('inf')) 83 | max = NumericProperty(float('inf')) 84 | 85 | interval = NumericProperty(50) 86 | '''the interval of the values of the "roulette".''' 87 | 88 | anchor = NumericProperty(0) 89 | '''one of the valid stopping values.''' 90 | 91 | pull_duration = NumericProperty(.2) 92 | '''when movement slows around a stopping value, an animation is used 93 | to pull it toward the nearest value. :attr:`pull_duration` is the duration 94 | used for such an animation.''' 95 | 96 | coasting_alpha = NumericProperty(.5) 97 | '''When within :attr:`coasting_alpha` * :attr:`interval` of the 98 | next notch and velocity is below :attr:`terminal_velocity`, 99 | coasting begins and will end on the next notch.''' 100 | 101 | pull_back_velocity = NumericProperty('50sp') 102 | '''the velocity below which the scroll value will be drawn to the 103 | *nearest* notch instead of the *next* notch in the direction travelled.''' 104 | 105 | _anim = ObjectProperty(None) 106 | 107 | def get_term_vel(self): 108 | return (exp(self.friction) * self.interval * 109 | self.coasting_alpha / self.pull_duration) 110 | def set_term_vel(self, val): 111 | self.pull_duration = (exp(self.friction) * self.interval * 112 | self.coasting_alpha / val) 113 | terminal_velocity = AliasProperty(get_term_vel, set_term_vel, 114 | bind=['interval', 115 | 'coasting_alpha', 116 | 'pull_duration', 117 | 'friction'], 118 | cache=True) 119 | '''if velocity falls between :attr:`pull_back_velocity` and 120 | :attr:`terminal velocity` then the movement will start to coast 121 | to the next coming stopping value. 122 | 123 | :attr:`terminal_velocity` is computed from a set formula given 124 | :attr:`interval`, :attr:`coasting_alpha`, :attr:`pull_duration`, 125 | and :attr:`friction`. Setting :attr:`terminal_velocity` has the 126 | effect of setting :attr:`pull_duration`. 127 | ''' 128 | 129 | def start(self, val, t=None): 130 | if self._anim: 131 | self._anim.stop(self) 132 | return ScrollEffect.start(self, val, t=t) 133 | 134 | def on_notch(self, *args): 135 | return (self.scroll - self.anchor) % self.interval == 0 136 | 137 | def nearest_notch(self, *args): 138 | interval = float(self.interval) 139 | anchor = self.anchor 140 | n = round((self.scroll - anchor) / interval) 141 | return anchor + n * interval 142 | 143 | def next_notch(self, *args): 144 | interval = float(self.interval) 145 | anchor = self.anchor 146 | round_ = ceil if self.velocity > 0 else floor 147 | n = round_((self.scroll - anchor) / interval) 148 | return anchor + n * interval 149 | 150 | def near_notch(self, d=0.01): 151 | nearest = self.nearest_notch() 152 | if abs((nearest - self.scroll) / self.interval) % 1 < d: 153 | return nearest 154 | else: 155 | return None 156 | 157 | def near_next_notch(self, d=None): 158 | d = d or self.coasting_alpha 159 | next_ = self.next_notch() 160 | if abs((next_ - self.scroll) / self.interval) % 1 < d: 161 | return next_ 162 | else: 163 | return None 164 | 165 | def update_velocity(self, dt): 166 | if self.is_manual: 167 | return 168 | velocity = self.velocity 169 | t_velocity = self.terminal_velocity 170 | next_ = self.near_next_notch() 171 | pull_back_velocity = self.pull_back_velocity 172 | if pull_back_velocity < abs(velocity) < t_velocity and next_: 173 | duration = abs((next_ - self.scroll) / self.velocity) 174 | anim = Animation(scroll=next_, 175 | duration=duration, 176 | ) 177 | self._anim = anim 178 | anim.on_complete = self._coasted_to_stop 179 | anim.start(self) 180 | return 181 | if abs(velocity) < pull_back_velocity and not self.on_notch(): 182 | anim = Animation(scroll=self.nearest_notch(), 183 | duration=self.pull_duration, 184 | t='in_out_circ') 185 | self._anim = anim 186 | anim.on_complete = self._coasted_to_stop 187 | anim.start(self) 188 | else: 189 | self.velocity -= self.velocity * self.friction 190 | self.apply_distance(self.velocity * dt) 191 | self.trigger_velocity_update() 192 | 193 | def on_coasted_to_stop(self, *args): 194 | '''this event fires when the roulette has stopped, "making a selection". 195 | ''' 196 | pass 197 | 198 | def _coasted_to_stop(self, *args): 199 | self.velocity = 0 200 | self.dispatch('on_coasted_to_stop') 201 | 202 | 203 | if __name__ == '__main__': 204 | # example modified from the scrollview example 205 | 206 | from kivy.uix.gridlayout import GridLayout 207 | from kivy.uix.button import Button 208 | from kivy.uix.scrollview import ScrollView 209 | from kivy.base import runTouchApp 210 | 211 | layout = GridLayout(cols=1, padding=10, 212 | size_hint=(None, None), width=500) 213 | 214 | layout.bind(minimum_height=layout.setter('height')) 215 | 216 | for i in range(30): 217 | btn = Button(text=str(i), size=(480, 40), 218 | size_hint=(None, None)) 219 | layout.add_widget(btn) 220 | 221 | root = ScrollView(size_hint=(None, None), size=(500, 320), 222 | pos_hint={'center_x': .5, 'center_y': .5} 223 | , do_scroll_x=False) 224 | root.add_widget(layout) 225 | 226 | root.effect_y = RouletteScrollEffect(anchor=20, interval=40) 227 | runTouchApp(root) -------------------------------------------------------------------------------- /snu/scrollview.py: -------------------------------------------------------------------------------- 1 | from kivy.uix.scrollview import ScrollView 2 | from kivy.animation import Animation 3 | from kivy.properties import ListProperty, AliasProperty, NumericProperty, ObjectProperty, BooleanProperty, ColorProperty 4 | from functools import partial 5 | from kivy.uix.boxlayout import BoxLayout 6 | from kivy.clock import Clock 7 | from kivy.uix.stencilview import StencilView 8 | from kivy.lang.builder import Builder 9 | Builder.load_string(""" 10 | : 11 | always_overscroll: False 12 | scroll_distance: 10 13 | scroll_timeout: 100 14 | bar_width: app.scrollbar_scale 15 | bar_color: app.theme.scroller_selected 16 | bar_inactive_color: app.theme.scroller 17 | scroll_type: ['bars', 'content'] 18 | 19 | : 20 | _handle_x_pos: self.x + self.width * self.hbar[0], self.y 21 | _handle_x_size: self.width * self.hbar[1], self.height 22 | canvas: 23 | Color: 24 | rgba: self._bar_color if self.is_active else [0, 0, 0, 0] 25 | RoundedRectangle: 26 | radius: [self.rounding] 27 | pos: root._handle_x_pos or (0, 0) 28 | size: root._handle_x_size or (0, 0) 29 | is_active: self.viewport_size[0] > self.scroller_size[0] 30 | bar_color: app.theme.scroller_selected 31 | bar_inactive_color: app.theme.scroller 32 | size_hint_y: None 33 | orientation: 'horizontal' 34 | height: 0 if (not self.is_active and self.autohide) else app.scrollbar_scale 35 | opacity: 0 if (not self.is_active and self.autohide) else 1 36 | 37 | : 38 | _handle_y_pos: self.x, self.y + self.height * self.vbar[0] 39 | _handle_y_size: self.width, self.height * self.vbar[1] 40 | canvas: 41 | Color: 42 | rgba: self._bar_color if self.is_active else [0, 0, 0, 0] 43 | RoundedRectangle: 44 | radius: [self.rounding] 45 | pos: root._handle_y_pos or (0, 0) 46 | size: root._handle_y_size or (0, 0) 47 | is_active: self.viewport_size[1] > self.scroller_size[1] 48 | bar_color: app.theme.scroller_selected 49 | bar_inactive_color: app.theme.scroller 50 | size_hint_x: None 51 | orientation: 'vertical' 52 | width: 0 if (not self.is_active and self.autohide) else app.scrollbar_scale 53 | opacity: 0 if (not self.is_active and self.autohide) else 1 54 | """) 55 | 56 | 57 | class Scroller(ScrollView): 58 | """Generic scroller container widget.""" 59 | pass 60 | 61 | 62 | class ScrollViewCentered(ScrollView): 63 | """Special ScrollView that begins centered""" 64 | 65 | def __init__(self, **kwargs): 66 | self.scroll_x = 0.5 67 | self.scroll_y = 0.5 68 | super(ScrollViewCentered, self).__init__(**kwargs) 69 | 70 | def window_to_parent(self, x, y, relative=False): 71 | return self.to_parent(*self.to_widget(x, y)) 72 | 73 | 74 | class ScrollWrapper(Scroller): 75 | """Special ScrollView that allows ScrollViews inside it to respond to touches. 76 | The internal ScrollViews must be added to the 'masks' list""" 77 | 78 | masks = ListProperty() 79 | 80 | def on_touch_down(self, touch): 81 | for mask in self.masks: 82 | coords = mask.to_parent(*mask.to_widget(*touch.pos)) 83 | collide = mask.collide_point(*coords) 84 | if collide: 85 | touch.apply_transform_2d(mask.to_widget) 86 | touch.apply_transform_2d(mask.to_parent) 87 | mask.on_touch_down(touch) 88 | return True 89 | super(ScrollWrapper, self).on_touch_down(touch) 90 | 91 | 92 | class ScrollBar(BoxLayout): 93 | """ 94 | Base class for a basic scrollbar widget that can control a set ScrollView. 95 | This class itself should not be used, use ScrollBarX or ScrollBarY for horizontal or vertical scrolling. 96 | The 'scroller' variable must be set to the ScrollView widget that should be controlled. 97 | 'bar_color' and 'bar_inactive_color' can be set to a rgba color. 98 | """ 99 | 100 | scroll = NumericProperty() 101 | scroller = ObjectProperty(allownone=True) 102 | scroller_size = ListProperty([0, 0]) 103 | rounding = NumericProperty(0) 104 | is_active = BooleanProperty(True) 105 | autohide = BooleanProperty(True) 106 | 107 | # borrow some functions and variables from ScrollView 108 | scroll_wheel_distance = NumericProperty('20sp') 109 | bar_color = ColorProperty([.7, .7, .7, .9]) 110 | bar_inactive_color = ColorProperty([.7, .7, .7, .2]) 111 | viewport_size = ListProperty([0, 0]) 112 | _bar_color = ListProperty([0, 0, 0, 0]) 113 | _bind_inactive_bar_color_ev = None 114 | _set_viewport_size = ScrollView._set_viewport_size 115 | _bind_inactive_bar_color = ScrollView._bind_inactive_bar_color 116 | _change_bar_color = ScrollView._change_bar_color 117 | 118 | def __init__(self, **kwargs): 119 | super().__init__(**kwargs) 120 | self.update_bar_color() 121 | 122 | def _set_scroller_size(self, instance, value): 123 | self.scroller_size = value 124 | 125 | def _set_scroll(self, instance, value): 126 | self.scroll = value 127 | 128 | def jump_bar(self, pos): 129 | # Placeholder for subclassed jump-to function, can scroll to a different location in the scrollbar without dragging 130 | pass 131 | 132 | def on_touch_down(self, touch): 133 | if not self.disabled and self.collide_point(*touch.pos): 134 | if 'button' in touch.profile and touch.button.startswith('scroll'): 135 | btn = touch.button 136 | return self.wheel_scroll(btn) 137 | 138 | self.jump_bar(touch.pos) 139 | touch.grab(self) 140 | self.do_touch_scroll(touch) 141 | return True 142 | 143 | def on_touch_move(self, touch): 144 | if touch.grab_current == self: 145 | self.do_touch_scroll(touch) 146 | 147 | def do_touch_scroll(self, touch): 148 | # Touch events should activate scrolling 149 | # Splitting this into its own function to make it easier to subclass 150 | pass 151 | 152 | def on_scroller(self, instance, value): 153 | # The scroller object has been set, create binds and set up local variables 154 | if value: 155 | value.bind(size=self._set_scroller_size) 156 | value.bind(viewport_size=self._set_viewport_size) 157 | self.scroller_size = value.size 158 | self.viewport_size = value.viewport_size 159 | 160 | def update_bar_color(self): 161 | # in original code, this was in update_from_scroll, but that extra code is not needed 162 | ev = self._bind_inactive_bar_color_ev 163 | if ev is None: 164 | ev = self._bind_inactive_bar_color_ev = Clock.create_trigger( 165 | self._bind_inactive_bar_color, .5) 166 | self.funbind('bar_inactive_color', self._change_bar_color) 167 | Animation.stop_all(self, '_bar_color') 168 | self.fbind('bar_color', self._change_bar_color) 169 | self._bar_color = self.bar_color 170 | ev() 171 | 172 | def do_wheel_scroll(self, direction, scroll_axis): 173 | scroll_up = ['scrollup', 'scrollright'] 174 | scroll_down = ['scrolldown', 'scrollleft'] 175 | if (direction in scroll_down and self.scroll >= 1) or (direction in scroll_up and self.scroll <= 0): 176 | return False 177 | 178 | if self.viewport_size[scroll_axis] > self.scroller_size[scroll_axis]: 179 | scroll_percent = self.scroll_wheel_distance / self.viewport_size[scroll_axis] 180 | if direction in scroll_up: 181 | new_scroll = self.scroll - scroll_percent 182 | elif direction in scroll_down: 183 | new_scroll = self.scroll + scroll_percent 184 | else: 185 | return False 186 | self.scroll = min(max(new_scroll, 0), 1) 187 | return True 188 | return False 189 | 190 | def wheel_scroll(self, direction): 191 | return False 192 | 193 | def _get_bar(self, axis, min_size): 194 | viewport_size = self.viewport_size[axis] 195 | scroller_size = self.scroller_size[axis] 196 | if viewport_size < scroller_size or viewport_size == 0: 197 | # not large enough to scroll 198 | return 0, 1. 199 | bar_length = max(min_size, scroller_size / float(viewport_size)) 200 | scroll = min(1.0, max(0.0, self.scroll)) 201 | bar_pos = (1. - bar_length) * scroll 202 | return bar_pos, bar_length 203 | 204 | def _get_vbar(self): 205 | if self.height > 0: 206 | min_height = self.width / self.height # prevent scroller size from being too small 207 | else: 208 | min_height = 0 209 | return self._get_bar(1, min_height) 210 | 211 | vbar = AliasProperty(_get_vbar, bind=('scroller_size', 'scroll', 'viewport_size', 'height'), cache=True) 212 | 213 | def _get_hbar(self): 214 | if self.width > 0: 215 | min_width = self.height / self.width # prevent scroller size from being too small 216 | else: 217 | min_width = 0 218 | return self._get_bar(0, min_width) 219 | 220 | hbar = AliasProperty(_get_hbar, bind=('scroller_size', 'scroll', 'viewport_size', 'width'), cache=True) 221 | 222 | def in_bar(self, click_pos, self_pos, self_size, bar): 223 | local_pos = click_pos - self_pos 224 | click_per = local_pos / self_size 225 | bar_top = bar[0] + bar[1] 226 | bar_bottom = bar[0] 227 | half_bar_height = bar[1] / 2 228 | if click_per > bar_top: 229 | return click_per - bar_top + half_bar_height 230 | elif click_per < bar_bottom: 231 | return click_per - bar_bottom - half_bar_height 232 | else: # bar_top > click_per > bar_bottom: 233 | return 0 234 | 235 | 236 | class ScrollBarX(ScrollBar): 237 | """Horizontal scrollbar widget. See 'ScrollBar' for more information.""" 238 | 239 | scroll = NumericProperty(0.) 240 | 241 | def jump_bar(self, pos): 242 | position = self.in_bar(pos[0], self.x, self.width, self.hbar) 243 | self.scroller.scroll_x += position 244 | 245 | def on_scroller(self, instance, value): 246 | super().on_scroller(instance, value) 247 | if value: 248 | value.bind(scroll_x=self._set_scroll) 249 | self.scroll = value.scroll_x 250 | 251 | def on_scroll(self, instance, value): 252 | if self.scroller is not None: 253 | self.scroller.scroll_x = value 254 | 255 | def do_touch_scroll(self, touch): 256 | self.update_bar_color() 257 | scroll_scale = (self.width - self.width * self.hbar[1]) 258 | if scroll_scale == 0: 259 | return 260 | scroll_amount = touch.dx / scroll_scale 261 | self.scroll = min(max(self.scroll + scroll_amount, 0.), 1.) 262 | 263 | def wheel_scroll(self, direction): 264 | return self.do_wheel_scroll(direction, 0) 265 | 266 | 267 | class ScrollBarY(ScrollBar): 268 | """Vertical scrollbar widget. See 'ScrollBar' for more information.""" 269 | 270 | scroll = NumericProperty(1.) 271 | 272 | def jump_bar(self, pos): 273 | position = self.in_bar(pos[1], self.y, self.height, self.vbar) 274 | self.scroller.scroll_y += position 275 | 276 | def on_scroller(self, instance, value): 277 | super().on_scroller(instance, value) 278 | if value: 279 | value.bind(scroll_y=self._set_scroll) 280 | self.scroll = value.scroll_y 281 | 282 | def on_scroll(self, instance, value): 283 | if self.scroller is not None: 284 | self.scroller.scroll_y = value 285 | 286 | def do_touch_scroll(self, touch): 287 | self.update_bar_color() 288 | scroll_scale = (self.height - self.height * self.vbar[1]) 289 | if scroll_scale == 0: 290 | return 291 | scroll_amount = touch.dy / scroll_scale 292 | self.scroll = min(max(self.scroll + scroll_amount, 0.), 1.) 293 | 294 | def wheel_scroll(self, direction): 295 | return self.do_wheel_scroll(direction, 1) 296 | 297 | 298 | class TouchScroller(ScrollView): 299 | """ 300 | Modified version of Kivy's ScrollView widget, removes scrollbars and allows for finer control over touch events. 301 | allow_middle_mouse: set this to True to enable scrolling with the middle mouse button (blocks middle mouse clicks on child widgets). 302 | allow_flick: set this to True to enable touch 'flicks' to scroll the view. 303 | allow_drag: Set this to True to enable click-n-drag scrolling within the scrollview itself. 304 | allow_wheel: set this to True to enable scrolling via the mouse wheel. 305 | masks: ListProperty, add any child widgets to this, and they will receive all touches on them, blocking any touch controlls of this widget within their bounds. 306 | """ 307 | 308 | bar_width = NumericProperty(0) 309 | scroll_distance = NumericProperty(10) 310 | scroll_timeout = NumericProperty(100) 311 | scroll_wheel_distance = NumericProperty('20sp') 312 | 313 | allow_middle_mouse = BooleanProperty(True) 314 | allow_flick = BooleanProperty(True) 315 | allow_drag = BooleanProperty(True) 316 | allow_wheel = BooleanProperty(True) 317 | masks = ListProperty() 318 | 319 | _touch_moves = 0 320 | _touch_delay = None 321 | _start_scroll_x = 0 322 | _start_scroll_y = 0 323 | 324 | def transformed_touch(self, touch, touch_type='down'): 325 | touch.push() 326 | touch.apply_transform_2d(self.to_local) 327 | #touch.apply_transform_2d(self.to_widget) 328 | if touch_type == 'down': 329 | ret = StencilView.on_touch_down(self, touch) 330 | elif touch_type == 'up': 331 | ret = StencilView.on_touch_up(self, touch) 332 | else: #touch_type == 'move' 333 | ret = StencilView.on_touch_move(self, touch) 334 | touch.pop() 335 | return ret 336 | 337 | def scroll_to_point(self, per_x, per_y, animate=True): 338 | sxp = min(1, max(0, per_x)) 339 | syp = min(1, max(0, per_y)) 340 | Animation.stop_all(self, 'scroll_x', 'scroll_y') 341 | if animate: 342 | if animate is True: 343 | animate = {'d': 0.2, 't': 'out_quad'} 344 | Animation(scroll_x=sxp, scroll_y=syp, **animate).start(self) 345 | else: 346 | self.scroll_x = sxp 347 | self.scroll_y = syp 348 | 349 | def scroll_by(self, per_x, per_y, animate=True): 350 | self.scroll_to_point(self.scroll_x + per_x, self.scroll_y + per_y, animate=animate) 351 | 352 | def on_touch_down(self, touch): 353 | if self.collide_point(*touch.pos): 354 | for widget in self.masks: 355 | touch.push() 356 | #touch.apply_transform_2d(self.to_local) 357 | touch.apply_transform_2d(self.to_widget) 358 | if widget.collide_point(*touch.pos): 359 | return widget.on_touch_down(touch) 360 | touch.pop() 361 | 362 | #delay touch to check if scroll is initiated 363 | if 'button' in touch.profile and touch.button.startswith('scroll'): 364 | if self.allow_wheel: 365 | touch.grab(self) 366 | btn = touch.button 367 | return self.wheel_scroll(btn) 368 | else: 369 | return self.transformed_touch(touch) 370 | 371 | touch.grab(self) 372 | self._touch_delay = None 373 | self._touch_moves = 0 374 | 375 | self._start_scroll_x = self.scroll_x 376 | self._start_scroll_y = self.scroll_y 377 | if self.allow_middle_mouse and 'button' in touch.profile and touch.button == 'middle': 378 | return True 379 | if self.allow_drag or self.allow_flick: 380 | self._touch_delay = Clock.schedule_once(partial(self._on_touch_down_delay, touch), (self.scroll_timeout / 1000)) 381 | else: 382 | return self.transformed_touch(touch) 383 | return True 384 | 385 | def on_touch_up(self, touch): 386 | if touch.grab_current == self: 387 | touch.ungrab(self) 388 | if self.allow_middle_mouse and 'button' in touch.profile and touch.button == 'middle': 389 | return True 390 | if self._touch_delay: 391 | self._touch_delay.cancel() 392 | self._touch_delay = None 393 | dx, dy = self.touch_moved_distance(touch) 394 | if self.allow_flick and (dx or dy): 395 | per_x = self.scroll_x - ((dx * 2) / self.width) 396 | per_y = self.scroll_y - ((dy * 2) / self.height) 397 | self.scroll_to_point(per_x, per_y) 398 | self._touch_delay = None 399 | return True 400 | else: 401 | self.transformed_touch(touch) 402 | return self.transformed_touch(touch, 'up') 403 | 404 | def _on_touch_down_delay(self, touch, *largs): 405 | self._touch_delay = None 406 | dx, dy = self.touch_moved_distance(touch) 407 | if self.allow_drag and (dx or dy): 408 | #user has satisfied the requirements for scrolling 409 | return True 410 | else: 411 | touch.ungrab(self) 412 | #Need to fix the touch position since it has been translated by this widget's position somehow... 413 | touch.push() 414 | touch.apply_transform_2d(self.to_widget) 415 | touch.apply_transform_2d(self.to_parent) 416 | return self.transformed_touch(touch) 417 | 418 | def on_touch_move(self, touch): 419 | middle_button = 'button' in touch.profile and touch.button == 'middle' 420 | if not self.allow_drag and not middle_button: 421 | return 422 | if self._touch_delay: 423 | always = False 424 | else: 425 | always = True 426 | if touch.grab_current == self: 427 | self._touch_moves += 1 428 | if self._touch_moves == 1 and not middle_button: 429 | animate = True 430 | else: 431 | animate = False 432 | dx, dy = self.touch_moved_distance(touch, always=always) 433 | if self.viewport_size[0] != self.width: 434 | per_x = self._start_scroll_x + (dx / (self.width - self.viewport_size[0])) 435 | else: 436 | per_x = self._start_scroll_x 437 | if self.viewport_size[1] != self.height: 438 | per_y = self._start_scroll_y + (dy / (self.height - self.viewport_size[1])) 439 | else: 440 | per_y = self._start_scroll_y 441 | self.scroll_to_point(per_x, per_y, animate=animate) 442 | 443 | def touch_moved_distance(self, touch, always=False): 444 | #determines if the touch has moved the required distance to allow for scrolling 445 | can_move_x = self.viewport_size[0] > self.width 446 | can_move_y = self.viewport_size[1] > self.height 447 | dx = touch.pos[0] - touch.opos[0] 448 | dy = touch.pos[1] - touch.opos[1] 449 | if can_move_x and (always or abs(dx) >= self.scroll_distance): 450 | pass 451 | else: 452 | dx = 0 453 | if can_move_y and (always or abs(dy) >= self.scroll_distance): 454 | pass 455 | else: 456 | dy = 0 457 | 458 | return dx, dy 459 | 460 | def wheel_scroll(self, btn): 461 | can_move_x = self.viewport_size[0] > self.width 462 | can_move_y = self.viewport_size[1] > self.height 463 | scroll_percent_x = self.scroll_wheel_distance / self.viewport_size[0] 464 | scroll_percent_y = self.scroll_wheel_distance / self.viewport_size[1] 465 | 466 | if can_move_x and can_move_y: 467 | if btn == 'scrollup': 468 | self.scroll_by(0, scroll_percent_y, animate=False) 469 | elif btn == 'scrolldown': 470 | self.scroll_by(0, 0 - scroll_percent_y, animate=False) 471 | elif btn == 'scrollleft': 472 | self.scroll_by(scroll_percent_x, 0, animate=False) 473 | elif btn == 'scrollright': 474 | self.scroll_by(0 - scroll_percent_x, 0, animate=False) 475 | elif can_move_x: 476 | if btn in ['scrolldown', 'scrollleft']: 477 | self.scroll_by(scroll_percent_x, 0, animate=False) 478 | elif btn in ['scrollup', 'scrollright']: 479 | self.scroll_by(0 - scroll_percent_x, 0, animate=False) 480 | elif can_move_y: 481 | if btn in ['scrolldown', 'scrollright']: 482 | self.scroll_by(0, scroll_percent_y, animate=False) 483 | elif btn in ['scrollup', 'scrollleft']: 484 | self.scroll_by(0, 0 - scroll_percent_y, animate=False) 485 | return True 486 | -------------------------------------------------------------------------------- /snu/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from kivy.app import App 3 | from kivy.uix.settings import SettingsWithNoMenu, SettingTitle 4 | from kivy.uix.settings import SettingItem as SettingItemOriginal 5 | from kivy.properties import ObjectProperty, StringProperty, ListProperty, BooleanProperty, NumericProperty 6 | from kivy.uix.popup import Popup 7 | from kivy.uix.boxlayout import BoxLayout 8 | from kivy.compat import text_type 9 | from .scrollview import Scroller 10 | from .button import WideButton, WideToggle 11 | from .popup import NormalPopup, InputPopupContent 12 | from .filebrowser import FileBrowser 13 | from .navigation import Navigation 14 | 15 | from kivy.lang.builder import Builder 16 | Builder.load_string(""" 17 | <-SettingsPanel>: 18 | spacing: 5 19 | size_hint_y: None 20 | height: self.minimum_height 21 | 22 | <-Settings>: 23 | canvas.before: 24 | Color: 25 | rgba: app.theme.background 26 | 27 | Rectangle: 28 | size: root.size 29 | pos: root.pos 30 | Color: 31 | rgba: app.theme.main_background 32 | Rectangle: 33 | size: root.size 34 | pos: root.pos 35 | source: 'data/mainbg.png' 36 | orientation: 'vertical' 37 | Header: 38 | HeaderLabel: 39 | text: "Settings" 40 | NormalButton: 41 | text: 'Close' 42 | on_release: app.close_settings() 43 | 44 | <-SettingItem>: 45 | label_size_hint_x: 0.66 46 | size_hint: .25, None 47 | height: labellayout.texture_size[1] + dp(10) 48 | content: content 49 | 50 | BoxLayout: 51 | pos: root.pos 52 | Widget: 53 | size_hint_x: .2 54 | BoxLayout: 55 | canvas: 56 | Color: 57 | rgba: 47 / 255., 167 / 255., 212 / 255., root.selected_alpha 58 | Rectangle: 59 | pos: self.x, self.y + 1 60 | size: self.size 61 | Color: 62 | rgb: .2, .2, .2 63 | Rectangle: 64 | pos: self.x, self.y - 2 65 | size: self.width, 1 66 | Label: 67 | size_hint_x: max(root.label_size_hint_x, 0.001) 68 | id: labellayout 69 | markup: True 70 | text: u"{0}\\n[size=13sp]{1}[/size]".format(root.title or "", root.desc or "") 71 | font_size: '15sp' 72 | text_size: self.width - 32, None 73 | color: app.theme.text 74 | BoxLayout: 75 | id: content 76 | size_hint_x: 1 - root.label_size_hint_x 77 | Widget: 78 | size_hint_x: .2 79 | 80 | : 81 | label_size_hint_x: 0 82 | WideButton: 83 | text: "About This App" 84 | size: root.size 85 | pos: root.pos 86 | font_size: '15sp' 87 | on_release: app.about() 88 | 89 | : 90 | background_color: app.theme.menu_background 91 | background: 'data/panelbg.png' 92 | separator_color: 1, 1, 1, .25 93 | title_size: app.text_scale * 1.25 94 | title_color: app.theme.header_text 95 | title: app.about_title 96 | size_hint: app.popup_size_hint_x, None 97 | height: app.button_scale * 5 98 | BoxLayout: 99 | orientation: 'vertical' 100 | Scroller: 101 | do_scroll_x: False 102 | NormalLabel: 103 | text_size: self.width, None 104 | size_hint_y: None 105 | height: self.texture_size[1] + 20 106 | text: app.about_text 107 | WideButton: 108 | id: button 109 | text: root.button_text 110 | on_release: root.close() 111 | 112 | <-SettingTitle>: 113 | size_hint_y: None 114 | height: max(dp(20), self.texture_size[1] + dp(40)) 115 | color: (.9, .9, .9, 1) 116 | font_size: '15sp' 117 | canvas: 118 | Color: 119 | rgb: .2, .2, .2 120 | Rectangle: 121 | pos: self.x, self.y - 2 122 | size: self.width, 1 123 | Label: 124 | padding: app.button_scale, 0 125 | size_hint: None, None 126 | size: root.size 127 | color: app.theme.text 128 | text: root.title 129 | text_size: self.size 130 | halign: 'left' 131 | valign: 'bottom' 132 | pos: root.pos 133 | font_size: '15sp' 134 | 135 | : 136 | Label: 137 | text: root.value or '' 138 | pos: root.pos 139 | font_size: '15sp' 140 | color: app.theme.text 141 | 142 | : 143 | Label: 144 | text: root.value or '' 145 | pos: root.pos 146 | font_size: '15sp' 147 | color: app.theme.text 148 | 149 | : 150 | Label: 151 | text: root.value or '' 152 | pos: root.pos 153 | font_size: '15sp' 154 | color: app.theme.text 155 | 156 | : 157 | true_text: 'On' 158 | false_text: 'Off' 159 | NormalToggle: 160 | size_hint_x: 1 161 | state: 'normal' if root.value == '0' else 'down' 162 | on_press: root.value = '0' if self.state == 'normal' else '1' 163 | text: root.true_text if root.value == '1' else root.false_text 164 | """) 165 | 166 | 167 | class SettingItem(SettingItemOriginal): 168 | label_size_hint_x = NumericProperty(0.66) 169 | 170 | 171 | class SettingOptions(SettingItem, Navigation): 172 | """Options value in the settings screen. Customizes the input popup""" 173 | 174 | options = ListProperty([]) 175 | popup = ObjectProperty(None, allownone=True) 176 | 177 | def on_navigation_activate(self): 178 | self._create_popup(self) 179 | 180 | def on_panel(self, instance, value): 181 | if value is None: 182 | return 183 | self.fbind('on_release', self._create_popup) 184 | 185 | def _dismiss(self, *_): 186 | app = App.get_running_app() 187 | if app.popup: 188 | app.popup.dismiss() 189 | 190 | def _set_option(self, instance): 191 | self.value = instance.text 192 | self._dismiss() 193 | 194 | def _create_popup(self, instance): 195 | app = App.get_running_app() 196 | if app.popup: 197 | app.popup.dismiss() 198 | content = BoxLayout(orientation='vertical') 199 | scroller = Scroller() 200 | content.add_widget(scroller) 201 | options_holder = BoxLayout(orientation='vertical', size_hint_y=None, height=len(self.options) * app.button_scale) 202 | for option in self.options: 203 | button = WideToggle(text=option) 204 | if self.value == option: 205 | button.state = 'down' 206 | options_holder.add_widget(button) 207 | button.bind(on_release=self._set_option) 208 | scroller.add_widget(options_holder) 209 | cancel_button = WideButton(text='Cancel') 210 | cancel_button.bind(on_release=self._dismiss) 211 | content.add_widget(cancel_button) 212 | max_height = app.root.height - (app.button_scale * 3) 213 | height = min((len(self.options) + 3) * app.button_scale, max_height) 214 | app.popup = NormalPopup(title=self.title, content=content, size_hint=(None, None), size=(app.popup_x, height)) 215 | app.popup.open() 216 | 217 | 218 | class SettingPath(SettingItem, Navigation): 219 | """Path value in the settings screen. Customizes the input popup""" 220 | 221 | popup = ObjectProperty(None, allownone=True) 222 | show_hidden = BooleanProperty(True) 223 | dirselect = BooleanProperty(True) 224 | 225 | def on_navigation_activate(self): 226 | self._create_popup(self) 227 | 228 | def on_panel(self, instance, value): 229 | if value is None: 230 | return 231 | self.fbind('on_release', self._create_popup) 232 | 233 | def _dismiss(self, *largs): 234 | app = App.get_running_app() 235 | if app.popup: 236 | app.popup.dismiss() 237 | app.popup = None 238 | 239 | def _validate(self, browser): 240 | value = browser.selected[0] 241 | self._dismiss() 242 | if not value: 243 | return 244 | self.value = os.path.realpath(value) 245 | 246 | def _create_popup(self, instance): 247 | app = App.get_running_app() 248 | if app.popup: 249 | app.popup.dismiss() 250 | 251 | if self.value: 252 | if not self.dirselect: 253 | initial_path, selected = os.path.split(self.value) 254 | else: 255 | initial_path = self.value 256 | selected = '' 257 | else: 258 | initial_path = os.getcwd() 259 | selected = '' 260 | content = FileBrowser(folder=initial_path, selected=[selected], show_hidden=self.show_hidden, file_select=not self.dirselect, folder_select=self.dirselect, show_files=not self.dirselect) 261 | content.bind(on_select=self._validate) 262 | content.bind(on_cancel=self._dismiss) 263 | app.popup = NormalPopup(title=self.title, content=content, size_hint=(0.9, 0.9)) 264 | app.popup.open() 265 | 266 | 267 | class SettingString(SettingItem, Navigation): 268 | """String value in the settings screen. Customizes the input popup""" 269 | 270 | popup = ObjectProperty(None, allownone=True) 271 | textinput = ObjectProperty(None) 272 | 273 | def on_navigation_activate(self): 274 | self._create_popup(self) 275 | 276 | def on_panel(self, instance, value): 277 | if value is None: 278 | return 279 | self.fbind('on_release', self._create_popup) 280 | 281 | def dismiss(self, *largs): 282 | if self.popup: 283 | self.popup.dismiss() 284 | app = App.get_running_app() 285 | if app.popup: 286 | app.popup = None 287 | self.popup = None 288 | 289 | def _validate(self, instance, answer): 290 | value = self.popup.content.ids['input'].text.strip() 291 | self.dismiss() 292 | if answer == 'yes': 293 | self.value = value 294 | 295 | def _create_popup(self, instance): 296 | content = InputPopupContent(text='', input_text=self.value) 297 | app = App.get_running_app() 298 | content.bind(on_answer=self._validate) 299 | self.popup = NormalPopup(title=self.title, content=content, size_hint=(None, None), size=(app.popup_x, app.button_scale * 3.5), auto_dismiss=True) 300 | app.popup = self.popup 301 | self.popup.open() 302 | 303 | 304 | class SettingNumeric(SettingString): 305 | def _validate(self, instance, answer): 306 | # we know the type just by checking if there is a '.' in the original value 307 | is_float = '.' in str(self.value) 308 | value = self.popup.content.ids['input'].text 309 | self.dismiss() 310 | if answer == 'yes': 311 | try: 312 | if is_float: 313 | self.value = text_type(float(value)) 314 | else: 315 | self.value = text_type(int(value)) 316 | except ValueError: 317 | return 318 | 319 | 320 | class AppSettings(SettingsWithNoMenu): 321 | """Expanded settings class to add new settings buttons and types.""" 322 | 323 | def __init__(self, **kwargs): 324 | super(AppSettings, self).__init__(**kwargs) 325 | self.register_type('string', SettingString) 326 | self.register_type('options', SettingOptions) 327 | self.register_type('title', SettingTitle) 328 | self.register_type('path', SettingPath) 329 | self.register_type('numeric', SettingNumeric) 330 | self.register_type('aboutbutton', SettingAboutButton) 331 | 332 | 333 | class SettingAboutButton(SettingItem): 334 | """Settings widget that opens an about dialog.""" 335 | pass 336 | 337 | 338 | class AboutPopup(Popup): 339 | """Basic popup message with a message and 'ok' button.""" 340 | 341 | button_text = StringProperty('OK') 342 | 343 | def close(self, *_): 344 | app = App.get_running_app() 345 | app.popup.dismiss() 346 | -------------------------------------------------------------------------------- /snu/slider.py: -------------------------------------------------------------------------------- 1 | from kivy.uix.slider import Slider 2 | from kivy.clock import Clock 3 | from .navigation import Navigation 4 | from kivy.lang.builder import Builder 5 | Builder.load_string(""" 6 | <-NormalSlider>: 7 | #:set sizing 18 8 | canvas: 9 | Color: 10 | rgba: app.theme.slider_background 11 | BorderImage: 12 | border: (0, sizing, 0, sizing) 13 | pos: self.pos 14 | size: self.size 15 | source: 'data/sliderbg.png' 16 | Color: 17 | rgba: app.theme.slider_grabber 18 | Rectangle: 19 | pos: (self.value_pos[0] - app.button_scale/4, self.center_y - app.button_scale/2) 20 | size: app.button_scale/2, app.button_scale 21 | source: 'data/buttonflat.png' 22 | size_hint_y: None 23 | height: app.button_scale 24 | min: -1 25 | max: 1 26 | value: 0 27 | """) 28 | 29 | 30 | class SpecialSlider(Slider): 31 | """Slider that adds a 'reset_value' function that is called on double-click. 32 | This function does not do anything by default, the user must bind a function to this function. 33 | 34 | Example in kvlang: 35 | SpecialSlider: 36 | reset_value: root.reset_function 37 | """ 38 | 39 | def on_touch_down(self, touch): 40 | if self.collide_point(*touch.pos) and touch.is_double_tap: 41 | Clock.schedule_once(self.reset_value, 0.15) #Need to delay this longer than self.scroll_timeout, or it wont work right... 42 | self.reset_value() 43 | return 44 | super(SpecialSlider, self).on_touch_down(touch) 45 | 46 | def reset_value(self, *_): 47 | pass 48 | 49 | 50 | class NormalSlider(SpecialSlider, Navigation): 51 | def on_navigation_decrease(self): 52 | new_value = self.value - .1 53 | if new_value < self.min: 54 | self.value = self.min 55 | else: 56 | self.value = new_value 57 | 58 | def on_navigation_increase(self): 59 | new_value = self.value + .1 60 | if new_value > self.max: 61 | self.value = self.max 62 | else: 63 | self.value = new_value 64 | -------------------------------------------------------------------------------- /snu/smoothsetting.py: -------------------------------------------------------------------------------- 1 | from kivy.animation import Animation 2 | from kivy.clock import Clock 3 | from kivy.properties import ListProperty, NumericProperty, ObjectProperty, StringProperty 4 | from kivy.uix.scrollview import ScrollView 5 | from kivy.uix.image import Image 6 | from kivy.uix.label import Label 7 | from kivy.uix.floatlayout import FloatLayout 8 | from .navigation import Navigation 9 | from .roulettescroll import RouletteScrollEffect 10 | from kivy.lang.builder import Builder 11 | Builder.load_string(""" 12 | : 13 | fit_mode: 'fill' 14 | size_hint_x: None 15 | 16 | : 17 | opacity: 0.25 18 | size_hint_x: None 19 | color: app.theme.button_text 20 | font_size: app.text_scale 21 | 22 | : 23 | canvas.before: 24 | Color: 25 | rgba: app.theme.slider_background 26 | RoundedRectangle: 27 | radius: [root.rounding] 28 | size: self.size 29 | pos: self.pos 30 | Color: 31 | rgba: 0, 0, 0, .1 32 | Line: 33 | width: 2 34 | rounded_rectangle: (self.x, self.y, self.width, self.height, root.rounding) 35 | orientation: 'horizontal' 36 | active: scrollerArea.active 37 | BoxLayout: 38 | canvas.before: 39 | Color: 40 | rgba: 1, 1, 1, root.gradient_transparency 41 | RoundedRectangle: 42 | radius: [root.rounding, 0.001, 0.001, root.rounding] 43 | pos: self.pos 44 | size: self.size 45 | source: 'data/gradient-left.png' 46 | pos: root.pos 47 | size_hint_x: None 48 | width: (root.width - scrollerArea.item_width) / 2 49 | BoxLayout: 50 | canvas.before: 51 | Color: 52 | rgba: 1, 1, 1, root.gradient_transparency 53 | RoundedRectangle: 54 | radius: [0.001, root.rounding, root.rounding, 0.001] 55 | pos: self.pos 56 | size: self.size 57 | source: 'data/gradient-right.png' 58 | pos: root.pos[0] + ((root.width + scrollerArea.item_width) / 2), root.pos[1] 59 | size_hint_x: None 60 | width: (root.width - scrollerArea.item_width) / 2 61 | 62 | SmoothSettingScroller: 63 | size_hint_x: None 64 | width: root.width - controlLeft.width - controlRight.width 65 | id: scrollerArea 66 | pos: (root.pos[0] + controlLeft.width, root.pos[1]) 67 | content: root.content 68 | item_width: root.item_width if root.item_width is not None else root.height 69 | start_on: root.start_on 70 | scroll_distance: 1 71 | scroll_timeout: 10000 72 | bar_width: 0 73 | scroll_type: ['content'] 74 | do_scroll_x: True 75 | do_scroll_y: False 76 | BoxLayout: 77 | id: fillArea 78 | padding: (self.parent.width - scrollerArea.item_width) / 2, 0 79 | size_hint_x: None 80 | width: self.parent.width + (scrollerArea.item_width * (len(self.parent.content) - 1)) 81 | 82 | SmoothSettingControl: 83 | id: controlLeft 84 | color: app.theme.button_text 85 | repeat_length: root.repeat_length 86 | repeat_minimum: root.repeat_minimum 87 | scroller: scrollerArea 88 | direction: 'left' 89 | source: root.left_image 90 | width: root.control_width if root.control_width is not None else root.height 91 | opacity: 0 if self.width == 0 else 1 92 | disabled: True if self.width == 0 else False 93 | pos: root.pos 94 | 95 | SmoothSettingControl: 96 | id: controlRight 97 | color: app.theme.button_text 98 | repeat_length: root.repeat_length 99 | repeat_minimum: root.repeat_minimum 100 | scroller: scrollerArea 101 | direction: 'right' 102 | source: root.right_image 103 | width: root.control_width if root.control_width is not None else root.height 104 | opacity: 0 if self.width == 0 else 1 105 | disabled: True if self.width == 0 else False 106 | pos: root.pos[0] + root.width - self.width, root.pos[1] 107 | """) 108 | 109 | 110 | class SmoothSettingButton(Label): 111 | pass 112 | 113 | 114 | class SmoothSettingControl(Image): 115 | scroller = ObjectProperty() 116 | direction = StringProperty('left') 117 | repeater = ObjectProperty(allownone=True) 118 | repeat_length = NumericProperty(1) 119 | repeat_minimum = NumericProperty(0.1) 120 | repeat_length_current = 1 121 | 122 | def on_touch_down(self, touch): 123 | if self.disabled: 124 | return 125 | if self.collide_point(*touch.pos): 126 | self.repeat_length_current = self.repeat_length 127 | touch.grab(self) 128 | self.scroll_repeat() 129 | 130 | def scroll_repeat(self, *_): 131 | if self.repeat_length_current > self.repeat_minimum: 132 | self.repeat_length_current = self.repeat_length_current / 2 133 | self.scroll_segment() 134 | self.repeater = Clock.schedule_once(self.scroll_repeat, self.repeat_length_current) 135 | 136 | def scroll_segment(self, *_): 137 | if self.direction == 'left': 138 | self.scroller.scroll_left() 139 | else: 140 | self.scroller.scroll_right() 141 | 142 | def on_touch_up(self, touch): 143 | if touch.grab_current is self: 144 | self.repeater.cancel() 145 | touch.ungrab(self) 146 | 147 | 148 | class SmoothSetting(FloatLayout, Navigation): 149 | rounding = NumericProperty(10) 150 | gradient_transparency = NumericProperty(0.5) 151 | content = ListProperty() 152 | repeat_length = NumericProperty(1) 153 | repeat_minimum = NumericProperty(0.1) 154 | left_image = StringProperty('data/left.png') 155 | right_image = StringProperty('data/right.png') 156 | control_width = NumericProperty(None) 157 | item_width = NumericProperty(None) 158 | start_on = NumericProperty(0) 159 | active = NumericProperty(0) 160 | 161 | def scroll_to_element(self, index, instant=False): 162 | Clock.schedule_once(lambda x: self.ids.scrollerArea.scroll_to_element(index, instant=instant)) 163 | 164 | def on_navigation_decrease(self): 165 | self.ids.scrollerArea.scroll_left() 166 | 167 | def on_navigation_increase(self): 168 | self.ids.scrollerArea.scroll_right() 169 | 170 | def on_active(self, *_): 171 | pass 172 | 173 | 174 | class SmoothSettingScroller(ScrollView): 175 | content = ListProperty() 176 | not_selected = NumericProperty(0.25) 177 | active = NumericProperty(0) 178 | scroll_anim = ObjectProperty(allownone=True) 179 | item_width = NumericProperty(40) 180 | start_on = NumericProperty(0) 181 | 182 | def cancel_anim(self): 183 | if self.scroll_anim is not None: 184 | self.scroll_anim.stop(self) 185 | self.scroll_anim = None 186 | 187 | def scroll_left(self): 188 | self.scroll_to_element(self.active - 1) 189 | 190 | def scroll_right(self): 191 | self.scroll_to_element(self.active + 1) 192 | 193 | def scroll_to_element(self, index, instant=False): 194 | divisors = len(self.content) - 1 195 | self.cancel_anim() 196 | scroll_to_x = index / divisors 197 | if scroll_to_x < 0: 198 | scroll_to_x = 0 199 | elif scroll_to_x > 1: 200 | scroll_to_x = 1 201 | if instant: 202 | self.scroll_x = scroll_to_x 203 | else: 204 | self.scroll_anim = Animation(scroll_x=scroll_to_x, duration=0.1) 205 | self.scroll_anim.start(self) 206 | 207 | def on_content(self, *_): 208 | self.populate_buttons() 209 | self.scroll_to_element(self.start_on, instant=True) 210 | self.on_active() 211 | 212 | def populate_buttons(self): 213 | fill_area = self.children[0] 214 | fill_area.clear_widgets() 215 | for element in self.content: 216 | button = SmoothSettingButton(text=element, width=self.item_width) 217 | self.bind(item_width=button.setter('width')) 218 | fill_area.add_widget(button) 219 | 220 | def on_item_width(self, *_): 221 | self.set_scroll_effect() 222 | 223 | def on_parent(self, *_): 224 | self.set_scroll_effect() 225 | 226 | def set_scroll_effect(self): 227 | self.effect_x = RouletteScrollEffect(anchor=self.item_width, interval=self.item_width) 228 | 229 | def on_scroll_x(self, *_): 230 | divisors = len(self.content) - 1 231 | self.active = round(self.scroll_x * divisors) 232 | 233 | def on_active(self, *_): 234 | divisors = len(self.content) 235 | for index, child in enumerate(self.children[0].children): 236 | if (divisors - index - 1) != self.active: 237 | child.opacity = self.not_selected 238 | else: 239 | child.opacity = 1 240 | -------------------------------------------------------------------------------- /snu/songplayer.py: -------------------------------------------------------------------------------- 1 | import os 2 | from kivy.properties import ObjectProperty, StringProperty, BooleanProperty, NumericProperty, OptionProperty 3 | from kivy.uix.gridlayout import GridLayout 4 | from kivy.uix.progressbar import ProgressBar 5 | from kivy.uix.image import Image 6 | from kivy.clock import Clock 7 | from kivy.core.audio import SoundLoader 8 | from kivy.lang.builder import Builder 9 | Builder.load_string(""" 10 | : 11 | canvas.after: 12 | Color: 13 | rgba: 0, 0, 0, .5 if self.disabled else 0 14 | Rectangle: 15 | size: self.size 16 | pos: self.pos 17 | rows: 1 18 | size_hint_y: None 19 | height: '44dp' 20 | disabled: not root._song 21 | 22 | SongPlayerStop: 23 | size_hint_x: None 24 | song: root 25 | width: '44dp' 26 | source: root.image_stop 27 | fit_mode: 'contain' 28 | 29 | SongPlayerPlayPause: 30 | size_hint_x: None 31 | song: root 32 | width: '44dp' 33 | source: root.image_pause if root.state == 'play' else root.image_play 34 | fit_mode: 'contain' 35 | 36 | SongPlayerVolume: 37 | song: root 38 | size_hint_x: None 39 | width: '44dp' 40 | source: root.image_volumehigh if root.volume > 0.8 else (root.image_volumemedium if root.volume > 0.4 else (root.image_volumelow if root.volume > 0 else root.image_volumemuted)) 41 | fit_mode: 'contain' 42 | 43 | Widget: 44 | size_hint_x: None 45 | width: 5 46 | 47 | SongPlayerProgressBar: 48 | song: root 49 | max: 1 50 | value: root.position 51 | 52 | Widget: 53 | size_hint_x: None 54 | width: 10 55 | """) 56 | 57 | 58 | class SongPlayerVolume(Image): 59 | song = ObjectProperty(None) 60 | 61 | def on_touch_down(self, touch): 62 | if not self.collide_point(*touch.pos): 63 | return False 64 | touch.grab(self) 65 | # save the current volume and delta to it 66 | touch.ud[self.uid] = [self.song.volume, 0] 67 | return True 68 | 69 | def on_touch_move(self, touch): 70 | if touch.grab_current is not self: 71 | return 72 | # calculate delta 73 | dy = abs(touch.y - touch.oy) 74 | if dy > 10: 75 | dy = min(dy - 10, 100) 76 | touch.ud[self.uid][1] = dy 77 | self.song.volume = dy / 100. 78 | return True 79 | 80 | def on_touch_up(self, touch): 81 | if touch.grab_current is not self: 82 | return 83 | touch.ungrab(self) 84 | dy = abs(touch.y - touch.oy) 85 | if dy < 10: 86 | if self.song.volume > 0: 87 | self.song.volume = 0 88 | else: 89 | self.song.volume = 1. 90 | 91 | 92 | class SongPlayerPlayPause(Image): 93 | song = ObjectProperty(None) 94 | 95 | def on_touch_down(self, touch): 96 | if self.collide_point(*touch.pos): 97 | if self.song.state == 'play': 98 | self.song.state = 'pause' 99 | else: 100 | self.song.state = 'play' 101 | return True 102 | 103 | 104 | class SongPlayerStop(Image): 105 | song = ObjectProperty(None) 106 | 107 | def on_touch_down(self, touch): 108 | if self.collide_point(*touch.pos): 109 | self.song.state = 'stop' 110 | return True 111 | 112 | 113 | class SongPlayerProgressBar(ProgressBar): 114 | song = ObjectProperty(None) 115 | seek = NumericProperty(None, allownone=True) 116 | scrub = BooleanProperty(True) 117 | 118 | def on_touch_down(self, touch): 119 | if not self.collide_point(*touch.pos): 120 | return 121 | touch.grab(self) 122 | self._update_seek(touch.x) 123 | if self.song.state != 'play': 124 | self.song.state = 'play' 125 | self.song.seek(self.seek) 126 | self.seek = None 127 | return True 128 | 129 | def on_touch_move(self, touch): 130 | if touch.grab_current is not self: 131 | return 132 | if self.scrub: 133 | self._update_seek(touch.x) 134 | self.song.seek(self.seek) 135 | self.seek = None 136 | return True 137 | 138 | def on_touch_up(self, touch): 139 | if touch.grab_current is not self: 140 | return 141 | touch.ungrab(self) 142 | if self.seek is not None: 143 | self.song.seek(self.seek) 144 | self.seek = None 145 | return True 146 | 147 | def _update_seek(self, x): 148 | if self.width == 0: 149 | return 150 | x = max(self.x, min(self.right, x)) - self.x 151 | self.seek = x / float(self.width) 152 | 153 | 154 | class SongPlayer(GridLayout): 155 | source = StringProperty('') 156 | duration = NumericProperty(-1) 157 | position = NumericProperty(0) 158 | volume = NumericProperty(1.0) 159 | state = OptionProperty('stop', options=('play', 'pause', 'stop')) 160 | image_play = StringProperty('atlas://data/images/defaulttheme/media-playback-start') 161 | image_stop = StringProperty('atlas://data/images/defaulttheme/media-playback-stop') 162 | image_pause = StringProperty('atlas://data/images/defaulttheme/media-playback-pause') 163 | image_volumehigh = StringProperty('atlas://data/images/defaulttheme/audio-volume-high') 164 | image_volumemedium = StringProperty('atlas://data/images/defaulttheme/audio-volume-medium') 165 | image_volumelow = StringProperty('atlas://data/images/defaulttheme/audio-volume-low') 166 | image_volumemuted = StringProperty('atlas://data/images/defaulttheme/audio-volume-muted') 167 | _song = ObjectProperty(allownone=True) 168 | 169 | def __init__(self, **kwargs): 170 | self._song = None 171 | super(SongPlayer, self).__init__(**kwargs) 172 | 173 | def on_source(self, instance, value): 174 | if self._song is not None: 175 | self._song.unload() 176 | self._song = None 177 | if os.path.exists(self.source): 178 | self._song = SoundLoader.load(self.source) 179 | if self._song is None: 180 | return 181 | self._song.volume = self.volume 182 | self._song.state = self.state 183 | self.duration = self._song.length 184 | 185 | def _update_position(self, *_): 186 | if self._song is None: 187 | return 188 | if self.state == 'play': 189 | self.position = (self._song.get_pos() / self.duration) 190 | Clock.schedule_once(self._update_position) 191 | 192 | def on_state(self, instance, value): 193 | if self._song is not None: 194 | if value == 'play': 195 | self._song.play() 196 | self.seek(self.position) 197 | self._update_position() 198 | elif value == 'pause': 199 | self._song.stop() 200 | else: 201 | self._song.stop() 202 | self.position = 0 203 | 204 | def on_volume(self, instance, value): 205 | if not self._song: 206 | return 207 | self._song.volume = value 208 | 209 | def seek(self, percent): 210 | if not self._song: 211 | return 212 | self._song.seek(percent * self.duration) 213 | -------------------------------------------------------------------------------- /snu/stencilview.py: -------------------------------------------------------------------------------- 1 | from kivy.uix.stencilview import StencilView 2 | 3 | 4 | class StencilViewTouch(StencilView): 5 | """Custom StencilView that stencils touches as well as visual elements.""" 6 | 7 | def on_touch_down(self, touch): 8 | """Modified to only register touch down events when inside stencil area.""" 9 | if self.collide_point(*touch.pos): 10 | super(StencilViewTouch, self).on_touch_down(touch) 11 | -------------------------------------------------------------------------------- /snu/textinput.py: -------------------------------------------------------------------------------- 1 | import re 2 | from kivy.app import App 3 | from kivy.animation import Animation 4 | from kivy.core.text import Label as CoreLabel 5 | from kivy.uix.textinput import TextInput 6 | from kivy.clock import Clock 7 | from kivy.properties import NumericProperty, ObjectProperty, BooleanProperty, StringProperty, ColorProperty, ListProperty, AliasProperty 8 | from kivy.uix.bubble import Bubble 9 | from .navigation import Navigation 10 | from kivy.lang.builder import Builder 11 | Builder.load_string(""" 12 | <-NormalInput>: 13 | canvas.before: 14 | Color: 15 | rgba: self._current_background_color 16 | RoundedRectangle: 17 | pos: self.x + self.background_padding, self.y + self.background_padding 18 | size: self.width - (self.background_padding * 2), self.height - (self.background_padding * 2) 19 | radius: [self.rounded] 20 | Color: 21 | rgba: self.background_border_color 22 | Line: 23 | width: self.background_border_width 24 | rounded_rectangle: (self.x + self.background_padding, self.y + self.background_padding, self.width - (self.background_padding * 2), self.height - (self.background_padding * 2), self.rounded) 25 | Color: 26 | rgba: (self.cursor_color if self.focus and not self._cursor_blink and int(self.x + self.padding[0]) <= self._cursor_visual_pos[0] <= int(self.x + self.width - self.padding[2]) else (0, 0, 0, 0)) 27 | Rectangle: 28 | pos: self._cursor_visual_pos 29 | size: root.cursor_width, -self._cursor_visual_height 30 | Color: 31 | rgba: self.disabled_foreground_color if self.disabled else ((0, 0, 0, 0) if not self.text else self.foreground_color) 32 | canvas.after: 33 | Color: 34 | rgba: self.cursor_color if root._activated else (0, 0, 0, 0) 35 | Rectangle: #underline 36 | pos: self.x + self.background_padding + (self.rounded / 2) + (self.width * self.underline_pos * (1 - self._activated)), self.y + self.background_padding 37 | size: (self.width - self.rounded - (self.background_padding * 2)) * self._activated, self._underline_size 38 | Color: 39 | rgba: self.hint_text_color if root.animate_hint or not self.text else (0, 0, 0, 0) 40 | Rectangle: #hint text 41 | size: self._hint_label_size 42 | pos: self.x + self.padding[0], self.y + self.height - self._hint_label_size[1] - (self.padding[1] * (1 - (self._activated_hint / 2))) 43 | texture: self._hint_label_texture if self._hint_label_texture else None 44 | _underline_size: max(1, app.button_scale / 10) 45 | padding: max(app.button_scale / 8, self.rounded) + self.background_padding, (self._hint_max_size * 0.3 * self.animate_hint) + (app.button_scale / 8) + self.background_padding, max(app.button_scale / 8, self.rounded), app.button_scale / 8 46 | mipmap: True 47 | cursor_color: app.theme.text 48 | write_tab: False 49 | background_color: app.theme.input_background[:3]+[0.333] 50 | background_color_active: app.theme.input_background 51 | background_border_color: app.theme.text[:3]+[0.5] 52 | hint_text_color: app.theme.disabled_text 53 | disabled_foreground_color: app.theme.text[:3]+[.75] 54 | foreground_color: app.theme.text 55 | size_hint_y: None 56 | height: app.button_scale 57 | font_size: app.text_scale 58 | 59 | : 60 | multiline: False 61 | write_tab: False 62 | 63 | : 64 | multiline: False 65 | write_tab: False 66 | 67 | : 68 | canvas.before: 69 | Color: 70 | rgba: app.theme.menu_background 71 | BorderImage: 72 | display_border: [app.display_border, app.display_border, app.display_border, app.display_border] 73 | size: self.size 74 | pos: self.pos 75 | source: 'data/buttonflat.png' 76 | background_image: 'data/transparent.png' 77 | size_hint: None, None 78 | size: app.button_scale * 9, app.button_scale 79 | show_arrow: False 80 | BoxLayout: 81 | MenuButton: 82 | text: 'Select All' 83 | on_release: root.select_all() 84 | MenuButton: 85 | text: 'Cut' 86 | on_release: root.cut() 87 | MenuButton: 88 | text: 'Copy' 89 | on_release: root.copy() 90 | MenuButton: 91 | text: 'Paste' 92 | on_release: root.paste() 93 | """) 94 | 95 | 96 | class NormalInput(TextInput, Navigation): 97 | """Text input widget that adds a popup menu for normal text operations.""" 98 | 99 | background_padding = NumericProperty(4) 100 | hint_text = StringProperty("Enter Text") 101 | underline_pos = NumericProperty(0.5) #Horizontal position (percent) from where the underline will grow from 102 | activate_time = NumericProperty(0.2) #Time in seconds for the animate in 103 | deactivate_time = NumericProperty(0.2) #Time in seconds for the animate out 104 | background_color = ColorProperty((1, 1, 1, 0.5)) 105 | background_color_active = ColorProperty((1, 1, 1, 1)) #Color that the background will fade to when the text input is focused 106 | background_border_color = ColorProperty((0, 0, 0, 0.5)) #Color of the border line 107 | background_border_width = NumericProperty(1) #Thickness of the border line 108 | rounded = NumericProperty(4) #Radius of rounded corners on background 109 | animate_hint = BooleanProperty(False) 110 | 111 | _activated = NumericProperty(0) 112 | _activated_hint = NumericProperty(0) 113 | _activate_animation = None 114 | _underline_size = NumericProperty(1) 115 | _current_background_color = ColorProperty() 116 | _hint_label = None 117 | _hint_max_size = NumericProperty(0) 118 | _hint_min_size = NumericProperty(0) 119 | def update_hint_label(self): 120 | if not self._hint_label: 121 | self._hint_label = CoreLabel(text='') 122 | self._hint_label.color = 1, 1, 1, 1 123 | self._hint_label.text = self.hint_text 124 | self._hint_max_size = min(self.font_size, self.height * 0.5) 125 | self._hint_min_size = self._hint_max_size * 0.5 126 | target_range = self._hint_max_size - self._hint_min_size 127 | self._hint_label.options['font_size'] = self._hint_min_size + target_range * (1 - self._activated_hint) 128 | self._hint_label.refresh() 129 | if self._hint_label.texture: 130 | self._hint_label_size = self._hint_label.texture.size 131 | return self._hint_label.texture 132 | _hint_label_texture = AliasProperty(update_hint_label, bind=('size', 'font_size', 'hint_text_color', 'hint_text', '_activated_hint')) 133 | _hint_label_size = ListProperty([1, 1]) 134 | context_menu = BooleanProperty(True) 135 | long_press_time = NumericProperty(1) 136 | long_press_clock = None 137 | long_press_pos = None 138 | allow_mode = StringProperty() 139 | allow_negative = BooleanProperty(True) 140 | 141 | def __init__(self, **kwargs): 142 | super().__init__(**kwargs) 143 | self._current_background_color = self.background_color 144 | 145 | def _show_cut_copy_paste(self, pos, win, parent_changed=False, mode='', pos_in_window=False, *l): 146 | return 147 | 148 | def on_navigation_activate(self): 149 | self.focus = not self.focus 150 | 151 | def on_touch_up(self, touch): 152 | if self.long_press_clock: 153 | self.long_press_clock.cancel() 154 | self.long_press_clock = None 155 | return super(NormalInput, self).on_touch_up(touch) 156 | 157 | def on_touch_down(self, touch): 158 | if self.context_menu and not self.disabled: 159 | if self.collide_point(*touch.pos): 160 | pos = self.to_window(*touch.pos) 161 | self.long_press_clock = Clock.schedule_once(self.do_long_press, self.long_press_time) 162 | self.long_press_pos = pos 163 | if hasattr(touch, 'button'): 164 | if touch.button == 'right': 165 | app = App.get_running_app() 166 | app.popup_bubble(self, pos) 167 | return True 168 | return super(NormalInput, self).on_touch_down(touch) 169 | 170 | def do_long_press(self, *_): 171 | app = App.get_running_app() 172 | app.popup_bubble(self, self.long_press_pos) 173 | 174 | def keyboard_on_key_down(self, window, keycode, text, modifiers): 175 | app = App.get_running_app() 176 | app.close_bubble() 177 | if keycode[0] == 27: 178 | self.focus = False 179 | return True 180 | if keycode[0] in [13, 271]: 181 | self.press_enter(self, self.text) 182 | if not self.multiline: 183 | self.focus = False 184 | return True 185 | super().keyboard_on_key_down(window, keycode, text, modifiers) 186 | 187 | def press_enter(self, instance, text): 188 | pass 189 | 190 | def on_focus(self, widget, is_focused): 191 | app = App.get_running_app() 192 | app.navigation_enabled = not self.focus 193 | if is_focused: 194 | self.activate() 195 | else: 196 | self.deactivate() 197 | 198 | def insert_text(self, substring, from_undo=False): 199 | if self.allow_mode.lower() == 'float': 200 | pat = re.compile('[^0-9]') 201 | if self.allow_negative: 202 | if '-' in substring: 203 | substring = substring.replace('-', '') 204 | if '-' in self.text: 205 | self.text = self.text.replace('-', '') 206 | else: 207 | self.text = '-' + self.text 208 | if '.' in self.text: 209 | s = re.sub(pat, '', substring) 210 | else: 211 | s = '.'.join([re.sub(pat, '', s) for s in substring.split('.', 1)]) 212 | elif self.allow_mode.lower() in ['int', 'integer']: 213 | pat = re.compile('[^0-9]') 214 | if self.allow_negative: 215 | if '-' in substring: 216 | substring = substring.replace('-', '') 217 | if '-' in self.text: 218 | self.text = self.text.replace('-', '') 219 | else: 220 | self.text = '-' + self.text 221 | s = re.sub(pat, '', substring) 222 | elif self.allow_mode.lower() in ['file', 'filename']: 223 | s = "".join(i for i in substring if i not in "\\/:*?<>|") 224 | elif self.allow_mode.lower() == 'url': 225 | s = "".join(i for i in substring if i in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -._~!'()*") 226 | elif self.allow_mode.lower() in ['hex', 'hexadecimal']: 227 | s = "".join(i for i in substring if i in "0123456789ABCDEFabcdef") 228 | else: 229 | s = substring 230 | return super().insert_text(s, from_undo=from_undo) 231 | 232 | def on_background_color(self, *_): 233 | self._current_background_color = self.background_color 234 | 235 | def stop_animation(self): 236 | if self._activate_animation: 237 | self._activate_animation.stop(self) 238 | self._activate_animation = None 239 | 240 | def activate(self): 241 | self.stop_animation() 242 | if self.animate_hint: 243 | activated_hint_target = 1 244 | else: 245 | activated_hint_target = 0 246 | self._activate_animation = Animation(_activated=1, _activated_hint=activated_hint_target, _current_background_color=self.background_color_active, duration=self.activate_time) 247 | self._activate_animation.start(self) 248 | 249 | def deactivate(self): 250 | self.stop_animation() 251 | if self.text and self.animate_hint: 252 | activated_hint_target = 1 253 | else: 254 | activated_hint_target = 0 255 | self._activate_animation = Animation(_activated=0, _activated_hint=activated_hint_target, _current_background_color=self.background_color, duration=self.deactivate_time) 256 | self._activate_animation.start(self) 257 | 258 | 259 | class FloatInput(NormalInput): 260 | """Custom text input that only allows float numbers to be typed in. Only allows numerals and one '.'""" 261 | 262 | hint_text = StringProperty("0.0") 263 | allow_negative = BooleanProperty(True) 264 | pat = re.compile('[^0-9]') 265 | 266 | def insert_text(self, substring, from_undo=False): 267 | pat = self.pat 268 | if self.allow_negative: 269 | if '-' in substring: 270 | substring = substring.replace('-', '') 271 | if '-' in self.text: 272 | self.text = self.text.replace('-', '') 273 | else: 274 | self.text = '-' + self.text 275 | if '.' in self.text: 276 | s = re.sub(pat, '', substring) 277 | else: 278 | s = '.'.join([re.sub(pat, '', s) for s in substring.split('.', 1)]) 279 | return super(FloatInput, self).insert_text(s, from_undo=from_undo) 280 | 281 | 282 | class IntegerInput(NormalInput): 283 | """Custom text input that only allows numbers to be typed in.""" 284 | 285 | hint_text = StringProperty("0") 286 | allow_negative = BooleanProperty(True) 287 | pat = re.compile('[^0-9]') 288 | 289 | def insert_text(self, substring, from_undo=False): 290 | pat = self.pat 291 | if self.allow_negative: 292 | if '-' in substring: 293 | substring = substring.replace('-', '') 294 | if '-' in self.text: 295 | self.text = self.text.replace('-', '') 296 | else: 297 | self.text = '-' + self.text 298 | s = re.sub(pat, '', substring) 299 | return super(IntegerInput, self).insert_text(s, from_undo=from_undo) 300 | 301 | 302 | class InputMenu(Bubble): 303 | """Class for the text input right-click popup menu. Includes basic text operations.""" 304 | 305 | owner = ObjectProperty() 306 | 307 | def on_touch_down(self, touch): 308 | if not self.collide_point(*touch.pos): 309 | app = App.get_running_app() 310 | app.close_bubble() 311 | else: 312 | super(InputMenu, self).on_touch_down(touch) 313 | 314 | def select_all(self, *_): 315 | if self.owner: 316 | app = App.get_running_app() 317 | self.owner.select_all() 318 | app.close_bubble() 319 | 320 | def cut(self, *_): 321 | if self.owner: 322 | app = App.get_running_app() 323 | self.owner.cut() 324 | app.close_bubble() 325 | 326 | def copy(self, *_): 327 | if self.owner: 328 | app = App.get_running_app() 329 | self.owner.copy() 330 | app.close_bubble() 331 | 332 | def paste(self, *_): 333 | if self.owner: 334 | app = App.get_running_app() 335 | self.owner.paste() 336 | app.close_bubble() 337 | -------------------------------------------------------------------------------- /test.kv: -------------------------------------------------------------------------------- 1 | #:kivy 1.0 2 | #:import kivy kivy 3 | 4 | : 5 | on_release: root.remove() 6 | 7 | 8 | : 9 | canvas.before: 10 | Color: 11 | rgba: app.theme.background 12 | Rectangle: 13 | pos: self.pos 14 | size: self.size 15 | BoxLayout: 16 | orientation: 'vertical' 17 | Header: 18 | id: header 19 | NormalLabel: 20 | text: 'test app' 21 | InfoLabel: 22 | SettingsButton: 23 | MainArea: 24 | id: mainArea 25 | Scroller: 26 | BoxLayout: 27 | padding: app.scrollbar_scale, 0 28 | orientation: 'vertical' 29 | size_hint_y: None 30 | height: self.minimum_height 31 | Holder: 32 | WideButton: 33 | text: "Load Theme 1" 34 | on_release: app.load_theme(1) 35 | WideButton: 36 | text: "Load Theme 2" 37 | on_release: app.load_theme(2) 38 | SmallSpacer: 39 | Holder: 40 | NormalMenuStarter: 41 | text: 'Small Menu' 42 | on_release: root.menu.open(self) 43 | WideMenuStarter: 44 | text: 'Wide Menu' 45 | on_release: root.menu.open(self) 46 | SmallSpacer: 47 | Holder: 48 | NormalToggle: 49 | text: 'Toggle Button' 50 | on_release: app.message('Toggled To: '+self.state) 51 | WideToggle: 52 | text: 'Wide Toggle Button' 53 | on_release: app.message('Toggled To: '+self.state) 54 | SmallSpacer: 55 | Holder: 56 | NormalButton: 57 | text: 'Small Button' 58 | on_release: app.message('Small Button Message') 59 | WideButton: 60 | text: 'Wide Button' 61 | on_release: app.message('Wide Button Message') 62 | SmallSpacer: 63 | WideButton: 64 | text: 'Warning Button' 65 | warn: True 66 | SmallSpacer: 67 | WideButton: 68 | text: 'Disabled Button' 69 | disabled: True 70 | 71 | LargeSpacer: 72 | Holder: 73 | ShortLabel: 74 | text: "Normal Input: " 75 | NormalInput: 76 | SmallSpacer: 77 | Holder: 78 | ShortLabel: 79 | text: "Integer Input: " 80 | IntegerInput: 81 | SmallSpacer: 82 | Holder: 83 | ShortLabel: 84 | text: "Float Input: " 85 | FloatInput: 86 | 87 | LargeSpacer: 88 | NormalLabel: 89 | text: 'RecycleViews: ' 90 | BoxLayout: 91 | size_hint_y: None 92 | height: 200 93 | orientation: 'horizontal' 94 | NormalRecycleView: 95 | data: root.recycle_data_1 96 | viewclass: 'RecycleItemButton' 97 | SelectableRecycleGridLayout: 98 | padding: 0, 0, app.scrollbar_scale, 0 99 | default_size: None, app.button_scale 100 | default_size_hint: 1, None 101 | size_hint_x: 1 102 | size_hint_y: None 103 | height: self.minimum_height 104 | NormalRecycleView: 105 | data: root.recycle_data_2 106 | viewclass: 'RecycleItemLabel' 107 | SelectableRecycleBoxLayout: 108 | padding: 0, 0, app.scrollbar_scale, 0 109 | multiselect: True 110 | default_size: None, app.button_scale 111 | default_size_hint: 1, None 112 | size_hint_x: 1 113 | size_hint_y: None 114 | height: self.minimum_height 115 | LargeSpacer: 116 | Holder: 117 | ShortLabel: 118 | text: "Slider: " 119 | NormalSlider: 120 | LargeSpacer: 121 | Holder: 122 | ShortLabel: 123 | text: "Smooth Setting: " 124 | SmoothSetting: 125 | start_on: 0 126 | content: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15'] 127 | 128 | LargeSpacer: 129 | 130 | Holder: 131 | WideButton: 132 | text: 'Show Popup Message' 133 | on_release: app.message_popup('Popup Window Message') 134 | WideButton: 135 | text: 'Show Popup Question' 136 | on_release: root.question_popup() 137 | WideButton: 138 | text: 'Show Popup Input' 139 | on_release: root.input_popup() 140 | WideButton: 141 | text: 'Show File Browser' 142 | on_release: root.filebrowser_popup() 143 | -------------------------------------------------------------------------------- /theme.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snuq/SnuKivyTemplate/39867dbd913b019c3d1b78f4a667f439c61deafe/theme.gif --------------------------------------------------------------------------------