You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

302 lines
12 KiB
Python

from itertools import zip_longest
from math import inf
import math
import numpy as np
import shutil
class UI():
console_width = 80
console_height = 24
@staticmethod
def read_particle(prompt, str_options, dtype=str, interval=[-inf,inf]):
'''
Read particle from user from a given dtype or from a str_options list
Parameters
----------
prompt : `str`
prompt to show user before reading input
str_options : `list`
list of str options (in addition to dtype if dtype is not str)
dtype : `class`
if dtype is str, then user must choose a value from str_options, otherwise it can also send a dtype value
interval : `list`
[>=min,<max] interval for numeric dtypes
Returns
-------
choice : `int` or dtype
index of str_options (int) or value (dtype)
is_str_option : `bool`
True if `choice` is an index from str_options
'''
# Check if user has no choice
if dtype is str and len(str_options) == 1:
print(prompt, str_options[0], sep="")
return 0, True
elif dtype is int and interval[0] == interval[1]-1:
print(prompt, interval[0], sep="")
return interval[0], False
while True:
inp = input(prompt)
if inp in str_options:
return str_options.index(inp), True
if dtype is not str:
try:
inp = dtype(inp)
if inp >= interval[0] and inp < interval[1]:
return inp, False
except:
pass
print("Error: illegal input! Options:", str_options, f" or {dtype}" if dtype != str else "")
@staticmethod
def read_int(prompt, min, max):
'''
Read int from user in a given interval
:param prompt: prompt to show user before reading input
:param min: minimum input (inclusive)
:param max: maximum input (exclusive)
:return: choice
'''
while True:
inp = input(prompt)
try:
inp = int(inp)
assert inp >= min and inp < max
return inp
except:
print(f"Error: illegal input! Choose number between {min} and {max-1}")
@staticmethod
def print_table(data, titles=None, alignment=None, cols_width=None, cols_per_title=None, margins=None, numbering=None, prompt=None):
'''
Print table
Parameters
----------
data : `list`
list of columns, where each column is a list of items
titles : `list`
list of titles for each column, default is `None` (no titles)
alignment : `list`
list of alignments per column (excluding titles), default is `None` (left alignment for all cols)
cols_width : `list`
list of widths per column, default is `None` (fit to content)
Positive values indicate a fixed column width
Zero indicates that the column will fit its content
cols_per_title : `list`
maximum number of subcolumns per title, default is `None` (1 subcolumn per title)
margins : `list`
number of added leading and trailing spaces per column, default is `None` (margin=2 for all columns)
numbering : `list`
list of booleans per columns, indicating whether to assign numbers to each option
prompt : `str`
the prompt string, if given, is printed after the table before reading input
Returns
-------
index : `int`
returns global index of selected item (relative to table)
col_index : `int`
returns local index of selected item (relative to column)
column : `int`
returns number of column of selected item (starts at 0)
* if `numbering` or `prompt` are `None`, `None` is returned
Example
-------
titles = ["Name","Age"]
data = [[John,Graciete], [30,50]]
alignment = ["<","^"] # 1st column is left-aligned, 2nd is centered
cols_width = [10,5] # 1st column's width=10, 2nd column's width=5
margins = [3,3]
numbering = [True,False] # prints: [0-John,1-Graciete][30,50]
prompt = "Choose a person:"
'''
#--------------------------------------------- parameters
cols_no = len(data)
if alignment is None:
alignment = ["<"]*cols_no
if cols_width is None:
cols_width = [0]*cols_no
if numbering is None:
numbering = [False]*cols_no
any_numbering = False
else:
any_numbering = True
if margins is None:
margins = [2]*cols_no
# Fit column to content + margin, if required
subcol = [] # subcolumn length and widths
for i in range(cols_no):
subcol.append([[],[]])
if cols_width[i] == 0:
numbering_width = 4 if numbering[i] else 0
if cols_per_title is None or cols_per_title[i] < 2:
cols_width[i] = max([len(str(item))+numbering_width for item in data[i]]) + margins[i]*2
else:
subcol[i][0] = math.ceil(len(data[i])/cols_per_title[i]) # subcolumn maximum length
cols_per_title[i] = math.ceil(len(data[i])/subcol[i][0]) # reduce number of columns as needed
cols_width[i] = margins[i]*(1+cols_per_title[i]) - (1 if numbering[i] else 0) # remove one if numbering, same as when printing
for j in range(cols_per_title[i]):
subcol_data_width = max([len(str(item))+numbering_width for item in data[i][j*subcol[i][0]:j*subcol[i][0]+subcol[i][0]]])
cols_width[i] += subcol_data_width # add subcolumn data width to column width
subcol[i][1].append(subcol_data_width) # save subcolumn data width
if titles is not None: # expand to acomodate titles if needed
cols_width[i] = max(cols_width[i], len(titles[i]) + margins[i]*2 )
if any_numbering:
no_of_items=0
cumulative_item_per_col=[0] # useful for getting the local index
for i in range(cols_no):
assert type(data[i]) == list, "In function 'print_table', 'data' must be a list of lists!"
if numbering[i]:
data[i] = [f"{n+no_of_items:3}-{d}" for n,d in enumerate(data[i])]
no_of_items+=len(data[i])
cumulative_item_per_col.append(no_of_items)
table_width = sum(cols_width)+cols_no-1
#--------------------------------------------- col titles
print(f'{"="*table_width}')
if titles is not None:
for i in range(cols_no):
print(f'{titles[i]:^{cols_width[i]}}', end='|' if i < cols_no - 1 else '')
print()
for i in range(cols_no):
print(f'{"-"*cols_width[i]}', end='+' if i < cols_no - 1 else '')
print()
#--------------------------------------------- merge subcolumns
if cols_per_title is not None:
for i,col in enumerate(data):
if cols_per_title[i] < 2:
continue
for k in range(subcol[i][0]): # create merged items
col[k] = (" "*margins[i]).join( f'{col[item]:{alignment[i]}{subcol[i][1][subcol_idx]}}'
for subcol_idx, item in enumerate(range(k,len(col),subcol[i][0])) )
del col[subcol[i][0]:] # delete repeated items
#--------------------------------------------- col items
for line in zip_longest(*data):
for i,item in enumerate(line):
l_margin = margins[i]-1 if numbering[i] else margins[i] # adjust margins when there are numbered options
item = "" if item is None else f'{" "*l_margin}{item}{" "*margins[i]}' # add margins
print(f'{item:{alignment[i]}{cols_width[i]}}', end='')
if i < cols_no - 1:
print(end='|')
print(end="\n")
print(f'{"="*table_width}')
#--------------------------------------------- prompt
if prompt is None:
return None
if not any_numbering:
print(prompt)
return None
index = UI.read_int(prompt, 0, no_of_items)
for i,n in enumerate(cumulative_item_per_col):
if index < n:
return index, index-cumulative_item_per_col[i-1], i-1
raise ValueError('Failed to catch illegal input')
@staticmethod
def print_list(data, numbering=True, prompt=None, divider=" | ", alignment="<", min_per_col=6):
'''
Print list - prints list, using as many columns as possible
Parameters
----------
data : `list`
list of items
numbering : `bool`
assigns number to each option
prompt : `str`
the prompt string, if given, is printed after the table before reading input
divider : `str`
string that divides columns
alignment : `str`
f-string style alignment ( '<', '>', '^' )
min_per_col : int
avoid splitting columns with fewer items
Returns
-------
item : `int`, item
returns tuple with global index of selected item and the item object,
or `None` (if `numbering` or `prompt` are `None`)
'''
WIDTH = shutil.get_terminal_size()[0]
data_size = len(data)
items = []
items_len = []
#--------------------------------------------- Add numbers, margins and divider
for i in range(data_size):
number = f"{i}-" if numbering else ""
items.append( f"{divider}{number}{data[i]}" )
items_len.append( len(items[-1]) )
max_cols = np.clip((WIDTH+len(divider)) // min(items_len),1,math.ceil(data_size/max(min_per_col,1))) # width + len(divider) because it is not needed in last col
#--------------------------------------------- Check maximum number of columns, considering content width (min:1)
for i in range(max_cols,0,-1):
cols_width = []
cols_items = []
table_width = 0
a,b = divmod(data_size,i)
for col in range(i):
start = a*col + min(b,col)
end = start+a+(1 if col<b else 0)
cols_items.append( items[start:end] )
col_width = max(items_len[start:end])
cols_width.append( col_width )
table_width += col_width
if table_width <= WIDTH+len(divider):
break
table_width -= len(divider)
#--------------------------------------------- Print columns
print("="*table_width)
for row in range(math.ceil(data_size / i)):
for col in range(i):
content = cols_items[col][row] if len(cols_items[col]) > row else divider # print divider when there are no items
if col == 0:
l = len(divider)
print(end=f"{content[l:]:{alignment}{cols_width[col]-l}}") # remove divider from 1st col
else:
print(end=f"{content :{alignment}{cols_width[col] }}")
print()
print("="*table_width)
#--------------------------------------------- Prompt
if prompt is None:
return None
if numbering is None:
return None
else:
idx = UI.read_int( prompt, 0, data_size )
return idx, data[idx]