urwid_ui.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. # urwid user interface for reportbug
  2. # Written by Chris Lawrence <lawrencc@debian.org>
  3. # (C) 2006-08 Chris Lawrence
  4. # Copyright (C) 2008-2016 Sandro Tosi <morph@debian.org>
  5. #
  6. # This program is freely distributable per the following license:
  7. #
  8. # Permission to use, copy, modify, and distribute this software and its
  9. # documentation for any purpose and without fee is hereby granted,
  10. # provided that the above copyright notice appears in all copies and that
  11. # both that copyright notice and this permission notice appear in
  12. # supporting documentation.
  13. #
  14. # I DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL
  15. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL I
  16. # BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY
  17. # DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
  18. # WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
  19. # ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS
  20. # SOFTWARE.
  21. #
  22. # Portions of this file are licensed under the Lesser GNU Public License
  23. # (LGPL) Version 2.1 or later. On Debian systems, this license is available
  24. # in /usr/share/common-licenses/LGPL
  25. import sys
  26. import re
  27. import getpass
  28. from reportbug.exceptions import (
  29. UINotImportable,
  30. NoPackage, NoBugs, NoNetwork, NoReport,
  31. )
  32. from reportbug.urlutils import launch_browser
  33. from text_ui import (
  34. ewrite,
  35. spawn_editor,
  36. system
  37. )
  38. from reportbug import VERSION
  39. try:
  40. import urwid.raw_display
  41. import urwid
  42. except ImportError:
  43. raise UINotImportable('Please install the python-urwid package to use this interface.')
  44. ISATTY = sys.stdin.isatty()
  45. log_message = ewrite
  46. # Start a urwid session
  47. def initialize_urwid_ui():
  48. ui = urwid.raw_display.Screen()
  49. ui.register_palette(palette)
  50. # Improve responsiveness of UI
  51. ui.set_input_timeouts(max_wait=0.1)
  52. return ui
  53. # Empty function to satisfy ui.run_wrapper()
  54. def nullfunc():
  55. pass
  56. # Widgets ripped mercilessly from urwid examples (dialog.py)
  57. class buttonpush(Exception):
  58. pass
  59. def button_press(button):
  60. raise buttonpush(button.exitcode)
  61. class SelectableText(urwid.Edit):
  62. def valid_char(self, ch):
  63. return False
  64. class dialog(object):
  65. def __init__(self, message, body=None, width=None, height=None,
  66. title='', long_message=''):
  67. self.body = body
  68. self.scrollmode = False
  69. if not body:
  70. if long_message:
  71. box = SelectableText(edit_text=long_message)
  72. box.set_edit_pos(0)
  73. self.body = body = urwid.ListBox([box])
  74. self.scrollmode = True
  75. else:
  76. self.body = body = urwid.Filler(urwid.Divider(), 'top')
  77. if not width:
  78. width = ('relative', 80)
  79. if not height:
  80. height = ('relative', 80)
  81. self.frame = urwid.Frame(body, focus_part='footer')
  82. if message:
  83. self.frame.header = urwid.Pile([urwid.Text(message),
  84. urwid.Divider()])
  85. w = self.frame
  86. # pad area around listbox
  87. w = urwid.Padding(w, ('fixed left', 2), ('fixed right', 2))
  88. w = urwid.Filler(w, ('fixed top', 1), ('fixed bottom', 1))
  89. w = urwid.AttrWrap(w, 'body')
  90. if title:
  91. w = urwid.Frame(w)
  92. w.header = urwid.Text(('title', title))
  93. # "shadow" effect
  94. w = urwid.Columns([w, ('fixed', 1, urwid.AttrWrap(urwid.Filler(urwid.Text(('border', ' ')), "top"), 'shadow'))])
  95. w = urwid.Frame(w, footer=urwid.AttrWrap(urwid.Text(('border', ' ')), 'shadow'))
  96. # outermost border area
  97. w = urwid.Padding(w, 'center', width)
  98. w = urwid.Filler(w, 'middle', height)
  99. w = urwid.AttrWrap(w, 'border')
  100. self.view = w
  101. def add_buttons(self, buttons, default=0, vertical=False):
  102. l = []
  103. for name, exitcode in buttons:
  104. if exitcode == '---':
  105. # Separator is just a text label
  106. b = urwid.Text(name)
  107. b = urwid.AttrWrap(b, 'scrolllabel')
  108. else:
  109. b = urwid.Button(name, self.button_press)
  110. b.exitcode = exitcode
  111. b = urwid.AttrWrap(b, 'selectable', 'focus')
  112. l.append(b)
  113. if vertical:
  114. box = urwid.ListBox(l)
  115. box.set_focus(default or 0)
  116. self.buttons = urwid.Frame(urwid.AttrWrap(box, 'selectable'))
  117. self.frame.footer = urwid.BoxAdapter(self.buttons, min(len(l), 10))
  118. else:
  119. self.buttons = urwid.GridFlow(l, 12, 3, 1, 'center')
  120. self.buttons.set_focus(default or 0)
  121. self.frame.footer = urwid.Pile([urwid.Divider(), self.buttons],
  122. focus_item=1)
  123. def button_press(self, button):
  124. raise buttonpush(button.exitcode)
  125. def run(self):
  126. # self.ui.set_mouse_tracking()
  127. size = self.ui.get_cols_rows()
  128. try:
  129. while True:
  130. canvas = self.view.render(size, focus=True)
  131. self.ui.draw_screen(size, canvas)
  132. keys = None
  133. while not keys:
  134. keys = self.ui.get_input()
  135. for k in keys:
  136. if urwid.util.is_mouse_event(k):
  137. event, button, col, row = k
  138. self.view.mouse_event(size,
  139. event, button, col, row,
  140. focus=True)
  141. if k == 'window resize':
  142. size = self.ui.get_cols_rows()
  143. k = self.view.keypress(size, k)
  144. if k:
  145. self.unhandled_key(size, k)
  146. except buttonpush, e:
  147. return self.on_exit(e.args[0])
  148. def on_exit(self, exitcode):
  149. return exitcode
  150. def unhandled_key(self, size, k):
  151. if k in ('tab', 'shift tab'):
  152. focus = self.frame.focus_part
  153. if focus == 'footer':
  154. self.frame.set_focus('body')
  155. else:
  156. self.frame.set_focus('footer')
  157. if k in ('up', 'page up', 'down', 'page down'):
  158. if self.scrollmode:
  159. self.frame.set_focus('body')
  160. self.body.keypress(size, k)
  161. elif k in ('up', 'page up'):
  162. self.frame.set_focus('body')
  163. else:
  164. self.frame.set_focus('footer')
  165. if k == 'enter':
  166. # pass enter to the "ok" button
  167. self.frame.set_focus('footer')
  168. self.view.keypress(size, k)
  169. def main(self, ui=None):
  170. if ui:
  171. self.ui = ui
  172. else:
  173. self.ui = initialize_urwid_ui()
  174. return self.ui.run_wrapper(self.run)
  175. class displaybox(dialog):
  176. def show(self, ui=None):
  177. if ui:
  178. self.ui = ui
  179. else:
  180. self.ui = initialize_urwid_ui()
  181. size = self.ui.get_cols_rows()
  182. canvas = self.view.render(size, focus=True)
  183. self.ui.start()
  184. self.ui.draw_screen(size, canvas)
  185. self.ui.stop()
  186. class textentry(dialog):
  187. def __init__(self, text, width=None, height=None, multiline=False,
  188. title='', edit_text=''):
  189. self.edit = urwid.Edit(edit_text=edit_text, multiline=multiline)
  190. body = urwid.ListBox([self.edit])
  191. body = urwid.AttrWrap(body, 'selectable', 'focustext')
  192. if not multiline:
  193. body = urwid.Pile([('fixed', 1, body), urwid.Divider()])
  194. body = urwid.Filler(body)
  195. dialog.__init__(self, text, body, width, height, title)
  196. self.frame.set_focus('body')
  197. def on_exit(self, exitcode):
  198. return exitcode, self.edit.get_edit_text()
  199. class listdialog(dialog):
  200. def __init__(self, text, widgets, has_default=False, width=None,
  201. height=None, title='', buttonwidth=12):
  202. l = []
  203. self.items = []
  204. for (w, label) in widgets:
  205. self.items.append(w)
  206. if label:
  207. w = urwid.Columns([('fixed', buttonwidth, w),
  208. urwid.Text(label)], 2)
  209. w = urwid.AttrWrap(w, 'selectable', 'focus')
  210. l.append(w)
  211. lb = urwid.ListBox(l)
  212. lb = urwid.AttrWrap(lb, "selectable")
  213. dialog.__init__(self, text, height=height, width=width, body=lb,
  214. title=title)
  215. self.frame.set_focus('body')
  216. def on_exit(self, exitcode):
  217. """Print the tag of the item selected."""
  218. if exitcode:
  219. return exitcode, None
  220. for i in self.items:
  221. if hasattr(i, 'get_state') and i.get_state():
  222. return exitcode, i.get_label()
  223. return exitcode, None
  224. class checklistdialog(listdialog):
  225. def on_exit(self, exitcode):
  226. """
  227. Mimick dialog(1)'s --checklist exit.
  228. Put each checked item in double quotes with a trailing space.
  229. """
  230. if exitcode:
  231. return exitcode, []
  232. l = []
  233. for i in self.items:
  234. if i.get_state():
  235. l.append(i.get_label())
  236. return exitcode, l
  237. def display_message(message, *args, **kwargs):
  238. if args:
  239. message = message % tuple(args)
  240. if 'title' in kwargs:
  241. title = kwargs['title']
  242. else:
  243. title = ''
  244. if 'ui' in kwargs:
  245. ui = kwargs['ui']
  246. else:
  247. ui = None
  248. # Rewrap the message
  249. chunks = re.split('\n\n+', message)
  250. chunks = [re.sub(r'\s+', ' ', x).strip() for x in chunks]
  251. message = '\n\n'.join(chunks).strip()
  252. box = displaybox('', long_message=message, title=title or VERSION)
  253. box.show(ui)
  254. def long_message(message, *args, **kwargs):
  255. if args:
  256. message = message % tuple(args)
  257. if 'title' in kwargs:
  258. title = kwargs['title']
  259. else:
  260. title = ''
  261. if 'ui' in kwargs:
  262. ui = kwargs['ui']
  263. else:
  264. ui = None
  265. # Rewrap the message
  266. chunks = re.split('\n\n+', message)
  267. chunks = [re.sub(r'\s+', ' ', x).strip() for x in chunks]
  268. message = '\n\n'.join(chunks).strip()
  269. box = dialog('', long_message=message, title=title or VERSION)
  270. box.add_buttons([("OK", 0)])
  271. box.main(ui)
  272. final_message = long_message
  273. display_report = long_message
  274. display_failure = long_message
  275. def select_options(msg, ok, help=None, allow_numbers=False, nowrap=False,
  276. ui=None, title=None):
  277. box = dialog('', long_message=msg, height=('relative', 80),
  278. title=title or VERSION)
  279. if not help:
  280. help = {}
  281. buttons = []
  282. default = None
  283. for i, option in enumerate(ok):
  284. if option.isupper():
  285. default = i
  286. option = option.lower()
  287. buttons.append((help.get(option, option), option))
  288. box.add_buttons(buttons, default, vertical=True)
  289. result = box.main(ui)
  290. return result
  291. def yes_no(msg, yeshelp, nohelp, default=True, nowrap=False, ui=None):
  292. box = dialog('', long_message=msg + "?", title=VERSION)
  293. box.add_buttons([('Yes', True), ('No', False)], default=1 - int(default))
  294. result = box.main(ui)
  295. return result
  296. def get_string(prompt, options=None, title=None, empty_ok=False, force_prompt=False,
  297. default='', ui=None):
  298. if title:
  299. title = '%s: %s' % (VERSION, title)
  300. else:
  301. title = VERSION
  302. box = textentry(prompt, title=title, edit_text=default)
  303. box.add_buttons([("OK", 0)])
  304. code, text = box.main(ui)
  305. return text or default
  306. def get_multiline(prompt, options=None, title=None, force_prompt=False,
  307. ui=None):
  308. if title:
  309. title = '%s: %s' % (VERSION, title)
  310. else:
  311. title = VERSION
  312. box = textentry(prompt, multiline=True)
  313. box.add_buttons([("OK", 0)])
  314. code, text = box.main(ui)
  315. l = text.split('\n')
  316. return l
  317. def get_password(prompt=None):
  318. return getpass.getpass(prompt)
  319. def menu(par, options, prompt, default=None, title=None, any_ok=False,
  320. order=None, extras=None, multiple=False, empty_ok=False, ui=None,
  321. oklabel='Ok', cancellabel='Cancel', quitlabel=None):
  322. if not extras:
  323. extras = []
  324. else:
  325. extras = list(extras)
  326. if not default:
  327. default = ''
  328. if title:
  329. title = '%s: %s' % (VERSION, title)
  330. else:
  331. title = VERSION
  332. if isinstance(options, dict):
  333. options = options.copy()
  334. # Convert to a list
  335. if order:
  336. olist = []
  337. for key in order:
  338. if key in options:
  339. olist.append((key, options[key]))
  340. del options[key]
  341. # Append anything out of order
  342. options = options.items()
  343. options.sort()
  344. for option in options:
  345. olist.append(option)
  346. options = olist
  347. else:
  348. options = options.items()
  349. options.sort()
  350. opts = []
  351. for option, desc in options:
  352. if desc:
  353. opts.append((option, re.sub(r'\s+', ' ', desc)))
  354. else:
  355. opts.append((option, desc))
  356. options = opts
  357. if multiple:
  358. widgets = [(urwid.CheckBox(option, state=(option == default)),
  359. desc or '') for (option, desc) in options]
  360. box = checklistdialog(par, widgets, height=('relative', 80),
  361. title=title)
  362. if quitlabel:
  363. box.add_buttons([(oklabel, 0), (cancellabel, -1),
  364. (quitlabel, -2)])
  365. else:
  366. box.add_buttons([(oklabel, 0), (cancellabel, -1)])
  367. result, chosen = box.main(ui)
  368. if result < 0:
  369. # We return None to differentiate a Cancel/Quit from no selection, []
  370. return None
  371. return chosen
  372. # Single menu option only
  373. def label_button(option, desc):
  374. return option
  375. widgets = []
  376. rlist = []
  377. for option, desc in options:
  378. if option == '---':
  379. # Separator is just a text label
  380. b = urwid.Text(desc)
  381. b = urwid.AttrWrap(b, 'scrolllabel')
  382. desc = ''
  383. else:
  384. b = urwid.RadioButton(rlist, label_button(option, desc), state=(option == default))
  385. b.exitcode = option
  386. b = urwid.AttrWrap(b, 'selectable', 'focus')
  387. widgets.append((b, desc))
  388. box = listdialog(par, widgets, height=('relative', 80),
  389. title=title, buttonwidth=12)
  390. if quitlabel:
  391. box.add_buttons([(oklabel, 0), (cancellabel, -1), (quitlabel, -2)])
  392. else:
  393. box.add_buttons([(oklabel, 0), (cancellabel, -1)])
  394. focus = 0
  395. if default:
  396. for i, opt in enumerate(options):
  397. if opt[0] == default:
  398. focus = i
  399. break
  400. result, chosen = box.main(ui)
  401. if result < 0:
  402. return result
  403. return chosen
  404. # A real file dialog would be nice here
  405. def get_filename(prompt, title=None, force_prompt=False, default=''):
  406. return get_string(prompt, title=title, force_prompt=force_prompt,
  407. default=default)
  408. def select_multiple(par, options, prompt, title=None, order=None, extras=None):
  409. return menu(par, options, prompt, title=title, order=order, extras=extras,
  410. multiple=True, empty_ok=False)
  411. # Things that are very UI dependent go here
  412. def show_report(number, system, mirrors,
  413. http_proxy, timeout, screen=None, queryonly=False, title='',
  414. archived='no', mbox_reader_cmd=None):
  415. from reportbug import debbugs
  416. ui = screen
  417. if not ui:
  418. ui = initialize_urwid_ui()
  419. sysinfo = debbugs.SYSTEMS[system]
  420. display_message('Retrieving report #%d from %s bug tracking system...',
  421. number, sysinfo['name'], title=title, ui=ui)
  422. info = debbugs.get_report(number, timeout, system, mirrors=mirrors,
  423. http_proxy=http_proxy, archived=archived)
  424. if not info:
  425. long_message('Bug report #%d not found.', number, title=title, ui=ui)
  426. return
  427. options = dict(o='Ok', d='More details (launch browser)',
  428. m='Submit more information', q='Quit')
  429. valid = 'Odmq'
  430. while 1:
  431. (buginfo, bodies) = info
  432. body = bodies[0]
  433. r = select_options(body, valid, title=buginfo.subject, ui=ui, help=options)
  434. ui = None
  435. if not r or (r == 'o'):
  436. break
  437. elif r == 'q':
  438. return -1
  439. elif r == 'm':
  440. return buginfo
  441. launch_browser(debbugs.get_report_url(system, number, archived))
  442. return
  443. def handle_bts_query(package, bts, timeout, mirrors=None, http_proxy="",
  444. queryonly=False, screen=None, title="", archived='no',
  445. source=False, version=None, mbox=False, buglist=None,
  446. mbox_reader_cmd=None, latest_first=False):
  447. from reportbug import debbugs
  448. sysinfo = debbugs.SYSTEMS[bts]
  449. root = sysinfo.get('btsroot')
  450. if not root:
  451. ewrite("%s bug tracking system has no web URL; bypassing query.\n",
  452. sysinfo['name'])
  453. return
  454. ui = screen
  455. if not ui:
  456. ui = initialize_urwid_ui()
  457. if isinstance(package, basestring):
  458. pkgname = package
  459. if source:
  460. pkgname += ' (source)'
  461. display_message('Querying %s bug tracking system for reports on %s',
  462. debbugs.SYSTEMS[bts]['name'], pkgname,
  463. ui=ui, title=title)
  464. else:
  465. display_message('Querying %s bug tracking system for reports %s',
  466. debbugs.SYSTEMS[bts]['name'],
  467. ' '.join([str(x) for x in package]), ui=ui, title=title)
  468. result = None
  469. try:
  470. (count, sectitle, hierarchy) = debbugs.get_reports(
  471. package, timeout, bts, mirrors=mirrors, version=version,
  472. http_proxy=http_proxy, archived=archived, source=source)
  473. except Exception, e:
  474. ui.run_wrapper(nullfunc)
  475. long_message('Unable to connect to %s BTS.', sysinfo['name'],
  476. title=title)
  477. raise NoBugs
  478. try:
  479. if not count:
  480. ui.run_wrapper(nullfunc)
  481. if hierarchy is None:
  482. raise NoPackage
  483. else:
  484. raise NoBugs
  485. else:
  486. if count > 1:
  487. sectitle = '%d bug reports found' % (count,)
  488. else:
  489. sectitle = '%d bug report found' % (count,)
  490. buglist = []
  491. for (t, bugs) in hierarchy:
  492. bcount = len(bugs)
  493. buglist.append(('---', t))
  494. buglist_tmp = {}
  495. for bug in bugs:
  496. # show if the bugs is already resolved
  497. done = ''
  498. if bug.pending == 'done':
  499. done = ' [RESOLVED]'
  500. buglist_tmp[bug.bug_num] = bug.subject + done
  501. # append the sorted list of bugs for this severity
  502. map(buglist.append, [(str(k), buglist_tmp[k]) for k in sorted(buglist_tmp, reverse=latest_first)])
  503. p = buglist[1][0]
  504. # scr.popWindow()
  505. if queryonly:
  506. cancellabel = 'Exit'
  507. quitlabel = None
  508. else:
  509. cancellabel = 'New bug'
  510. quitlabel = 'Quit'
  511. while True:
  512. info = menu('Select a bug to read (and possibly report more information) or report a new bug:', buglist,
  513. '', ui=ui, title=sectitle, default=p,
  514. oklabel='Read bug',
  515. cancellabel=cancellabel,
  516. quitlabel=quitlabel)
  517. ui = None
  518. if info < 0:
  519. if info == -1:
  520. result = None
  521. # -2 is the Quit response, triggers the exiting way in main
  522. elif info == -2:
  523. raise NoReport
  524. else:
  525. # uniform to return Bugreport instance
  526. result = debbugs.get_report(info, timeout)[0]
  527. break
  528. else:
  529. p = info
  530. res = show_report(int(p), bts, mirrors, http_proxy,
  531. timeout, queryonly=queryonly)
  532. if res:
  533. result = res
  534. break
  535. except NoPackage:
  536. ui.run_wrapper(nullfunc)
  537. long_message('No record of this package found.', title=title)
  538. raise NoPackage
  539. # we didn't find a report; we access Bugreport thru debbugs,
  540. # so to avoid and import of debianbts
  541. if result and not isinstance(result, debbugs.debianbts.Bugreport):
  542. raise NoReport
  543. return result
  544. palette = [
  545. ('body', 'black', 'light gray', 'standout'),
  546. ('border', 'black', 'dark blue'),
  547. ('shadow', 'white', 'black'),
  548. ('selectable', 'black', 'dark cyan'),
  549. ('focus', 'white', 'dark blue', 'bold'),
  550. ('focustext', 'light gray', 'dark blue'),
  551. ('title', 'dark red', 'light gray'),
  552. ('scrolllabel', 'white', 'dark cyan'),
  553. ]
  554. def initialize():
  555. return True
  556. def can_input():
  557. return sys.stdin.isatty()