import base64 import uuid import ipywidgets as widgets from IPython.display import HTML, display from src.utils.colours import AppColour # noqa: F401 — re-exported for notebook from src.handlers.error_handler import ErrorHandler from src.handlers.tutorial_handler import Tutorial # noqa: F401 — re-exported for notebook _DARK_GREY = AppColour.DARK_GREY.value _GREY = AppColour.GREY.value _LIGHT_GREY = AppColour.LIGHT_GREY.value _ORANGE = AppColour.ORANGE.value _RED = AppColour.RED.value _GREEN = AppColour.GREEN.value BOX_WIDTH = '300px' BUTTON_WIDTH = '225px' QUERY_WIDTH = '396px' _STYLES_CSS = f""" """ def inject_styles(): """ Push the app's global CSS into the page output. Call once at the top of the notebook before any widgets are displayed. """ display(HTML(_STYLES_CSS)) def make_button(description, width=BUTTON_WIDTH): """Return a bold ipywidgets Button with the given label and width.""" return widgets.Button( description=description, layout=widgets.Layout(width=width), style=widgets.ButtonStyle(font_weight='bold') ) def make_separator(): """Return a thin horizontal rule widget for use between app sections.""" return widgets.HTML("
") def make_spacer(height='15px'): """Return an invisible HTML widget that occupies the given vertical height.""" return widgets.HTML(f"
") def make_section_header(text): """Return a bold 16 px HTML widget suitable for labelling an app section.""" return widgets.HTML(f"{text}") def set_executing(btn, message='Running...'): """ Put a Button into the executing state. Sets the description to message, disables the button, and removes the done-button CSS class so it reverts to the default style. """ btn.description = message btn.disabled = True btn.remove_class('done-button') def set_done(btn, message): """ Put a Button into the completed state. Sets the description to message, keeps the button disabled, and adds the done-button CSS class (green border and text). """ btn.description = message btn.disabled = True btn.add_class('done-button') def error_html(exc): """ Return an HTML string that displays exc in red below a button row. Newlines in the message are rendered as line breaks. Assign the result to an HTML widget's value to surface errors in the UI. """ return ErrorHandler.html(exc) class RunButton: """ A button paired with an inline HTML widget for status and error display. Use start() when an operation begins, done(msg) on success, and fail(exc) on failure. Call reset() to restore the button to its initial clickable state (e.g. after an Unload). run = RunButton('Search', executing_msg='Searching...', error_label='Search') display(run.row) run.on_click(my_handler) """ def __init__(self, label, executing_msg='Running...', error_label=None, width=BUTTON_WIDTH): self._label = label self._executing_msg = executing_msg self._error_label = error_label or label self.btn = make_button(label, width=width) self.out = widgets.HTML(value='') self.err_reload = widgets.Button( description='↺', layout=widgets.Layout(width='28px', height='28px', display='none')) self.err_reload.add_class('circular-btn') self.err_reload.add_class('error-reload-btn') self.err_reload.on_click(lambda b: self.reset()) self.row = widgets.VBox([ widgets.HBox([self.btn, self.err_reload]), self.out, ]) def on_click(self, handler): self.btn.on_click(handler) def start(self): """ Clear inline output and put the button into the executing state. Call at the very beginning of an on_click handler, before any async work. """ self.out.value = '' self.err_reload.layout.display = 'none' set_executing(self.btn, self._executing_msg) def done(self, msg=None): self.err_reload.layout.display = 'none' set_done(self.btn, msg if msg is not None else f'\u2713 {self._label}') def fail(self, exc): self.out.value = error_html(exc) self.btn.description = self._label self.btn.disabled = True self.btn.remove_class('done-button') self.err_reload.layout.display = '' def reset(self): """ Restore the button and output to their initial state. Call when the user unloads data or explicitly resets the section so the button becomes clickable again with its original label. """ self.btn.description = self._label self.btn.disabled = False self.btn.remove_class('done-button') self.err_reload.layout.display = 'none' self.out.value = '' _DL_SPAN = "display:inline-flex;align-items:center;margin-left:12px;line-height:28px;" _DL_LINK = f"font-size:12px;color:{_DARK_GREY};text-decoration:underline;line-height:28px;margin-right:3px;" _DL_BOX = "font-size:12px;border:1px solid #ccc;border-radius:3px;padding:1px 5px;height:20px;line-height:18px;width:130px;margin:0 2px;" _DL_EXT = "font-size:12px;line-height:28px;" def download_link(table, stem): n = len(table.df) b64 = table.to_csv_b64() return ( f"" f"Download {n} articles" f"" ) def download_combined_link(table, stem): """ Return an HTML snippet whose Download link saves both a .csv and a .ris file. The two saves are triggered sequentially (150 ms apart) so the browser handles them as distinct downloads. Suitable for the screened-articles section where researchers need both formats for their reference manager. """ csv_b64 = table.to_csv_b64() ris_b64 = table.to_ris_b64() uid = uuid.uuid4().hex[:8] return ( f"" f"" f"" f"Download {len(table.df)} articles identified after screening" f"" ) def download_predictions_link(table, stem): n = len(table.df) df = table.df.rename(columns={ 'y_prob': 'Predicted probability of meeting inclusion criteria', 'y_pred': 'Prediction (1=meets inclusion criteria; 0=does not meet inclusion criteria)', }) b64 = base64.b64encode(df.to_csv(index=False).encode()).decode() return ( f"" f"Download results for all {n} articles" f"" ) def make_section_header_with_info(text, tutorial, step): """Section header with an ⓘ button that reopens the tutorial at `step`.""" btn = widgets.Button( description='ⓘ', layout=widgets.Layout(width='auto', height='auto', min_width='unset', padding='0', margin='0 0 0 6px')) btn.add_class('tut-info-btn') btn.on_click(lambda b: tutorial.show(step)) return widgets.HBox([make_section_header(text), btn], layout=widgets.Layout(align_items='center'))