add new widget ListManager (#1005)

* add new widget ListManager

* flake8 complains

* Null_action appears now in the main list (to simplify additions to the list)
Formatted data are now at the from to the actions submenu

* Define a default action in the menu, potentially independent of a null_action
Both default and null actions don't have to be part of the element's action list
Some cleanup
This commit is contained in:
Werner Llácer 2022-02-28 16:17:10 +01:00 committed by GitHub
parent 537b9cab03
commit f07704529f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 285 additions and 6 deletions

View File

@ -34,6 +34,8 @@ from .lib.storage import *
from .lib.systemd import *
from .lib.user_interaction import *
from .lib.menu import Menu
from .lib.menu.list_manager import ListManager
from .lib.menu.text_input import TextInput
from .lib.menu.selection_menu import (
GlobalMenu,
Selector,

View File

@ -0,0 +1,269 @@
#!/usr/bin/python
"""
# Purpose
ListManager is a widget based on `menu` which allows the handling of repetitive operations in a list.
Imagine you have a list and want to add/copy/edit/delete their elements. With this widget you will be shown the list
```
Vamos alla
Use ESC to skip
> uno : 1
dos : 2
tres : 3
cuatro : 4
==>
Confirm and exit
Cancel
(Press "/" to search)
```
Once you select one of the elements of the list, you will be promted with the action to be done to the selected element
```
uno : 1
dos : 2
> tres : 3
cuatro : 4
==>
Confirm and exit
Cancel
(Press "/" to search)
Select an action for < {'tres': 3} >
Add
Copy
Edit
Delete
> Cancel
```
You execute the action for this element (which might or not involve user interaction) and return to the list main page
till you call one of the options `confirm and exit` which returns the modified list or `cancel` which returns the original list unchanged.
If the list is empty one action can be defined as default (usually Add). We call it **null_action**
YOu can also define a **default_action** which will appear below the separator, not tied to any element of the list. Barring explicit definition, default_action will be the null_action
```
==>
Add
Confirm and exit
Cancel
(Press "/" to search)
```
The default implementation can handle simple lists and a key:value dictionary. The default actions are the shown above.
A sample of basic usage is included at the end of the source.
More sophisticaded uses can be achieved by
* changing the action list and the null_action during intialization
```
opciones = ListManager('Vamos alla',opciones,[str(_('Add')),str(_('Delete'))],_('Add')).run()
```
* And using following methods to overwrite/define user actions and other details:
* * `reformat`. To change the appearance of the list elements
* * `action_list`. To modify the content of the action list once an element is defined. F.i. to avoid Delete to appear for certain elements, or to add/modify action based in the value of the element.
* * `exec_action` which contains the actual code to be executed when an action is selected
The contents in the base class of this methods serve for a very basic usage, and are to be taken as samples. Thus the best use of this class would be to subclass in your code
```
class ObjectList(archinstall.ListManager):
def __init__(prompt,list):
self.ObjectAction = [... list of actions ...]
self.ObjectNullAction = one ObjectAction
super().__init__(prompt,list,ObjectActions,ObjectNullAction)
def reformat(self):
... beautfy the output of the list
def action_list(self):
... if you need some changes to the action list based on self.target
def exec_action(self):
if self.action == self.ObjectAction[0]:
performFirstAction(self.target, ...)
...
resultList = ObjectList(prompt,originallist).run()
```
"""
from .text_input import TextInput
from .menu import Menu
from ..general import RequirementError
from os import system
from copy import copy
class ListManager:
def __init__(self,prompt :str, base_list :list ,base_actions :list = None,null_action :str = None, default_action :str = None):
"""
param :prompt Text which will appear at the header
type param: string | DeferredTranslation
param :base:_list list/dict of option to be shown / mainpulated
type param: list | dict
param base_actions an alternate list of actions to the items of the object
type param: list
param: null_action action which will be taken (if any) when base_list is empty
type param: string
param: default_action action which will be presented at the bottom of the list. Shouldn't need a target. If not present, null_action is set there.
Both Null and Default actions can be defined outside the base_actions list, as long as they are launched in exec_action
type param: string
"""
if not null_action and len(base_list) == 0:
raise RequirementError('Data list for ListManager can not be empty if there is no null_action')
self.prompt = prompt if prompt else _('Choose an object from the list')
self.null_action = str(null_action)
if not default_action:
self.default_action = self.null_action
else:
self.default_action = str(default_action)
self.cancel_action = str(_('Cancel'))
self.confirm_action = str(_('Confirm and exit'))
self.separator = '==>'
self.bottom_list = [self.confirm_action,self.cancel_action]
self.bottom_item = [self.cancel_action]
self.base_actions = base_actions if base_actions else [str(_('Add')),str(_('Copy')),str(_('Edit')),str(_('Delete'))]
self.base_data = base_list
self.data = copy(base_list) # as refs, changes are immediate
# default values for the null case
self.target = None
self.action = self.null_action
if len(self.data) == 0:
self.exec_action()
def run(self):
while True:
self.data_formatted = self.reformat()
options = self.data_formatted + [self.separator]
if self.default_action:
options += [self.default_action]
options += self.bottom_list
system('clear')
target = Menu(self.prompt,
options,
sort=False,
clear_screen=False,
clear_menu_on_exit=False).run()
if not target or target in self.bottom_list:
break
if target and target == self.separator:
continue
if target and target == self.default_action:
target = None
self.target = None
self.action = self.default_action
self.exec_action()
continue
if isinstance(self.data,dict):
key = list(self.data.keys())[self.data_formatted.index(target)]
self.target = {key: self.data[key]}
else:
self.target = self.data[self.data_formatted.index(target)]
# Possible enhacement. If run_actions returns false a message line indicating the failure
self.run_actions(target)
if not target or target == self.cancel_action: # TODO dubious
return self.base_data # return the original list
else:
return self.data
def run_actions(self,prompt_data=None):
options = self.action_list() + self.bottom_item
prompt = _("Select an action for < {} >").format(prompt_data if prompt_data else self.target)
self.action = Menu(prompt,
options,
sort=False,
skip=False,
clear_screen=False,
clear_menu_on_exit=False,
preset_values=self.bottom_item,
show_search_hint=False).run()
if self.action == self.cancel_action:
return False
else:
return self.exec_action()
"""
The following methods are expected to be overwritten by the user if the needs of the list are beyond the simple case
"""
def reformat(self):
"""
method to get the data in a format suitable to be shown
It is executed once for run loop and processes the whole self.data structure
"""
if isinstance(self.data,dict):
return list(map(lambda x:f"{x} : {self.data[x]}",self.data))
else:
return list(map(lambda x:str(x),self.data))
def action_list(self):
"""
can define alternate action list or customize the list for each item.
Executed after any item is selected, contained in self.target
"""
return self.base_actions
def exec_action(self):
"""
what's executed one an item (self.target) and one action (self.action) is selected.
Should be overwritten by the user
The result is expected to update self.data in this routine, else it is ignored
The basic code is useful for simple lists and dictionaries (key:value pairs, both strings)
"""
# TODO guarantee unicity
if isinstance(self.data,list):
if self.action == str(_('Add')):
self.target = TextInput(_('Add :'),None).run()
self.data.append(self.target)
if self.action == str(_('Copy')):
while True:
target = TextInput(_('Copy to :'),self.target).run()
if target != self.target:
self.data.append(self.target)
break
elif self.action == str(_('Edit')):
tgt = self.target
idx = self.data.index(self.target)
result = TextInput(_('Edite :'),tgt).run()
self.data[idx] = result
elif self.action == str(_('Delete')):
del self.data[self.data.index(self.target)]
elif isinstance(self.data,dict):
# allows overwrites
if self.target:
origkey,origval = list(self.target.items())[0]
else:
origkey = None
origval = None
if self.action == str(_('Add')):
key = TextInput(_('Key :'),None).run()
value = TextInput(_('Value :'),None).run()
self.data[key] = value
if self.action == str(_('Copy')):
while True:
key = TextInput(_('Copy to new key:'),origkey).run()
if key != origkey:
self.data[key] = origval
break
elif self.action == str(_('Edit')):
value = TextInput(_(f'Edit {origkey} :'),origval).run()
self.data[origkey] = value
elif self.action == str(_('Delete')):
del self.data[origkey]
return True
if __name__ == "__main__":
# opciones = ['uno','dos','tres','cuatro']
# opciones = archinstall.list_mirrors()
opciones = {'uno':1,'dos':2,'tres':3,'cuatro':4}
# acciones = ['editar','borrar','añadir']
opciones = ListManager('Vamos alla',opciones,None,_('Add')).run()
print(opciones)

View File

@ -1,6 +1,7 @@
from typing import Dict, List, Union, Any, TYPE_CHECKING
from archinstall.lib.menu.simple_menu import TerminalMenu
from ..exceptions import RequirementError
from ..output import log
@ -22,7 +23,8 @@ class Menu(TerminalMenu):
default_option :str = None,
sort :bool = True,
preset_values :Union[str, List[str]] = None,
cursor_index :int = None
cursor_index :int = None,
**kwargs
):
"""
Creates a new menu
@ -51,6 +53,8 @@ class Menu(TerminalMenu):
:param cursor_index: The position where the cursor will be located. If it is not in range (number of elements of the menu) it goes to the first position
:type cursor_index: int
:param kwargs : any SimpleTerminal parameter
"""
# we guarantee the inmutability of the options outside the class.
# an unknown number of iterables (.keys(),.values(),generator,...) can't be directly copied, in this case
@ -103,19 +107,23 @@ class Menu(TerminalMenu):
cursor = "> "
main_menu_cursor_style = ("fg_cyan", "bold")
main_menu_style = ("bg_blue", "fg_gray")
# defaults that can be changed up the stack
kwargs['clear_screen'] = kwargs.get('clear_screen',True)
kwargs['show_search_hint'] = kwargs.get('show_search_hint',True)
kwargs['cycle_cursor'] = kwargs.get('cycle_cursor',True)
super().__init__(
menu_entries=self.menu_options,
title=menu_title,
menu_cursor=cursor,
menu_cursor_style=main_menu_cursor_style,
menu_highlight_style=main_menu_style,
cycle_cursor=True,
clear_screen=True,
# cycle_cursor=True,
# clear_screen=True,
multi_select=multi,
show_search_hint=True,
# show_search_hint=True,
preselected_entries=self.preset_values,
cursor_index=self.cursor_index
cursor_index=self.cursor_index,
**kwargs,
)
def _show(self):