diff --git a/docs/changelog.md b/docs/changelog.md index 81af85e..1f20fb3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,3 +5,5 @@ add script to support local .tmpl and .dspy file translation. it can build dynam ## version 0.3.1 * uses weakref to collect all the i18n widgets in i18n.py +* show video play position + diff --git a/kivyblocks/anz/__init__.py b/kivyblocks/anz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kivyblocks/anz/bluetooth.py b/kivyblocks/anz/bluetooth.py new file mode 100644 index 0000000..b554eb2 --- /dev/null +++ b/kivyblocks/anz/bluetooth.py @@ -0,0 +1,46 @@ +from kivy.app import App + +from jnius import autoclass +import kivy +from android.broadcast import BroadcastReceiver +import sys + +from kivyblocks.baseWidget import VBox + +BluetoothManager = autoclass('android.bluetooth.BluetoothManager') +BluetoothAdapter = autoclass('android.bluetooth.BluetoothAdapter') +BluetoothDevice = autoclass('android.bluetooth.BluetoothDevice') + +class BluetoothFinder(VBox): + data = ListProperty([]) + def __init__(self, **kw): + super().__init__(**kw) + arg = context.getSystemService(context.BLUETOOTH_SERVICE) + self.bt_manager = BluetoothManager(arg) + self.bt_adapter = self.bt_manager.getAdaper() + self.unpairedBT() + + def get_paired_bt(self): + return self.bt_adapter.getBondedDevice() + + def __del__(self): + self.adapter.cancelDiscovery() + + def unpairedBT(self): + self.adapter = BluetoothAdapter.getDefaultAdapter() + # Search foir unpaired devices + print("Unpaired devices") + self.data=[{"text": "Unpaired Devices"}] + myReceiver = BroadcastReceiver(self.onReceive, actions = [BluetoothDevice.ACTION_FOUND, BluetoothAdapter.ACTION_DISCOVERY_STARTED, BluetoothAdapter.ACTION_DISCOVERY_FINISHED]) + myReceiver.start() + self.adapter.startDiscovery() + + # Called by Broadcastreceiver + def onReceive(self, context, intent): + print(f"*BT* On receive context{context}", flush = True) + print(f"*BT* On receive intent {intent}", flush = True) + sys.stdout.flush() + + self.myData.append({"text": f"Context {context}, intent {intent}"}) + self.layout.data = [item for item in self.myData] + diff --git a/kivyblocks/baseWidget.py b/kivyblocks/baseWidget.py index 4cada0e..53e3119 100755 --- a/kivyblocks/baseWidget.py +++ b/kivyblocks/baseWidget.py @@ -2,6 +2,7 @@ import sys import math from traceback import print_exc +from kivy.resources import resource_find from kivy.properties import ObjectProperty, StringProperty, \ NumericProperty, BooleanProperty, OptionProperty from kivy.properties import DictProperty @@ -67,7 +68,7 @@ from .widgetExt.inputext import FloatInput,IntegerInput, \ StrInput,SelectInput, BoolInput, Password from .widgetExt.messager import Messager from .bgcolorbehavior import BGColorBehavior -from .utils import NeedLogin, InsufficientPrivilege, HTTPError +from .utils import NeedLogin, InsufficientPrivilege, HTTPError, blockImage from .login import LoginForm from .tab import TabsPanel from .threadcall import HttpClient @@ -76,6 +77,27 @@ from .widget_css import WidgetCSS from .ready import WidgetReady from .utils import CSize, SUPER from .swipebehavior import SwipeBehavior +from .widgetExt.inputext import MyDropDown + +font_names = { + 'text':resource_find('DroidSansFallback.ttf'), + 'title6':resource_find('TsangerYuYangT_W01_W01.ttf'), + 'title5':resource_find('TsangerYuYangT_W01_W02.ttf'), + 'title4':resource_find('TsangerYuYangT_W01_W03.ttf'), + 'title3':resource_find('TsangerYuYangT_W01_W04.ttf'), + 'title2':resource_find('TsangerYuYangT_W01_W05.ttf'), + 'title1':resource_find('Alimama_ShuHeiTi_Bold.ttf') +} + +font_sizes = { + 'text':CSize(1), + 'title6':CSize(1.1), + 'title5':CSize(1.3), + 'title4':CSize(1.5), + 'title3':CSize(1.7), + 'title2':CSize(1.9), + 'title1':CSize(2.1) +} if platform == 'android': from .widgetExt.phonebutton import PhoneButton @@ -145,33 +167,23 @@ class Text(Label): def __init__(self,i18n=False, texttype='text', wrap=False, fgcolor=None, **kw): - fontsize={'font_size':CSize(1)} - offset={ - 'text':0, - 'title1':CSize(0.6), - 'title2':CSize(0.5), - 'title3':CSize(0.4), - 'title4':CSize(0.3), - 'title5':CSize(0.2), - 'title6':CSize(0.1), - } - fontsize = {'font_size': CSize(1) + offset.get(texttype,0)} + fontsize = font_sizes.get(texttype) + fontname = font_names.get(texttype) self._i18n = i18n self.i18n = I18n() self.bgcolor = fgcolor kwargs = kw.copy() config = getConfig() self.wrap = wrap - if kwargs.get('font_size') and texttype=='text': - pass - else: - kwargs.update(fontsize) + kwargs.update({ + 'font_size':fontsize, + 'font_name':fontname + }) if not kwargs.get('text'): kwargs['text'] = kwargs.get('otext','') SUPER(Text, self, kwargs) if self._i18n: - self.i18n = I18n() self.i18n.addI18nWidget(self) if self.wrap: self.size_hint_y = None @@ -220,7 +232,8 @@ class Text(Label): self.lang = lang def on_lang(self,o,lang): - self.text = self.i18n(self.otext) + if self._i18n and self.otext: + self.text = self.i18n(self.otext) class Title1(Text): def __init__(self, **kw): @@ -256,12 +269,19 @@ class Modal(VBox): content = DictProperty(None) auto_open = BooleanProperty(True) auto_dismiss = BooleanProperty(True) - position = OptionProperty('tc',options=['tl', 'tc', 'tr', + target = StringProperty(None) + position = OptionProperty('cc',options=['tl', 'tc', 'tr', 'cl', 'cc', 'cr', 'bl', 'bc', 'br']) - def __init__(self, **kw): - SUPER(Modal, self, kw) + self._target = None + super(Modal, self).__init__(**kw) + self.set_size_position() + self._target.bind(size=self.set_size_position) + self.register_event_type('on_open') + self.register_event_type('on_pre_open') + self.register_event_type('on_pre_dismiss') + self.register_event_type('on_dismiss') if self.content: blocks = Factory.Blocks() self.content_w = blocks.widgetBuild(self.content) @@ -269,10 +289,6 @@ class Modal(VBox): self.add_widget(self.content_w) else: print(content,':cannot build widget') - self.register_event_type('on_open') - self.register_event_type('on_pre_open') - self.register_event_type('on_pre_dismiss') - self.register_event_type('on_dismiss') def on_touch_down(self, touch): if not self.collide_point(touch.x, touch.y): @@ -283,41 +299,74 @@ class Modal(VBox): return super().on_touch_down(touch) - def set_modal_position(self, w): + def on_target(self): + w = Window + if self.target is not None: + w = Factory.Blocks.getWidgetById(self.target) + if w is None: + w = Window + if w != self._target: + self._target = w + + def set_target(self): + if self._target is None: + if self.target is None: + w = Window + else: + w = Factory.Blocks.getWidgetById(self.target) + if w is None: + w = Window + self._target = w + + def set_size_position(self, *args): + self.set_target() + if self.size_hint_x: + self.width = self.size_hint_x * self._target.width + if self.size_hint_y: + self.height = self.size_hint_y * self._target.height + print(self.width, self.height, + self.size_hint_x, self.size_hint_y, + self._target.size + ) + self.set_modal_position() + + def set_modal_position(self): + self.set_target() xn = self.position[1] yn = self.position[0] x, y = 0, 0 if xn == 'c': - x = (w.width - self.width) / 2 + x = (self._target.width - self.width) / 2 elif xn == 'r': - x = w.width - self.width + x = self._target.width - self.width if x < 0: x = 0 if yn == 'c': - y = (w.height - self.height) / 2 - elif yn == 'b': - y = w.height - self.height + y = (self._target.height - self.height) / 2 + elif yn == 't': + y = self._target.height - self.height if y < 0: y = 0 - if w == Window: + if self._target == Window: self.pos = x, y else: - self.pos = w.pos[0] + x, w.pos[1] + y + self.pos = self._target.pos[0] + x, self._target.pos[1] + y - def open(self, widget=None): + def open(self): if self.parent: - return + self.parent.remove_widget(self) self.dispatch('on_pre_open') - if widget is None: - widget = Window - self.set_modal_position(widget) Window.add_widget(self) self.dispatch('on_open') + if self._target != Window: + self._target.disabled = True def dismiss(self, *args): self.dispatch('on_pre_dismiss') self.dispatch('on_dismiss') Window.remove_widget(self) + if self._target != Window: + self._target.enabled = False def on_open(self, *args): pass @@ -333,7 +382,6 @@ class Modal(VBox): def add_widget(self, w, *args, **kw): super().add_widget(w, *args, **kw) - # super().add_widget(Label(text='1111')) if self.auto_open: self.open() @@ -358,6 +406,32 @@ class TimedModal(Modal): self.time_task = None super().dismiss() +class Running(AsyncImage): + def __init__(self, widget, **kw): + super().__init__(**kw) + self.host_widget = widget + self.source = blockImage('running.gif') + self.host_widget.bind(size=self.set_size) + self.set_size() + self.open() + + def open(self): + if self.parent: + self.parent.remove_widget(self) + Window.add_widget(self) + self.host_widget.disabled = True + + def dismiss(self): + if self.parent: + self.parent.remove_widget(self) + self.host_widget.disabled = False + + def set_size(self, *args): + self.size_hint = (None, None) + self.width = CSize(2) + self.height = CSize(2) + self.center = self.host_widget.center + class PressableImage(ButtonBehavior,AsyncImage): def on_press(self): pass @@ -366,6 +440,10 @@ class PressableLabel(ButtonBehavior, Text): def on_press(self): pass +class PressableText(ButtonBehavior, Text): + def on_press(self): + pass + class FILEDataHandler(EventDispatcher): def __init__(self, url, suffixs=[],params={}): self.url = url @@ -566,3 +644,29 @@ class Slider(Carousel): w = bk.widgetBuild(desc) self.add_widget(w) +class I18nWidget(PressableText): + lang = StringProperty(None) + def __init__(self, **kw): + super().__init__(**kw) + i18n = I18n() + self.lang = i18n.lang + + def on_lang(self, *args): + self.otext = self.lang + + def on_press(self, *args): + i18n = I18n() + langs = i18n.get_languages() + data = [ {'lang':l} for l in langs ] + mdd = MyDropDown(textField='lang', valueField='lang', + value=self.lang, + data=data) + mdd.bind(on_select=self.selected_lang) + mdd.showme(self) + + def selected_lang(self, o, v): + lang = v[0] + self.lang = lang + i18n = I18n() + i18n.changeLang(self.lang) + diff --git a/kivyblocks/blocks.py b/kivyblocks/blocks.py index b789073..b61b866 100755 --- a/kivyblocks/blocks.py +++ b/kivyblocks/blocks.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD import os import sys import codecs @@ -818,3 +819,822 @@ class {{ classname }}({% for b in bases -%}{{b}}{% endfor %}) Factory.register('Blocks',Blocks) Factory.register('Video',Video) Factory.register('OrientationLayout', OrientationLayout) +======= +import os +import sys +import codecs +# import ujson as json +try: + import ujson as json +except: + import json +from traceback import print_exc + +from functools import partial + +from appPublic.dictExt import dictExtend +from appPublic.folderUtils import ProgramPath +from appPublic.dictObject import DictObject +from appPublic.Singleton import SingletonDecorator, GlobalEnv +from appPublic.datamapping import keyMapping +from appPublic.registerfunction import RegisterFunction + +from kivy.logger import Logger +from kivy.config import Config +from kivy.metrics import sp,dp,mm +from kivy.core.window import WindowBase, Window +from kivy.properties import BooleanProperty +from kivy.uix.widget import Widget +from kivy.clock import mainthread +from kivy.uix.modalview import ModalView +from kivy.app import App +from kivy.factory import Factory +from kivy.uix.video import Video +from .utils import * +from .newvideo import Video +from .orientationlayout import OrientationLayout +from .threadcall import HttpClient +from .register import * +from .script import Script, set_script_env + +class WidgetNotFoundById(Exception): + def __init__(self, id): + super().__init__() + self.idstr = id + + def __str__(self): + return "Widget not found by id:" + self.idstr + ':' + + def __expr__(self): + return str(self) + +class ClassMethodNotFound(Exception): + def __init__(self,k,m): + super().__init__() + self.kname = k + self.mname = m + + def __str__(self): + s = 'Method(%s) not found in class(%s)' % (self.mname, + str(self.kname.__classname__)) + return s + + def __expr__(self): + return self.__str__() + + +class NotExistsObject(Exception): + def __init__(self,name): + super().__init__() + self.name = name + + def __str__(self): + s = 'not exists widget(%s)' % self.name + return s + + def __expr__(self): + return self.__str__() + +class ArgumentError(Exception): + def __init__(self,argument,desc): + super().__init__() + self.argument = argument + self.desc = desc + + def __str__(self): + s = 'argument(%s) missed:%s' % (self.argument,self.desc) + return s + + def __expr__(self): + return self.__str__() + + +class NotRegistedWidget(Exception): + def __init__(self, name:str): + super().__init__() + self.widget_name = name + + def __str__(self): + s = 'not reigsted widget(%s)' % self.name + return s + + def __expr__(self): + return self.__str__() + +def registerWidget(name:str,widget): + globals()[name] = widget + + +class Blocks(EventDispatcher): + def __init__(self): + EventDispatcher.__init__(self) + self.action_id = 0 + self.register_event_type('on_built') + self.register_event_type('on_failed') + self.env = GlobalEnv() + config = getConfig() + self.script = Script() + + def set(self, k:str, v): + self.env[k] = v + + def register_widget(self, name:str, widget:Widget): + globals()[name] = widget + + def buildAction(self, widget:Widget, desc): + conform_desc = desc.get('conform') + blocks = Blocks() + if not conform_desc: + return partial(blocks.uniaction, widget, desc) + func =partial(blocks.conform_action,widget, desc) + return func + + def eval(self, s:str, l:dict): + g = {} + forbidens = [ + "os", + "sys", + "codecs", + "json", + ] + + for k,v in globals().copy().items(): + if k not in forbidens: + g[k] = v + + """ + g['__builtins__'] = globals()['__builtins__'] + g['__builtins__']['__import__'] = None + g['__builtins__']['__loader__'] = None + g['__builtins__']['open'] = None + """ + g.update(self.env) + return eval(s,g,l) + + def getUrlData(self, url:str, method:str='GET', + params:dict={}, files:dict={}, + callback=None, + errback=None,**kw): + + if url is None: + if errback: + errback(None,Exception('url is None')) + else: + return None + + if url.startswith('file://'): + return self.script.dispatch(url, **params) + elif url.startswith('http://') or url.startswith('https://'): + try: + hc = HttpClient() + resp=hc(url,method=method,params=params,files=files) + # print('Blocks.py :resp=',resp) + return resp + except Exception as e: + print_exc() + if errback: + return errback(None,e) + return None + else: + config = getConfig() + url = config.uihome + url + return self.getUrlData(url,method=method, + params=params, + files=files, + **kw) + + def strValueExpr(self,s:str,localnamespace:dict={}): + if not s.startswith('py::'): + return s + s = s[4:] + try: + v = self.eval(s,localnamespace) + return v + except Exception as e: + print('Exception .... ',e,'script=',s) + print_exc() + return s + + def arrayValueExpr(self,arr:list,localnamespace:dict={}) -> list: + d = [] + for v in arr: + if type(v) == type(''): + d.append(self.strValueExpr(v,localnamespace)) + continue + if type(v) == type([]): + d.append(self.arrayValueExpr(v,localnamespace)) + continue + if type(v) == type({}): + d.append(self.dictValueExpr(v,localnamespace)) + continue + if type(v) == type(DictObject): + d.append(self.dictValueExpr(v,localnamespace)) + continue + d.append(v) + return d + + def dictValueExpr(self,dic:dict,localnamespace:dict={}) -> dict: + d = {} + for k,v in dic.items(): + if type(v) == type(''): + d[k] = self.strValueExpr(v,localnamespace) + continue + if type(v) == type([]): + d[k] = self.arrayValueExpr(v,localnamespace) + continue + if type(v) == type({}): + d[k] = self.dictValueExpr(v,localnamespace) + continue + if type(v) == type(DictObject): + d[k] = self.dictValueExpr(v,localnamespace) + continue + d[k] = v + return d + def valueExpr(self,obj,localnamespace:dict={}): + if type(obj) == type(''): + return self.strValueExpr(obj,localnamespace) + if type(obj) == type([]): + return self.arrayValueExpr(obj,localnamespace) + if type(obj) == type({}): + return self.dictValueExpr(obj,localnamespace) + if isinstance(obj,DictObject): + return self.dictValueExpr(obj,localnamespace) + return obj + + def w_build(self,desc) -> Widget: + widgetClass = desc.get('widgettype',None) + if not widgetClass: + Logger.info("Block: w_build(), desc invalid", desc) + raise Exception(desc) + + widgetClass = desc['widgettype'] + opts = self.valueExpr(desc.get('options',{}).copy()) + widget = None + try: + klass = Factory.get(widgetClass) + widget = klass(**opts) + except Exception as e: + print('Error:',widgetClass,'contructon error') + print_exc() + raise NotExistsObject(widgetClass) + + if desc.get('id'): + id = desc.get('id') + if id.startswith('app.'): + app = App.get_running_app() + id = id[4:] + setattr(app, id, widget) + if id.startswith('root.'): + app = App.get_running_app() + id = id[5:] + setattr(app.root, id, widget) + + if '.' in id: + Logger.info('widget id(%s) can not contain "."', id) + else: + widget.widget_id = id + + widget.build_desc = desc + self.build_attributes(widget, desc) + self.build_rest(widget, desc) + self.buildBinds(widget, desc) + return widget + + def build_attributes(self, widget:Widget,desc,t=None): + excludes = ['widgettype','options','subwidgets','binds'] + for k,v in [(k,v) for k,v in desc.items() if k not in excludes]: + if isinstance(v,dict) and v.get('widgettype'): + b = Blocks() + v = self.valueExpr(v, localnamespace={'self':widget}) + w = b.widgetBuild(v) + if hasattr(widget,k): + aw = getattr(widget,k) + if isinstance(aw,Layout): + aw.add_widget(w) + continue + setattr(widget,k,w) + continue + setattr(widget,k,self.valueExpr(v,\ + localnamespace={'self':widget})) + + def build_rest(self, widget:Widget,desc,t=None): + self.subwidget_total = len(desc.get('subwidgets',[])) + self.subwidgets = [ None for i in range(self.subwidget_total)] + pos = 0 + for pos,sw in enumerate(desc.get('subwidgets',[])): + b = Blocks() + if isinstance(sw, str): + # w = Blocks.getWidgetById(sw, from_widget=widget) + w = Blocks.findWidget(sw, from_widget=widget) + if w: + widget.add_widget(w) + continue + + kw = self.valueExpr(sw.copy(), + localnamespace={'self':widget}) + w = b.widgetBuild(kw) + if w: + widget.add_widget(w) + + + def buildBinds(self, widget:Widget, desc:dict): + for b in desc.get('binds',[]): + kw = self.valueExpr(b.copy(), \ + localnamespace={'self':widget}) + self.buildBind(widget,kw) + + def buildBind(self, widget:Widget, desc:dict): + wid = desc.get('wid','self') + # w = Blocks.getWidgetById(desc.get('wid','self'),from_widget=widget) + w = Blocks.findWidget(desc.get('wid','self'),from_widget=widget) + if not w: + Logger.info('Block: id(%s) %s',desc.get('wid','self'), + 'not found via Blocks.getWidgetById()') + return + event = desc.get('event') + if event is None: + Logger.info('Block: binds desc miss event, desc=%s',str(desc)) + return + f = self.buildAction(widget,desc) + if f is None: + Logger.info('Block: get a null function,%s',str(desc)) + return + w.bind(**{event:f}) + + def multipleAction(self, widget:Widget, desc, *args): + desc1 = {k:v for k, v in desc.items() if k != 'actions'} + mydesc = desc1.copy() + for a in desc['actions']: + new_desc = mydesc.copy() + new_desc.update(a) + self.uniaction(widget,new_desc, *args) + + def conform_action(self, widget:Widget, desc, *args): + conform_desc = desc.get('conform') + blocks = Blocks() + if not conform_desc: + blocks.uniaction(widget, desc,*args, **kw) + return + w = blocks.widgetBuild({ + "widgettype":"Conform", + "options":conform_desc + }) + w.bind(on_conform=partial(blocks.uniaction, widget, desc)) + w.open() + + def uniaction(self, widget:Widget, desc, *args): + acttype = desc.get('actiontype') + if acttype=='blocks': + return self.blocksAction(widget,desc, *args) + if acttype=='urlwidget': + return self.urlwidgetAction(widget,desc, *args) + if acttype == 'registedfunction': + return self.registedfunctionAction(widget,desc, *args) + if acttype == 'script': + return self.scriptAction(widget, desc, *args) + if acttype == 'method': + return self.methodAction(widget, desc, *args) + if acttype == 'event': + return self.eventAction(widget, desc, *args) + if acttype == 'multiple': + return self.multipleAction(widget, desc, *args) + + alert("actiontype(%s) invalid" % acttype,title='error') + + def eventAction(self, widget:Widget, desc, *args): + target = self.get_target(widget, desc) + event = desc.get('dispatch_event') + if not event: + Logger.info('Block: eventAction():desc(%s) miss dispatch_event', + str(desc)) + return + params = desc.get('params',{}) + d = self.getActionData(widget,desc, *args) + if d: + params.update(d) + try: + target.dispatch(event, params) + except Exception as e: + Logger.info(f'Block: eventAction():dispatch {event} error') + print_exc() + return + + def get_target(self, widget:Widget, desc): + if not desc.get('target'): + return None + # return Blocks.getWidgetById(desc.get('target'),from_widget=widget) + return Blocks.findWidget(desc.get('target'),from_widget=widget) + + def blocksAction(self, widget:Widget, desc, *args): + target = self.get_target(widget, desc) + add_mode = desc.get('mode','replace') + opts = desc.get('options').copy() + d = self.getActionData(widget,desc, *args) + p = opts.get('options',{}).copy() + if d: + p.update(d) + opts['options'] = p + def doit(target:Widget, add_mode:str, o, w:Widget): + if isinstance(w, Modal): + return + + if target and not w.parent: + if add_mode == 'replace': + target.clear_widgets() + target.add_widget(w) + + def doerr(o,e): + Logger.info('Block: blocksAction(): desc=%s widgetBuild error' + ,str(desc)) + raise e + + b = Blocks() + b.bind(on_built=partial(doit,target,add_mode)) + b.bind(on_failed=doerr) + b.widgetBuild(opts) + + def urlwidgetAction(self, widget:Widget, desc, *args): + target = self.get_target(widget, desc) + add_mode = desc.get('mode','replace') + opts = desc.get('options', {}).copy() + p1 = opts.get('params',{}) + p = {} + if p1: + p.update(p1) + if len(args) >= 1 and isinstance(args[0],dict): + p.update(args[0]) + d = self.getActionData(widget, desc, *args) + if d: + p.update(d) + opts['params'] = p + d = { + 'widgettype' : 'urlwidget', + 'options': opts + } + + def doit(target:Widget, add_mode:str, o, w:Widget): + if isinstance(w, ModalView): + return + + if target and not w.parent: + if add_mode == 'replace': + target.clear_widgets() + target.add_widget(w) + + def doerr(o,e): + Logger.info('Block: urlwidgetAction(): desc=%s widgetBuild error' + ,str(desc)) + + b = Blocks() + b.bind(on_built=partial(doit,target,add_mode)) + b.bind(on_failed=doerr) + b.widgetBuild(d) + + def getActionData(self, widget:Widget, desc, *args): + data = {} + rtdesc = self.build_rtdesc(desc) + if rtdesc: + rt = self.get_rtdata(widget, rtdesc, *args) + if rt: + data.update(rt) + if desc.get('keymapping'): + data = keyMapping(data, desc.get('keymapping')) + Logger.info('getActionData():rtdesc=%s, data=%s', rtdesc, data) + return data + + def registedfunctionAction(self, widget:Widget, desc, *args): + target = self.get_target(widget, desc) + rf = RegisterFunction() + name = desc.get('rfname') + func = rf.get(name) + if func is None: + Logger.info('Block: desc=%s rfname(%s) not found', + str(desc), name) + raise Exception('rfname(%s) not found' % name) + + params = desc.get('params',{}).copy() + d = self.getActionData(widget,desc, *args) + if d: + params.update(d) + func(target, *args, **params) + + def scriptAction(self, widget:Widget, desc, *args): + script = desc.get('script') + if not script: + Logger.info('Block: scriptAction():desc(%s) target not found', + str(desc)) + return + target = self.get_target(widget, desc) + d = self.getActionData(widget,desc, *args) + ns = { + "self":target, + "args":args, + "kwargs":d + } + if d: + ns.update(d) + try: + self.eval(script, ns) + except Exception as e: + print_exc() + print(e) + + def build_rtdesc(self, desc): + rtdesc = desc.get('rtdata') + if not rtdesc: + if desc.get('datawidget'): + rtdesc = {} + rtdesc['target'] = desc['datawidget'] + if desc.get('datascript'): + rtdesc['script'] = desc.get('datacript') + else: + rtdesc['method'] = desc.get('datamethod', 'getValue') + rtdesc['kwargs'] = desc.get('datakwargs', {}) + return rtdesc + + def get_rtdata(self, widget:Widget, desc, *args): + """ + desc descript follow attributes + { + "target", a target name from widget + "method", a method name, default is 'getValue', + "script", script to handle the data + "kwargs":" a dict arguments, default is {} + } + """ + + if desc is None or desc == {}: + print('get_rtdata():desc is None') + return {} + kwargs = desc.get('kwargs', {}) + if not isinstance(kwargs, dict): + kwargs = {} + w = None + if len(args) > 0: + w = args[0] + target = desc.get('target') + if target: + # w = Blocks.getWidgetById(target, from_widget=widget) + w = Blocks.findWidget(target, from_widget=widget) + if w is None: + print('get_rtdata():w is None', desc) + return {} + script = desc.get('script') + if script: + ns = { + "self":w, + "args":args, + "kwargs":kwargs + } + d = self.eval(script, ns) + return d + + method = desc.get('method', 'getValue') + if not hasattr(w, method): + print('get_rtdata():method is None', desc, type(w)) + return {} + f = getattr(w, method) + try: + r = f(**kwargs) + if isinstance(r, dict): + return r + if isinstance(r, DictObject): + return r.to_dict() + print('get_rtdata():method return is not dict', desc, 'ret=', r, type(r)) + return {} + except: + print_exc() + return {} + + def methodAction(self, widget:Widget, desc, *args): + method = desc.get('method') + target = self.get_target(widget, desc) + if target is None: + Logger.info('Block: methodAction():desc(%s) target not found', + str(desc)) + return + if method is None: + Logger.info('Block: methodAction():desc(%s) method not found', + str(desc)) + return + if hasattr(target,method): + f = getattr(target, method) + kwargs = desc.get('options',{}).copy() + d = self.getActionData(widget,desc, *args) + if d: + kwargs.update(d) + f(*args, **kwargs) + else: + alert('%s method not found' % method) + + def widgetBuild(self, desc): + """ + desc format: + { + widgettype:, + id:widget id, + options:{} + subwidgets:[ + ] + binds:[ + ] + } + """ + def doit(desc): + if isinstance(desc,DictObject): + desc = desc.to_dict() + if not isinstance(desc,dict): + Logger.info('Block: desc(%s) must be a dict object(%s)', + desc,type(desc)) + return None + + # desc = self.valueExpr(desc) + try: + widget = self.w_build(desc) + self.dispatch('on_built',widget) + if hasattr(widget,'ready'): + widget.ready = True + return widget + except Exception as e: + print_exc() + self.dispatch('on_failed',e) + return None + + if isinstance(desc,DictObject): + desc = desc.to_dict() + if not isinstance(desc, dict): + print('widgetBuild1: desc must be a dict object', + desc,type(desc)) + self.dispatch('on_failed',Exception('miss url')) + return + + widgettype = desc.get('widgettype') + while widgettype == "urlwidget": + opts = desc.get('options',{}).copy() + extend = desc.get('extend') + addon = None + if desc.get('extend'): + addon = desc.get('extend').copy() + url = opts.get('url') + if url is None: + self.dispatch('on_failed',Exception('miss url')) + return + + if opts.get('url'): + del opts['url'] + rtdesc = self.build_rtdesc(opts) + if rtdesc: + rtdata = self.get_rtdata(None, rtdesc) + params = opts.get('params', {}) + params.update(rtdata) + opts['params'] = params + + desc = self.getUrlData(url,**opts) + if not (isinstance(desc, DictObject) or \ + isinstance(desc, dict)): + print('Block2: desc must be a dict object', + desc,type(desc)) + self.dispatch('on_failed',Exception('miss url')) + return + + if addon: + desc = dictExtend(desc,addon) + widgettype = desc.get('widgettype') + if widgettype is None: + print('Block3: desc must be a dict object not None') + return None + return doit(desc) + + @classmethod + def findWidget(self, id:str, from_widget:Widget=None) -> Widget: + """ + -:find up direct, find widget in ancestor + + or empty: find down direct, find widget in descendant + @:find widget by class + $ or empty:find widget by id + .:more than one step. + + """ + def find_widget_by_id(id, from_widget): + if id=='self': + return from_widget + if hasattr(from_widget,'widget_id'): + if from_widget.widget_id == id: + return from_widget + if hasattr(from_widget, id): + w = getattr(from_widget,id) + return w + return None + + def find_widget_by_class(klass, from_widget): + if from_widget.__class__.__name__ == klass: + return from_widget + for k, v in from_widget.__dict__.items(): + if isinstance(v, Widget): + if v.__class__.__name__ == klass: + return v + return None + + def _find_widget(name, from_widget, dir='down', + find_func=find_widget_by_id): + w = find_func(name, from_widget) + if w: + return w + if dir == 'down': + children = [i for i in from_widget.children] + if hasattr(from_widget, 'get_subwidgets'): + children = from_widget.get_subwidgets() + # Logger.info('children=%s', str(children)) + for c in children: + ret = _find_widget(name, from_widget=c, dir=dir) + if ret: + return ret + else: + if isinstance(from_widget, WindowBase): + return None + if from_widget.parent: + return _find_widget(name, + from_widget=from_widget.parent, + dir=dir) + return None + + def find_widget(step, from_widget): + dir = 'down' + if step[0:1] == '-': + dir = 'up' + step = step[1:] + find_func = find_widget_by_id + if step[0:1] == '@': + step = step[1:] + find_func = find_widget_by_class + return _find_widget(step, from_widget=from_widget, + dir=dir, find_func=find_func) + + steps = id.split('.') + w = from_widget + for i, s in enumerate(steps): + if i==0: + app = App.get_running_app() + fid = s + if fid == '/self' or fid == 'root': + w = app.root + if len(steps) == 1: + return from_widget + continue + if fid == 'Window': + w = Window + if len(steps) == 1: + return w + continue + if fid == 'app': + return app + + w = find_widget(s, w) + if not w: + return None + return w + + @classmethod + def getWidgetById(self, id:str, from_widget:Widget=None) -> Widget: + return Blocks.findWidget(id, from_widget=from_widget) + + def on_built(self,v=None): + return + + def on_failed(self,e=None): + return + + def buildKlass(self, desc): + """ + desc = { + "classname":"MyClass", + "base":["Box", "WidgetReady"], + "properties":[ + { + "name":"aaa", + "type":"str", + "default":1 + } + ] + "subwidgets":[ + ], + + """ + codes = """ +{% for b in bases %} +{{b}} = Factory.{{b}} +{% endfor %} + +class {{ classname }}({% for b in bases -%}{{b}}{% endfor %}) +{% for p in properties %} + {{p.name}} {{p.type}}({{p.default}}) +{% endfor %} + def __init__(self, **kw): + super({{classname}}, self).__init__(**kw) + +""" + +Factory.register('Blocks',Blocks) +Factory.register('Video',Video) +Factory.register('OrientationLayout', OrientationLayout) +>>>>>>> cab6a2818466e5d32880a6a01e7afe16ad192675 diff --git a/kivyblocks/blocksapp.py b/kivyblocks/blocksapp.py index 6642857..2353a23 100644 --- a/kivyblocks/blocksapp.py +++ b/kivyblocks/blocksapp.py @@ -2,12 +2,6 @@ import os import sys from kivy.resources import resource_add_path -from kivy.config import Config -resource_add_path(os.path.join(os.path.dirname(__file__),'./ttf')) -Config.set('kivy', 'default_font', [ - 'msgothic', - 'DroidSansFallback.ttf']) - import signal import codecs import json @@ -23,6 +17,7 @@ from kivy.core.window import WindowBase, Window from kivy.clock import Clock from kivy.logger import Logger from kivy.utils import platform +from kivy.metrics import Metrics from kivy.app import App import plyer @@ -31,20 +26,25 @@ from .register import * from .threadcall import HttpClient,Workers from .utils import * from .widget_css import register_css +from .version import __version__ if platform == 'android': + from android.storage import app_storage_path from jnius import autoclass + from .android_rotation import get_rotation -from .android_rotation import get_rotation - +Logger.info(f'KivyBlocks:version={__version__}') def signal_handler(signal, frame): app = App.get_running_app() + if app is None: + return app.workers.running = False app.stop() print('Singal handled .........') signal.signal(signal.SIGINT, signal_handler) + class BlocksApp(App): def get_rotation(self): return get_rotation() @@ -80,6 +80,8 @@ class BlocksApp(App): self.public_headers = { "platform":self.platform } + # Window.borderless = True + print('Window.dpi=', Window.dpi, 'Metrics.dpi=', Metrics.dpi) Window.bind(on_request_close=self.on_close) Window.bind(on_rotate=self.on_rotate) Window.bind(size=self.device_info) @@ -104,23 +106,19 @@ class BlocksApp(App): def get_user_data_path(self): if platform == 'android': - Environment = autoclass('android.os.Environment') - sdpath = Environment.getExternalStorageDirectory() - return str(sdpath) + # Environment = autoclass('android.os.Environment') + # sdpath = Environment.getExternalStorageDirectory() + # return str(sdpath) + return str(app_storage_path()) sdpath = App.get_running_app().user_data_dir return str(sdpath) def get_profile_name(self): - fname = os.path.join(self.user_data_dir,'.profile.json') + fname = os.path.join(self.get_user_data_path(),'.profile.json') print('profile_path=', fname) return fname - def write_profile(self, dic): - fname = self.get_profile_name() - with codecs.open(fname,'w','utf-8') as f: - json.dump(dic,f) - - def write_default_profile(self): + def default_profile(self): device_id = getID() try: device_id = plyer.uniqueid.id @@ -132,6 +130,15 @@ class BlocksApp(App): d = { 'device_id': device_id } + return d + + def write_profile(self, dic): + fname = self.get_profile_name() + with codecs.open(fname,'w','utf-8') as f: + json.dump(dic,f) + + def write_default_profile(self): + d = self.default_profile() self.write_profile(d) def read_profile(self): diff --git a/kivyblocks/clickable.py b/kivyblocks/clickable.py index 382fa75..e9bf4c7 100644 --- a/kivyblocks/clickable.py +++ b/kivyblocks/clickable.py @@ -16,6 +16,11 @@ from kivyblocks.widget_css import WidgetCSS from .uitype.factory import UiFactory, get_value from .command_action import cmd_action +def on_disabled(self, *args): + pass + +TouchRippleButtonBehavior.on_disabled = on_disabled + class TinyText(Text): def __init__(self, **kw): SUPER(TinyText, self, kw) @@ -26,6 +31,46 @@ class TinyText(Text): self.texture_update() self.size = self.texture_size +class PressableBox(TouchRippleButtonBehavior, Box): + normal_css = StringProperty("default") + actived_css = StringProperty("default") + box_actived = BooleanProperty(False) + def __init__(self, + border_width=1, + user_data=None, + radius=[], + **kw): + super(PressableBox, self).__init__( + padding=[border_width, + border_width, + border_width, + border_width], + radius=radius, + **kw) + self.border_width = border_width + self.user_data = user_data + self.actived = False + self.csscls = self.normal_css + + def active(self, flag): + self.box_actived = flag + + def on_box_actived(self, o, v): + if self.box_actived: + self.csscls = self.actived_css + else: + self.csscls = self.normal_css + + def on_press(self,o=None): + self.box_actived = True + + + def setValue(self,d): + self.user_data = d + + def getValue(self): + return self.user_data + class ClickableBox(TouchRippleButtonBehavior, Box): def __init__(self, border_width=1, diff --git a/kivyblocks/command_action.py b/kivyblocks/command_action.py index 93203eb..913e33c 100644 --- a/kivyblocks/command_action.py +++ b/kivyblocks/command_action.py @@ -15,7 +15,6 @@ def get_cmd_desc(cmd_desc): desc['datamethod'] = v['datamethod'] keys = v.keys() if 'url' in keys: - Logger.info('get_cmd_desc():cmd_desc=%s', cmd_desc) desc['actiontype'] = 'urlwidget' desc['mode'] = 'replace' options = { @@ -24,18 +23,22 @@ def get_cmd_desc(cmd_desc): } desc['options'] = options return desc + if 'rfname' in keys: desc['actiontype'] = 'registedfunction' desc['params'] = v.get('params',{}) return desc + if 'script' in keys: desc['actiontype'] = 'script' desc['script'] = v.get('script') return desc + if 'method' in keys: desc['actiontype'] = 'method' desc['method'] = v.get('method') return desc + if 'actions' in keys: desc['actiontype'] = 'multiple' desc['actions'] = v.get('actions') @@ -61,6 +64,6 @@ def cmd_action(cmd_desc, widget): }) w.bind(on_conform=partial(blocks.uniaction, widget, desc)) w.open() - Logger.info('cmd_action():desc=%s, conform and action', desc) + print('cmd_action():desc=', desc, ' conform and action', desc) diff --git a/kivyblocks/dataloader.py b/kivyblocks/dataloader.py index 8197fb8..939c259 100644 --- a/kivyblocks/dataloader.py +++ b/kivyblocks/dataloader.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from kivy.event import EventDispatcher from kivy.app import App @@ -191,3 +192,187 @@ class RegisterFunctionDataLoader(DataLoader): self.dispatch('on_error', e) +======= + +from kivy.event import EventDispatcher +from kivy.app import App +from kivy.factory import Factory +from .threadcall import HttpClient +from .utils import absurl +from appPublic.registerfunction import RegisterFunction + +class DataGraber(EventDispatcher): + """ + Graber format + { + "widgettype":"DataGraber", + "options":{ + "dataurl":"first", + "datarfname":"second", + "target":"third", + "params": + "method": + "pagging":"default false" + } + } + if dataurl present, the DataGraber using this dataurl to get and + return data + else if datarfname present, it find a registered function named + by 'datarfname' to return data + else if datatarget present, it find the widget and uses target + method(default is 'getValue') to return data + else it return None + """ + def __init__(self, **kw): + super().__init__() + self.options = kw + self.register_event_type('on_success') + self.register_event_type('on_error') + + def load(self, *args, **kw): + ret = None + while True: + try: + dataurl = self.options.get('dataurl') + if dataurl: + ret = self.loadUrlData(*args, **kw) + break + + rfname = self.options.get('datarfname') + if rfname: + ret = self.loadRFData(*args, **kw) + break + target = self.options.get('datatarget') + if target: + ret = self.loadTargetData(*args, **kw) + break + except Exception as e: + self.dispatch('on_error', e) + return + if ret: + self.dispatch('on_success',ret) + else: + e = Exception('Not method to do load') + self.dispatch('on_error', e) + + def loadUrlData(self, *args, **kw): + dataurl = self.options.get('dataurl') + hc = HttpClient() + params = self.options.get('params',{}).copy() + params.update(kw) + method = self.options.get('method','GET') + d = hc(dataurl, params=params,method=method) + return d + + def loadRFData(self, *args, **kw): + rfname = self.options.get('datarfname') + rf = RegisterFunction() + f = rf.get(rfname) + if not f: + return None + params = self.options.get('params',{}).copy() + params.update(kw) + try: + d = f(**params) + return d + except Exception as e: + Logger.info('blocks : Exception:%s',e) + print_exc() + return None + + def loadTargetData(self, *args, **kw): + target = self.options.get('datatarget') + w = Factory.Blocks.getWidgetById(target) + if not w: + return None + params = self.options.get('params',{}).copy() + params.update(kw) + method = params.get('method', 'getValue') + if not has(w, method): + return None + try: + f = getattr(w, method) + d = f() + return d + except Exception as e: + Logger.info('blocks : Exception %s', e) + print_exc() + return None + + def on_success(self,d): + pass + + def on_error(self,e): + pass + +class DataLoader(EventDispatcher): + def __init__(self,data_user): + self.data_user = data_user + EventDispatcher.__init__(self) + self.register_event_type('on_success') + self.register_event_type('on_error') + + def on_success(self,d): + pass + + def on_error(self,e): + pass + + def load(self): + pass + +class HttpDataLoader(DataLoader): + def load(self, *args, **kw): + app = App.get_running_app() + url = self.data_user.url + method = self.data_user.method + params = self.data_user.params.copy() + params.update({ + "page":self.data_user.curpage, + "rows":self.data_user.page_rows + }) + hc = HttpClient() + try: + r = hc(url, method=method, params=params) + self.dispatch('on_success', r) + return r + except Exception as e: + self.dispatch('on_error', e) + + +class ListDataLoader(DataLoader): + def load(self, *args, **kw): + p = self.data_user.curpage + r = self.data_user.page_rows + try: + s = self.data_user.data[(p-1)*r:p*r] + d = { + "total":len(self.data_user.data), + "rows":s + } + self.dispatch('on_success', d) + return d + except Exception as e: + self.dispatch('on_error', e) + +class RegisterFunctionDataLoader(DataLoader): + def load(self, *args, **kw): + rf = RegisterFunction() + try: + rfname = self.data_user.rfname + func = rf.get(rfname) + if func is None: + raise Exception('%s is not a registerfunction' % rfname) + params = {k:v for k,v in self.user_data.params.items()} + params.update({ + "page":self.data_user.curpage, + "rows":self.data_user.page_rows + }) + s = func(**params) + self.dispatch('on_success', s) + return s + except Exception as e: + self.dispatch('on_error', e) + + +>>>>>>> cab6a2818466e5d32880a6a01e7afe16ad192675 diff --git a/kivyblocks/dg.py b/kivyblocks/dg.py index 545a2d0..c86861f 100755 --- a/kivyblocks/dg.py +++ b/kivyblocks/dg.py @@ -25,7 +25,7 @@ from appPublic.uniqueID import getID from appPublic.myTE import string_template_render from .utils import * -from .baseWidget import Text, HBox, VBox +from .baseWidget import Text, HBox, VBox, Running from .scrollpanel import ScrollPanel from .paging import Paging, RelatedLoader from .ready import WidgetReady @@ -64,7 +64,7 @@ class Cell(ButtonBehavior, WidgetCSS, BoxLayout): if self.row.header: self.csscls=self.row.part.datagrid.header_css if desc['header']: - bl = Text(i18n=True, text=str(desc['value']), + bl = Text(i18n=True, otext=str(desc['value']), font_size=CSize(1),wrap=True, halign='left', valign='middle' ) @@ -432,7 +432,7 @@ class DataGrid(VBox): desc = { "widgettype":"Text", "options":{ - "text":n, + "otext":n, "i18n":True, } } diff --git a/kivyblocks/download.py b/kivyblocks/download.py new file mode 100644 index 0000000..784df93 --- /dev/null +++ b/kivyblocks/download.py @@ -0,0 +1,7 @@ +from kivy.properties import StringProperty + +class DownloadFile(ClickableIconText): + def __init__(self, **kw): + pass + + diff --git a/kivyblocks/ffpyplayer_video.py b/kivyblocks/ffpyplayer_video.py index a42b38e..13825e9 100644 --- a/kivyblocks/ffpyplayer_video.py +++ b/kivyblocks/ffpyplayer_video.py @@ -6,12 +6,14 @@ from ffpyplayer.tools import set_log_callback from kivy.factory import Factory from kivy.app import App +from kivy.core.window import Window from kivy.uix.image import Image from kivy.uix.widget import Widget from kivy.clock import Clock from kivy.properties import StringProperty, BooleanProperty, \ OptionProperty, NumericProperty from kivy.graphics.texture import Texture +from kivy.graphics import Color, Line from kivyblocks.ready import WidgetReady @@ -26,17 +28,17 @@ class FFVideo(WidgetReady, Image): duration = NumericProperty(-1) position = NumericProperty(-1) volume = NumericProperty(-1) - framerate = NumericProperty(180) in_center_focus = BooleanProperty(False) def __init__(self, **kwargs): self._player = None self._update_task = None - self._texture = None + self.vh_task = None self.is_black = False self.videosize = None - self.timeperiod = 1.0 / self.framerate - self.ff_opts = {} + self.ff_opts = { + "framedrop":True + } self.lib_opts = {} self.headers_pattern = {} super(FFVideo, self).__init__(**kwargs) @@ -46,6 +48,7 @@ class FFVideo(WidgetReady, Image): self.register_event_type('on_leave_focus') self.register_event_type('on_enter_focus') self.register_event_type('on_load_success') + self.register_event_type('on_startplay') def on_open_failed(self, *args): pass @@ -101,10 +104,14 @@ class FFVideo(WidgetReady, Image): def on_status(self, *args): print('on_status called, ', self.status) if self._player is None: + print('no _player, do nothing') return + Window.allow_screensaver = True if self.status == 'play': + Window.allow_screensaver = False self._player.set_pause(False) + self.video_handle() elif self.status == 'pause': self._player.set_pause(True) elif self.status == 'stop': @@ -112,20 +119,35 @@ class FFVideo(WidgetReady, Image): else: pass + def on_parent(self, *args): + if self.parent: + return + self._play_stop() + + def on_startplay(self, *args): + pass + def on_frame(self, *args): if self._player is None: return - if self.audio_id is None: - return - self._player.request_channel(self, 'audio', 'open', self.audio_id) + if not self.playing: + self.dispatch('on_startplay') + self._player.request_channel( \ + 'audio', 'open', self.audio_id) + self.seek(self.position) + self.playing = True + if self.duration > 0: + p = self.position / self.duration * self.width + self.canvas.after.clear() + with self.canvas.after: + Color(1,1,1,1) + Line() + Line(points=[0, 0, self.width, 0], width=1) + Color(1,0,0,1) + Line(points=[0,1,p,1], width=1) def __del__(self): - if self._update_task: - self._update_task.cancel() - self._update_task = None - if self._player: - self._player.close_player() - self._player = None + self._play_stop() def set_volume(self, v): if self.play_mode == 'preview': @@ -141,23 +163,27 @@ class FFVideo(WidgetReady, Image): self.volume = v def seek(self, pts): + self.vsync = False if self.play_mode == 'preview': return if self._player is None: return if self.status != 'play': return - self._player.seek(pts) + self._player.seek(pts, relative=False) self.position = self._player.get_pts() + self.last_val = None - def mute(self, flag): + def mute(self, flag=None): if self.play_mode == 'preview': return if self._player is None: return if self.status != 'play': return - self._player.set_mute(flag) + x = self._player.get_mute() + print('Video(), mute=', x) + self._player.set_mute(not x) def switch_audio(self): if self.play_mode == 'preview': @@ -169,7 +195,9 @@ class FFVideo(WidgetReady, Image): self._player.request_channel('audio', action='cycle') def on_v_src(self, o, src): - self._play_stop() + self.status = 'stop' + self.playing = False + ff_opts = { 'pause':False } @@ -194,7 +222,8 @@ class FFVideo(WidgetReady, Image): # self._player = MediaPlayer(self.v_src) self._player = MediaPlayer(self.v_src, ff_opts=ff_opts, \ lib_opts=lib_opts) - self._play_start() + # self._play_start() + self.status = 'play' def file_opened(self, files): self.v_src = files[0] @@ -202,7 +231,6 @@ class FFVideo(WidgetReady, Image): def play(self): if self._player is None: return - # self._player.set_pause(False) self.status = 'play' def pause(self): @@ -212,33 +240,32 @@ class FFVideo(WidgetReady, Image): self.status = 'pause' def _play_start(self): - self.timepass = 0.0 self.last_frame = None self.is_black = False - self.first_play = True - self._update_task = Clock.schedule_interval(self._update, self.timeperiod) + self.vsync = False def _get_video_info(self): - if self.first_play: + if not self.playing: + self.playing = True meta = self._player.get_metadata() self.duration = meta['duration'] - self.position = 0 self._out_fmt = meta['src_pix_fmt'] self.frame_rate = meta['frame_rate'] self.videosize = meta['src_vid_size'] def _play_stop(self): - if self._player is None: - return - self._update_task.cancel() - self._update_task = None - self._player.close_player() - self._player = None - self.timepass = 0 + if self._player: + self._player.close_player() + self._player = None + if self._update_task: + self._update_task.cancel() + self._update_task = None + if self.vh_task: + self.vh_task.cancel() + self.vh_task = None self.next_frame = None self.duration = -1 - self.position = -1 - self.frame_rate = None + self.position = 0 self.videosize = None def on_size(self, *args): @@ -283,7 +310,6 @@ class FFVideo(WidgetReady, Image): fbo['tex_y'] = 0 fbo['tex_u'] = 1 fbo['tex_v'] = 2 - self._texture = fbo.texture dy, du, dv, _ = img.to_memoryview() if dy and du and dv: self._tex_y.blit_buffer(dy, colorfmt='luminance') @@ -304,35 +330,44 @@ class FFVideo(WidgetReady, Image): self.texture = texture # print('img_size=', w, h, 'window size=', self.size) - def _update(self, dt): - if self.last_frame is None: - frame, val = self._player.get_frame() - if val == 'eof': - print('*****EOF******') - self.status = 'stop' - self.set_black() - return - if val == 'pause': - self.status = 'pause' - return - if frame is None: - # print('video null', time.time()) - self.set_black() - return - self.last_frame = frame - self.video_ts = val - self._get_video_info() - - self.timepass += self.timeperiod + def video_handle(self, *args): + if self._update_task: + self._update_task.cancel() + self._update_task = None + if self._player is None: + return + frame, val = self._player.get_frame() + if val == 'eof': + self.status = 'stop' + self.set_black() + self.last_val = None + return + if val == 'pause': + self.status = 'pause' + self.last_val = None + return + if frame is None: + self.set_black() + self.last_val = None + self.vh_task = Clock.schedule_once(self.video_handle, 0.1) + return + + self. _get_video_info() + self.last_frame = frame + self.video_ts = val + if self.last_val is None: + self.last_val = val + self._update_task = Clock.schedule_once(self.do_update, 0) + else: + t = val - self.last_val + if t > 0: + self._update_task = Clock.schedule_once(self.do_update, t) + else: + self._update_task = Clock.schedule_once(self.do_update, 0) + + def do_update(self, *args): self.position = self._player.get_pts() - if self.timepass < self.video_ts: - return - - if self.timepass > self.video_ts +0.2: - self.last_frame = None - return - - self.status = 'play' + self.volume = self._player.get_volume() img, t = self.last_frame if self._out_fmt == 'yuv420p': self.show_yuv420(img) @@ -340,5 +375,5 @@ class FFVideo(WidgetReady, Image): self.show_others(img) self.dispatch('on_frame', self.last_frame) self.last_frame = None - -Factory.register('FFVideo', FFVideo) + self.vh_task = Clock.schedule_once(self.video_handle, 0) + diff --git a/kivyblocks/hierarchy.py b/kivyblocks/hierarchy.py index 338964a..449d2bf 100644 --- a/kivyblocks/hierarchy.py +++ b/kivyblocks/hierarchy.py @@ -13,31 +13,34 @@ from .scrollpanel import ScrollPanel from .clickable import SingleCheckBox from .baseWidget import Text from .utils import CSize +from .command_action import cmd_action from appPublic.registerfunction import getRegisterFunctionByName -class TreeViewComplexNode(BoxLayout, TreeViewLabel): +class TreeViewComplexNode(BoxLayout, TreeViewNode): otext = StringProperty(None) + font_size_c = NumericProperty(1) + node_height = NumericProperty(2) checkbox = BooleanProperty(False) icon = StringProperty(None) data = DictProperty(None) def __init__(self, **kw): super(TreeViewComplexNode, self).__init__(**kw) self.orientation = 'horizontal' - self.size_hint_x = None if self.checkbox: cb = SingleCheckBox(size_hint=(None,None)) cb.bind(on_press=self.set_checked) self.add_widget(cb) if self.icon: img = AsyncImage(source=self.icon, size_hint=(None,None)) - img.size = CSize(1,1) + img.size = CSize(self.font_size_c,self.font_size_c) self.add_widget(img) - txt = Text(otext=self.otext, i18n=True) - txt.texture_update() - txt.size_hint = (None, None) - txt.size = txt.texture_size + txt = Text(otext=self.otext, i18n=True, wrap=True, + font_size=CSize(self.font_size_c),halign='left') self.add_widget(txt) + self.size_hint_x = 1 + self.size_hint_y = None + self.height = CSize(self.node_height) def set_checked(self, o): if not self.data: @@ -52,6 +55,8 @@ class Hierarchy(ScrollPanel): params = DictProperty(None) method = StringProperty('get') idField = StringProperty(None) + node_height = NumericProperty(2) + font_size_c = NumericProperty(1) textField = StringProperty(None) data = ListProperty(None) checkbox = BooleanProperty(False) @@ -60,13 +65,17 @@ class Hierarchy(ScrollPanel): def __init__(self, **kw): self.register_event_type('on_press') self.tree = TreeView(hide_root=True) - self.tree.size_hint = (None, None) + self.tree.size_hint = (1, None) self.tree.bind(on_node_expand=self.check_load_subnodes) self.tree.bind(selected_node=self.node_selected) super(Hierarchy, self).__init__(inner=self.tree, **kw) if self.url: self.data = self.get_remote_data() + def on_size(self, *args): + self.tree.size_hint_x = 1 + self.tree.width = self.width + def on_press(self, node): print('selected node=', node) @@ -95,12 +104,14 @@ class Hierarchy(ScrollPanel): def create_new_node(self, data, node=None): n = TreeViewComplexNode(otext=data[self.textField], checkbox=self.checkbox, + node_height=self.node_height, + font_size_c=self.font_size_c, icon=data.get('icon') or self.icon ) n.data = data - n.width = self.tree.indent_start + \ - self.tree.indent_level * n.level \ - + sum([i.width for i in n.children]) + # n.width = self.tree.indent_start + \ + # self.tree.indent_level * n.level \ + # + sum([i.width for i in n.children]) if node: self.tree.add_node(n, node) else: @@ -148,50 +159,10 @@ class Menu(Hierarchy): def on_press(self, node): self.tree.deselect_node() data = {} - dw = node.data.get('datawidget') - if dw: - data_widget = Factory.Blocks.getWidgetById(dw) - if data_widget: - vn = node.data.get('datamethod', 'getValue') - if hasattr(data_widget, vn): - f = getattr(data_widget, vn) - data = f() - if not isinstance(data, dict): - data = {} - - url = node.data.get('url') - target = Factory.Blocks.getWidgetById(node.data.get('target',self.target),self) - if url: - params = node.data.get('params',{}) - params.update(data) - blocks = Factory.Blocks() - desc = { - "widgettype":"urlwidget", - "options":{ - "url":url, - "params":params - } - } - w = blocks.widgetBuild(desc) - if w and target: - target.add_widget(w) - return - - rfname = node.data.get('rfname') - if rfname: - f = getRegisterFunctionByName(rfname) - if f: - f(self, **data) - return - - script = node.data.get('script') - if script: - target_name = node.data.get('target', self.target) - target = Factory.Blocks.getWidgetById(target_name, self) - data.update({'self':target}) - if target: - eval(script,data) - return + data = node.data.copy() + if self.target and data.get('target') is None: + data['target'] = self.target + return cmd_action(data, self) Factory.register('Hierarchy', Hierarchy) Factory.register('Menu', Menu) diff --git a/kivyblocks/i18n.py b/kivyblocks/i18n.py index 914a8d7..5ad2fc5 100644 --- a/kivyblocks/i18n.py +++ b/kivyblocks/i18n.py @@ -14,6 +14,9 @@ class I18n: def __init__(self): self.kvlang={} self.lang = locale.getdefaultlocale()[0] + if self.lang is None: + self.lang = 'en_US' + self.languages = None self.loadI18n(self.lang) self.i18nWidgets = [] @@ -21,8 +24,9 @@ class I18n: self.i18nWidgets.append(ref(w)) def loadI18nFromI18nFolder(self, lang): - config = gtConfig() + config = getConfig() fpath = os.path.join(config.i18n_folder,lang,'msg.txt') + print('fpath=', fpath, type(fpath)) with codecs.open(fpath,'r','utf-8') as f: line = f.readline() d = {} @@ -32,15 +36,39 @@ class I18n: continue k,v = line.split(':',1) d.update({k:v}) - line = readline() + line = f.readline() return d + def get_languages(self): + if self.languages: + return self.languages + self.languages = self.getLanguages() + return self.languages + + def getLanguages(self): + config = getConfig() + if config.i18n_folder: + langs = [] + for f in os.listdir(config.i18n_folder): + p = os.path.join(config.i18n_folder, f) + if os.path.isdir(p): + langs.append(os.path.basename(f)) + return langs + + if config.i18n_url: + url = '%s%s' % (config.uihome, config.i18n_url) + hc = HttpClient() + d = hc.get(url) + if isinstance(d, list): + return d + return [] + def loadI18n(self,lang): app = App.get_running_app() config = getConfig() self.kvlang[lang] = {} if config.i18n_folder: - self.kvlang[lang] = self.loadI18nFromFolder(lang) + self.kvlang[lang] = self.loadI18nFromI18nFolder(lang) return if config.i18n_url: @@ -63,8 +91,8 @@ class I18n: if not d: self.loadI18n(lang) self.lang = lang - ws = [ w for w in self.i18nWidgets if w is not None ] + ws = [ w for w in self.i18nWidgets if w() is not None ] + for w in ws: + w().changeLang(lang) self.i18nWidgets = ws - for w in self.i18nWidgets: - w.changeLang(lang) diff --git a/kivyblocks/imgs/download.png b/kivyblocks/imgs/download.png new file mode 100644 index 0000000..99b9876 Binary files /dev/null and b/kivyblocks/imgs/download.png differ diff --git a/kivyblocks/imgs/upload.png b/kivyblocks/imgs/upload.png new file mode 100644 index 0000000..9dcf37a Binary files /dev/null and b/kivyblocks/imgs/upload.png differ diff --git a/kivyblocks/markdown.py b/kivyblocks/markdown.py index fea4163..58e98d5 100644 --- a/kivyblocks/markdown.py +++ b/kivyblocks/markdown.py @@ -191,7 +191,7 @@ class MarkdownBody(VBox): self.md_obj = md_obj super().__init__(**kw) self.padding=padding - self.size_hint = None,None + self.size_hint = 1,None self.bind(parent=self.resize) self.resize() @@ -205,6 +205,7 @@ class MarkdownBody(VBox): f(v) def resize(self, *args): + return Logger.info('MDBody:resize called') if self.parent: ps = [0,0,0,0] diff --git a/kivyblocks/pagepanel.py b/kivyblocks/pagepanel.py index f693f1f..93c4aa5 100644 --- a/kivyblocks/pagepanel.py +++ b/kivyblocks/pagepanel.py @@ -2,8 +2,10 @@ from kivy.core.window import Window from kivy.uix.widget import Widget from kivy.app import App +from kivy.clock import Clock from kivy.factory import Factory -from .baseWidget import VBox, HBox +from kivy.utils import platform +from .baseWidget import VBox, HBox, I18nWidget from .toggleitems import PressableBox from .utils import * from .swipebehavior import SwipeBehavior @@ -56,8 +58,10 @@ in control bar, there is a optional left menu icon, page title, right menu icon, PagePanel description file format ``` { + "bar_autohide": true when page is idle "bar_size": bar size in CSize unit "bar_at": "top" or "bottom" + "i18n": true of false "bar_css": "panel_css": "left_menu": if defined, it must be a widget instance or a dict @@ -133,16 +137,24 @@ sub-widget's description file format """ def __init__(self, bar_size=2, bar_css='default', + i18n=False, csscls='default', singlepage=False, fixed_before=None, + bar_autohide=False, fixed_after=None, bar_at='top', enable_on_close=False, left_menu=None, **kw): self.bar_size = bar_size + self.bar_autohide = bar_autohide self.bar_at = bar_at + self.i18n = i18n self.singlepage = singlepage + self.idle_status = False + self.idle_threshold = 10 + self.bar_show = True + self.idle_task = None self.swipe_buffer = [] self.swipe_right = False self.fixed_before = None @@ -209,6 +221,10 @@ sub-widget's description file format }) self.bar.add_widget(self.bar_left_menu) self.bar_left_menu.bind(on_press=self.show_left_menu) + if self.i18n: + self.i18n_w = I18nWidget(size_hint_x=None, width=CSize(5)) + self.bar.add_widget(self.i18n_w) + self.bar_title = HBox(csscls=bar_css) self.bar.add_widget(self.bar_title) self.bar_right_menu = VBox(size_hint=(None,None),size=CSize(bcsize,bcsize)) @@ -229,15 +245,47 @@ sub-widget's description file format }) self.bar.add_widget(self.bar_right_menu) self.bar_right_menu_w.bind(on_press=self.show_right_menu) + self.construct() + if self.bar_autohide: + Window.bind(on_touch_down=self.set_normal_bar) + self.idle_task = Clock.schedule_once(self.set_idle_bar, \ + self.idle_threshold) - if bar_at == 'top': + def set_idle_bar(self, *args): + if not self.bar_show: + return + try: + self.bar_pos = self.children.index(self.bar) + print('self.bar_pos=', self.bar_pos, '......................') + super().remove_widget(self.bar) + if platform in ['win', 'macosx','linux']: + Window.borderless = True + except: + pass + self.bar_show = False + + def set_normal_bar(self, *args): + if self.idle_task: + self.idle_task.cancel() + self.idle_task = Clock.schedule_once(self.set_idle_bar, \ + self.idle_threshold) + if self.bar_show: + return + super().add_widget(self.bar, index=self.bar_pos) + if platform in ['win', 'macosx','linux']: + Window.borderless = False + self.bar_show = True + + def construct(self): + self.clear_widgets() + if self.bar_show and self.bar_at == 'top': super().add_widget(self.bar) if self.fixed_before: super().add_widget(self.fixed_before) super().add_widget(self.content) if self.fixed_after: super().add_widget(self.fixed_after) - if bar_at != 'top': + if self.bar_show and self.bar_at != 'top': super().add_widget(self.bar) self.left_menu_showed = False self.right_menu_showed = False diff --git a/kivyblocks/register.py b/kivyblocks/register.py index fb135fc..f4a9f2f 100644 --- a/kivyblocks/register.py +++ b/kivyblocks/register.py @@ -42,10 +42,13 @@ from .block_test import BlockTest from .hierarchy import Hierarchy from .price import PriceView from .ffpyplayer_video import FFVideo +from .upload import UploadFile r = Factory.register # if kivy.platform in ['win','linux', 'macosx']: # r('ScreenWithMic', ScreenWithMic) +r('UploadFile', UploadFile) +r('FFVideo', FFVideo) r('AnchorBox', AnchorBox) r('FloatBox', FloatBox) r('RelativeBox', RelativeBox) @@ -144,4 +147,3 @@ def register_blocks(name, value): except: Logger.info(f'plugin : register_blocks():{name} register error') - diff --git a/kivyblocks/script.py b/kivyblocks/script.py index e02d062..619a114 100644 --- a/kivyblocks/script.py +++ b/kivyblocks/script.py @@ -1,3 +1,5 @@ +import os +import traceback try: import ujson as json except: @@ -9,6 +11,9 @@ from appPublic.myTE import MyTemplateEngine from appPublic.Singleton import SingletonDecorator from appPublic.dictObject import DictObject +from kivy.logger import Logger +from kivy.utils import platform + @SingletonDecorator class ScriptEnv(DictObject): pass @@ -18,8 +23,10 @@ def set_script_env(n,v): env.update({n:v}) class Script: - def __init__(self, root): - self.root = root + def __init__(self): + config = getConfig() + self.root = config.script_root + # print('Script.root=', self.root) self.env = {} self.handlers = {} self.register('.tmpl', TemplateHandler) @@ -31,6 +38,20 @@ class Script: url = url[7:] return join(self.root, *url.split('/')) + def show_info(self, env): + workdir = env['workdir'] + sfile = env['filepath'] + url = env['url'] + print(f'script:workdir={workdir}') + print(f'script:script_file={sfile}') + print(f'script:url={url}') + sdir = os.path.join(workdir, 'scripts') + sf_exists = os.path.isdir(sdir) + conf_f = os.path.join(workdir, 'conf', 'config.json') + conf_exists = os.path.isfile(conf_f) + print(f'script:script exists {sf_exists}') + print(f'script:config.json exists {conf_exists}') + def dispatch(self, url, **kw): filepath = self.url2filepath(url) for suffix, handler in self.handlers.items(): @@ -41,6 +62,10 @@ class Script: env['root_path'] = self.root env['url'] = url env['filepath'] = filepath + # print(f"workdir={env['workdir']}--------") + # if platform == 'android': + # self.show_info(env) + h = handler(env) d = h.render() try: @@ -90,7 +115,12 @@ class TemplateHandler(BaseHandler): self.engine = MyTemplateEngine(paths) def render(self): - return self.engine.render(self.templ_file, self.env) + try: + return self.engine.render(self.templ_file, self.env) + except Exception as e: + print('Exception:', str(e)) + print('filename=', self.env['filepath']) + traceback.print_exc() class DspyHandler(BaseHandler): def __init__(self, env): @@ -107,9 +137,15 @@ class DspyHandler(BaseHandler): return txt def render(self, params={}): - lenv = self.env.copy() - lenv.update(params) - txt = self.loadScript(self.env['filepath']) - exec(txt,lenv,lenv) - func = lenv['myfunc'] - return func(self.env, **lenv) + try: + lenv = self.env.copy() + lenv.update(params) + txt = self.loadScript(self.env['filepath']) + exec(txt,lenv,lenv) + func = lenv['myfunc'] + return func(self.env, **lenv) + except Exception as e: + print('Exception:', str(e)) + print('filename=', self.env['filepath']) + traceback.print_exc() + diff --git a/kivyblocks/threadcall.py b/kivyblocks/threadcall.py index 7106561..250cdef 100644 --- a/kivyblocks/threadcall.py +++ b/kivyblocks/threadcall.py @@ -121,18 +121,18 @@ class HttpClient(Http_Client): self.workers.add(self.webcall,callback,errback,kwargs=kwargs) - def get(self, url, params={}, headers={}, callback=None, errback=None): + def get(self, url, params={}, headers={}, stream=False, callback=None, errback=None): return self.__call__(url,method='GET',params=params, - headers=headers, callback=callback, + headers=headers, stream=stream, callback=callback, errback=errback) - def post(self, url, params={}, headers={}, files={}, callback=None, errback=None): + def post(self, url, params={}, headers={}, files={}, stream=False, callback=None, errback=None): return self.__call__(url,method='POST',params=params, files=files, - headers=headers, callback=callback, + headers=headers, stream=stream, callback=callback, errback=errback) - def put(self, url, params={}, headers={}, callback=None, errback=None): + def put(self, url, params={}, headers={}, stream=False, callback=None, errback=None): return self.__call__(url,method='PUT',params=params, - headers=headers, callback=callback, + headers=headers, stream=stream, callback=callback, errback=errback) def delete(self, url, params={}, headers={}, callback=None, errback=None): diff --git a/kivyblocks/toggleitems.py b/kivyblocks/toggleitems.py index 2e31e86..ec3ad27 100644 --- a/kivyblocks/toggleitems.py +++ b/kivyblocks/toggleitems.py @@ -1,6 +1,5 @@ from functools import partial from kivy.logger import Logger -from kivy.uix.behaviors import TouchRippleButtonBehavior from kivy.graphics import Color, Rectangle from kivy.uix.boxlayout import BoxLayout from kivy.factory import Factory @@ -11,46 +10,7 @@ from kivyblocks.bgcolorbehavior import BGColorBehavior from kivyblocks.utils import CSize from kivyblocks.baseWidget import Box from kivyblocks.widget_css import WidgetCSS - -class PressableBox(TouchRippleButtonBehavior, Box): - normal_css = StringProperty("default") - actived_css = StringProperty("default") - box_actived = BooleanProperty(False) - def __init__(self, - border_width=1, - user_data=None, - radius=[], - **kw): - super(PressableBox, self).__init__( - padding=[border_width, - border_width, - border_width, - border_width], - radius=radius, - **kw) - self.border_width = border_width - self.user_data = user_data - self.actived = False - self.csscls = self.normal_css - - def active(self, flag): - self.box_actived = flag - - def on_box_actived(self, o, v): - if self.box_actived: - self.csscls = self.actived_css - else: - self.csscls = self.normal_css - - def on_press(self,o=None): - self.box_actived = True - - - def setValue(self,d): - self.user_data = d - - def getValue(self): - return self.user_data +from kivyblocks.clickable import PressableBox """ ToggleItems format: diff --git a/kivyblocks/toolbar.py b/kivyblocks/toolbar.py index ec3d940..1e248d5 100644 --- a/kivyblocks/toolbar.py +++ b/kivyblocks/toolbar.py @@ -67,16 +67,22 @@ class Toolbar(ScrollPanel): self.register_event_type('on_delete_tool') if self.toolbar_orient == 'H': self._inner.orientation = 'horizontal' + self.size_hint_x = 1 else: self._inner.orientation = 'vertical' + self.size_hint_y = 1 self.clear_widgets() for t in self.tools: self.add_tool(t) self.bar_width = 0 if self.toolbar_orient == 'H': + self.size_hint_y = None + self.height = self._inner.height * 1.6 self.do_scroll_y = False else: + self.size_hint_x = None + self.width = self._inner.width * 1.6 self.do_scroll_x = False def on_children_size(self, o, size): diff --git a/kivyblocks/ttf/Alimama_ShuHeiTi_Bold.ttf b/kivyblocks/ttf/Alimama_ShuHeiTi_Bold.ttf new file mode 100644 index 0000000..854c844 Binary files /dev/null and b/kivyblocks/ttf/Alimama_ShuHeiTi_Bold.ttf differ diff --git a/kivyblocks/ttf/TsangerYuYangT_W01_W01.ttf b/kivyblocks/ttf/TsangerYuYangT_W01_W01.ttf new file mode 100644 index 0000000..12aa94a Binary files /dev/null and b/kivyblocks/ttf/TsangerYuYangT_W01_W01.ttf differ diff --git a/kivyblocks/ttf/TsangerYuYangT_W02_W02.ttf b/kivyblocks/ttf/TsangerYuYangT_W02_W02.ttf new file mode 100644 index 0000000..b1ac708 Binary files /dev/null and b/kivyblocks/ttf/TsangerYuYangT_W02_W02.ttf differ diff --git a/kivyblocks/ttf/TsangerYuYangT_W03_W03.ttf b/kivyblocks/ttf/TsangerYuYangT_W03_W03.ttf new file mode 100644 index 0000000..027535f Binary files /dev/null and b/kivyblocks/ttf/TsangerYuYangT_W03_W03.ttf differ diff --git a/kivyblocks/ttf/TsangerYuYangT_W04_W04.ttf b/kivyblocks/ttf/TsangerYuYangT_W04_W04.ttf new file mode 100644 index 0000000..4e821f2 Binary files /dev/null and b/kivyblocks/ttf/TsangerYuYangT_W04_W04.ttf differ diff --git a/kivyblocks/ttf/TsangerYuYangT_W05_W05.ttf b/kivyblocks/ttf/TsangerYuYangT_W05_W05.ttf new file mode 100644 index 0000000..612697e Binary files /dev/null and b/kivyblocks/ttf/TsangerYuYangT_W05_W05.ttf differ diff --git a/kivyblocks/upload.py b/kivyblocks/upload.py new file mode 100644 index 0000000..078ade4 --- /dev/null +++ b/kivyblocks/upload.py @@ -0,0 +1,57 @@ +import os +from kivy.properties import StringProperty, ListProperty +from kivyblocks.utils import blockImage, CSize +import requests +from plyer import filechooser +from .i18n import I18n +from .clickable import ClickableIconText +from .baseWidget import Running +from .message import Error + +class UploadFile(ClickableIconText): + upload_url = StringProperty(None) + name = StringProperty('upfile') + # suffixes = ListProperty([]) + def __init__(self, **kw): + super().__init__(**kw) + self.otext = 'please select file' + self.img_kw = { + "size_hint":[None, None], + "width":CSize(1), + "height":CSize(1) + } + self.source = blockImage('upload.png') + self.file_url = None + self.running = None + + def getValue(self): + return { + self.name:self.file_url + } + + def on_press(self, *args): + i18n = I18n() + filechooser.open_file(title=i18n('open file'), + on_selection=self.file_selected) + + def file_selected(self, files): + running = Running(self) + def readfile(f): + with open(f, 'rb') as o: + d = o.read(102400) + if not d: + return + yield d + fpath = files[0] + fn = os.path.basename(fpath) + print('fn=', fn) + headers={ + 'Content-Type': 'application/octet-stream', + 'Content-Disposition':'attachments;filename={}'.format(fn) + } + r = requests.post(self.upload_url, + data=readfile(files[0]), + headers=headers + ) + running.dismiss() + diff --git a/kivyblocks/version.py b/kivyblocks/version.py index 0404d81..f0ede3d 100644 --- a/kivyblocks/version.py +++ b/kivyblocks/version.py @@ -1 +1 @@ -__version__ = '0.3.0' +__version__ = '0.4.1'