├── 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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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
--------------------------------------------------------------------------------