Coverage for src/hods/tui/base/edit.py: 100.00%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

147 statements  

1"""hods - home directory synchronization. 

2 

3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de> 

4 

5hods is free software: you can redistribute it and/or modify 

6it under the terms of the GNU General Public License as published by 

7the Free Software Foundation, either version 3 of the License, or 

8(at your option) any later version. 

9 

10hods is distributed in the hope that it will be useful, 

11but WITHOUT ANY WARRANTY; without even the implied warranty of 

12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

13GNU General Public License for more details. 

14 

15You should have received a copy of the GNU General Public License 

16along with this program. If not, see <http://www.gnu.org/licenses/>. 

17""" 

18import urwid 

19 

20from hods.tui.base.list import button_list 

21from hods.tui.base.view import ErrorWindow, ListBoxWindow 

22from hods.tui.base.widgets import FocusMap 

23 

24 

25class ValidationError(Exception): 

26 """An `Exception` to easily cancel saving. 

27 

28 It displays a user-friendly error message in a dialog. 

29 """ 

30 

31 def __init__(self, message): 

32 """Initialize error. 

33 

34 :param message: `str` - User-friendly error message 

35 """ 

36 super().__init__(message) 

37 self.message = message 

38 

39 

40class EditWindow(ListBoxWindow): 

41 """Base window to show forms. 

42 

43 It takes a list of widgets to display and appends *Save* and 

44 *Cancel* buttons. Pressing them will close the window by default. 

45 

46 Overwrite `save` to add an action to the *Save* button. 

47 Use `ValidationError` when validating your data to show 

48 an Error dialog. 

49 

50 .. note:: `on_close` is called *before* `on_save` and `on_abort`. 

51 """ 

52 

53 ValidationError = ValidationError 

54 

55 save_button_text = 'Save' 

56 

57 close_on_save = True 

58 

59 def __init__(self, app, **kwargs): 

60 """Initialize widget. 

61 

62 Display given items and *Save* and *Cancel* buttons in a `urwid.ListBox`. 

63 

64 :param app: `hods.app.App` instance 

65 :param items: widgets to display 

66 :param kwargs: Additional options, passed to `Window` 

67 """ 

68 self._on_save = kwargs.pop('on_save', None) 

69 self._defer_save = kwargs.pop('defer_save', True) 

70 self._on_abort = kwargs.pop('on_abort', None) 

71 self._defer_abort = kwargs.pop('defer_abort', True) 

72 

73 self.buttons = [ 

74 (urwid.Button(self.save_button_text, on_press=self._on_press_save), 'success'), 

75 (urwid.Button('Cancel', on_press=lambda btn: self.close()), 'warning'), 

76 ] 

77 

78 additional_buttons = self.get_additional_buttons() 

79 if additional_buttons is not None: # pragma: no branch 

80 self.buttons.append(urwid.Divider()) 

81 self.buttons.extend(additional_buttons) 

82 

83 super().__init__(app, **kwargs) 

84 

85 self.saved = False 

86 self.save_args = () 

87 

88 def get_body(self): 

89 """Get the main widget to display in this window.""" 

90 self.columns = urwid.Columns([ 

91 self.wrap_body(super().get_body()), 

92 button_list(self.buttons, column=True), 

93 ], dividechars=1) 

94 return self.columns 

95 

96 def wrap_body(self, body): 

97 """Overwrite to wrap the body listbox widget.""" 

98 return body 

99 

100 def get_additional_buttons(self): 

101 """Overwrite to change displayed buttons.""" 

102 return [] 

103 

104 def _on_press_save(self, button): 

105 """Call `save` when *Save* button is pressed. 

106 

107 If a `ValidationError` occurs, display an 

108 `ErrorWindow` with the error message. 

109 """ 

110 try: 

111 data = self.clean() 

112 except self.ValidationError as e: 

113 ErrorWindow(self.app, e.message, title='Validation Error', parent=self).show() 

114 return 

115 

116 args = self.save(data) 

117 if args is None: 

118 args = () 

119 elif not isinstance(args, tuple): # pragma: no branch 

120 args = (args,) 

121 

122 self.saved = True 

123 self.save_args = args 

124 if self.close_on_save: 

125 self.close() 

126 

127 def on_save(self): 

128 """Called when closing the window after successful save.""" 

129 self.app.check_defer(self._on_save, *self.save_args, defer=self._defer_save) 

130 

131 def on_abort(self): 

132 """Called when closing the window without a successful save.""" 

133 self.app.check_defer(self._on_abort, defer=self._defer_abort) 

134 

135 def on_close(self): 

136 """Called by the app before switching to another widget.""" 

137 super().on_close() 

138 if self.saved: 

139 self.on_save() 

140 else: 

141 self.on_abort() 

142 

143 def clean(self): 

144 """Overwrite to collect and validate data. 

145 

146 Raise `hods.widgets.window.EditWindow.ValidationError` 

147 to display an error message dialog. 

148 """ 

149 return {} 

150 

151 def save(self, data): 

152 """Overwrite to store validated data.""" 

153 return data 

154 

155 

156def radio_buttons(choices, initial=None, on_change=None, getset=None, group=None, columns=False): 

157 """Build a radio button group for the given choices.""" 

158 if group is None: # pragma: no branch 

159 group = [] 

160 

161 if getset is not None: 

162 obj, attr = getset 

163 initial = getattr(obj, attr) 

164 _on_change = on_change 

165 

166 def on_change(btn, state, user_data=None): 

167 if state: 

168 setattr(obj, attr, user_data) 

169 if callable(_on_change): 

170 _on_change(btn, state, user_data=user_data) 

171 

172 for label, value in choices: 

173 btn = urwid.RadioButton( 

174 group, 

175 label, 

176 state=initial == value, 

177 on_state_change=on_change, 

178 user_data=value) 

179 

180 if columns: 

181 yield len(label) + 4, btn 

182 else: 

183 yield btn 

184 

185 

186class ObjectEditWindow(EditWindow): 

187 """A window to edit object attributes.""" 

188 

189 def __init__(self, app, obj, **kwargs): 

190 """Initialize window widget. 

191 

192 :param app: `hods.tui.__main__.App` object. Passed to 

193 mixed class! 

194 :param obj: The object to edit. 

195 :param kwargs: Pass to mixed class. 

196 """ 

197 self.object = obj 

198 super().__init__(app, **kwargs) 

199 

200 def save(self, data): 

201 """Store the attributes in cleaned data and return the object.""" 

202 for name, value in data.items(): 

203 setattr(self.object, name, value) 

204 self.app.save() 

205 self.app.refresh() 

206 return self.object 

207 

208 

209class ObjectDeleteMixin: 

210 """Mixin to add a delete button to a ObjectEditWindow.""" 

211 

212 def get_additional_buttons(self): 

213 """Return the additional delete button.""" 

214 yield from super().get_additional_buttons() 

215 yield urwid.Button('Delete', on_press=self.delete), 'error' 

216 

217 def get_delete_choices(self): 

218 """Return the choices to display in the delete dialog.""" 

219 yield 'delete', 'Delete', 'warning' 

220 yield 'cancel', 'Cancel', 'warning' 

221 

222 def get_delete_text(self): 

223 """Return the message to display in the delete dialog.""" 

224 dir_text = '' 

225 if self.object.isdir(): 

226 dir_text = '\nand all of its contents' 

227 return 'Are you sure you want to delete:\n"{}"{}?'.format(self.object, dir_text) 

228 

229 def delete(self, user_data=None): 

230 """Show the delete dialog.""" 

231 ChoiceWindow( 

232 self.app, 

233 text=self.get_delete_text(), 

234 choices=list(self.get_delete_choices()), 

235 on_select=self.on_delete_select, 

236 attr='error', 

237 title='Delete', 

238 height=8, 

239 parent=self, 

240 ).show() 

241 

242 def on_delete_select(self, choice): 

243 """Delete the object and close the window if delete was selected.""" 

244 if choice == 'delete': 

245 self.object.delete() 

246 self.app.save() 

247 self.close() 

248 

249 

250class ChoiceWindow(ListBoxWindow): 

251 """A base choice window that closes on select.""" 

252 

253 def __init__(self, app, choices, **kwargs): 

254 """Initialize widget. 

255 

256 :param app: `App` instance 

257 :param choices: list of tuples containing the id, label, and 

258 style-attribute for each choice 

259 :param kwargs: Additional options, passed to `Window` 

260 """ 

261 self.choices = choices 

262 

263 self.text = kwargs.pop('text', '') 

264 self.focus_column = kwargs.pop('focus_column', None) 

265 self.stretch = kwargs.pop('stretch', False) 

266 self.vertical = kwargs.pop('vertical', False) 

267 if self.vertical: 

268 self.stretch = False 

269 

270 self._on_select = kwargs.pop('on_select') 

271 

272 kwargs.setdefault('height', len(self.text.splitlines()) + 5) 

273 super().__init__(app, **kwargs) 

274 

275 def get_items(self): 

276 """Collect widgets to display.""" 

277 yield urwid.Text(self.text, align=urwid.CENTER) 

278 yield urwid.Divider() 

279 button_widgets = list(self.get_choice_widgets()) 

280 if self.vertical: 

281 yield urwid.Pile(button_widgets, focus_item=self.focus_column) 

282 else: 

283 yield urwid.Columns(button_widgets, dividechars=1, focus_column=self.focus_column) 

284 

285 def get_choice_widgets(self): 

286 """Collect choice button widgets.""" 

287 if not self.stretch and not self.vertical: 

288 yield urwid.Text('') 

289 

290 for options in self.choices: 

291 if len(options) == 3: 

292 user_data, label, attr = options 

293 else: 

294 user_data, label = options 

295 attr = 'edit' 

296 

297 btn = urwid.Button(str(label), on_press=self.on_choice_press, user_data=str(user_data)) 

298 widget = FocusMap(btn, attr) 

299 

300 if self.vertical: 

301 widget = urwid.Padding(widget, align='center') 

302 elif not self.stretch: # pragma: no branch # unused 

303 widget = len(str(label)) + 4, widget 

304 

305 yield widget 

306 

307 if self.vertical: 

308 yield urwid.Divider() 

309 

310 if not self.stretch and not self.vertical: 

311 yield urwid.Text('') 

312 

313 def on_choice_press(self, btn, user_data): 

314 """Close the window and call the callback with the selected option.""" 

315 self.close() 

316 self._on_select(user_data)