Spaces:
Sleeping
Sleeping
| 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"""<style> | |
| /* Zero out widget HTML container margins so spacers have full control */ | |
| .widget-html, .widget-html-content {{ | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| }} | |
| /* Widget label/description text: dark grey */ | |
| .widget-label, .widget-label-basic {{ | |
| color: {_DARK_GREY} !important; | |
| }} | |
| /* All enabled buttons: Orange bg, white text */ | |
| .widget-button {{ | |
| justify-content: flex-start !important; | |
| text-align: left !important; | |
| font-weight: bold !important; | |
| font-size: 15px !important; | |
| padding-left: 12px !important; | |
| background-color: {_ORANGE} !important; | |
| color: white !important; | |
| border: none !important; | |
| }} | |
| /* Disabled (not runnable / already ran): LG bg, dark G text */ | |
| .widget-button:disabled {{ | |
| background-color: {_LIGHT_GREY} !important; | |
| color: {_GREY} !important; | |
| opacity: 1 !important; | |
| }} | |
| /* done-button: green bg, white text */ | |
| .done-button.widget-button {{ | |
| background-color: {_GREEN} !important; | |
| color: white !important; | |
| border: none !important; | |
| }} | |
| /* Error reload: red outline, white bg */ | |
| .error-reload-btn.widget-button {{ | |
| background-color: white !important; | |
| color: {_RED} !important; | |
| border: 1px solid {_RED} !important; | |
| }} | |
| /* circular-btn centres its icon; padding-left must be 0 */ | |
| .circular-btn.widget-button {{ | |
| border-radius: 50% !important; | |
| padding: 0 !important; | |
| padding-left: 0 !important; | |
| justify-content: center !important; | |
| text-align: center !important; | |
| align-items: center !important; | |
| font-size: 14px !important; | |
| line-height: 1 !important; | |
| min-width: unset !important; | |
| }} | |
| /* Tutorial nav buttons (Back / Next): transparent bg, dark grey text */ | |
| .tut-nav-btn.widget-button {{ | |
| background-color: transparent !important; | |
| color: {_DARK_GREY} !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| }} | |
| .tut-nav-btn.widget-button:disabled {{ | |
| background-color: transparent !important; | |
| color: {_GREY} !important; | |
| }} | |
| .widget-textarea textarea {{ | |
| resize: none !important; | |
| overflow-y: auto !important; | |
| border: 1px solid {_DARK_GREY} !important; | |
| color: {_DARK_GREY} !important; | |
| }} | |
| .widget-datepicker input {{ | |
| border: 1px solid {_DARK_GREY} !important; | |
| color: {_DARK_GREY} !important; | |
| }} | |
| .widget-datepicker input:disabled {{ | |
| background-color: #f0f0f0 !important; | |
| color: {_GREY} !important; | |
| cursor: not-allowed !important; | |
| }} | |
| .widget-dropdown select {{ | |
| border: 1px solid {_DARK_GREY} !important; | |
| color: {_DARK_GREY} !important; | |
| }} | |
| /* Tutorial overlay */ | |
| .tut-overlay {{ | |
| position: fixed !important; | |
| top: 0 !important; left: 0 !important; | |
| right: 0 !important; bottom: 0 !important; | |
| background: rgba(0,0,0,0.55) !important; | |
| z-index: 9999 !important; | |
| }} | |
| .tut-card {{ | |
| background: white; | |
| border-radius: 8px; | |
| padding: 28px 32px 20px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.3); | |
| z-index: 9999; | |
| }} | |
| .grey-checkbox input[type="checkbox"] {{ | |
| appearance: none; | |
| -webkit-appearance: none; | |
| width: 13px; | |
| height: 13px; | |
| border: 2px solid {_DARK_GREY}; | |
| border-radius: 2px; | |
| background: transparent; | |
| cursor: pointer; | |
| vertical-align: middle; | |
| position: relative; | |
| }} | |
| .grey-checkbox input[type="checkbox"]:checked {{ | |
| background: transparent; | |
| border: 2px solid {_DARK_GREY}; | |
| }} | |
| .grey-checkbox input[type="checkbox"]:checked::after {{ | |
| content: ''; | |
| position: absolute; | |
| left: 2px; | |
| top: 1px; | |
| width: 3px; | |
| height: 5px; | |
| border: 2px solid {_DARK_GREY}; | |
| border-top: none; | |
| border-left: none; | |
| transform: rotate(45deg); | |
| }} | |
| .tut-info-btn {{ | |
| background: none !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| color: {_DARK_GREY} !important; | |
| font-size: 15px !important; | |
| font-weight: normal !important; | |
| padding: 0 2px !important; | |
| min-width: unset !important; | |
| width: auto !important; | |
| height: auto !important; | |
| line-height: inherit !important; | |
| justify-content: center !important; | |
| cursor: pointer !important; | |
| }} | |
| .tut-info-btn:hover {{ color: {_GREY} !important; }} | |
| /* ✕ top-right of every card: transparent bg, red icon */ | |
| .tut-x-btn.widget-button {{ | |
| background-color: transparent !important; | |
| color: {_RED} !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| padding-left: 0 !important; | |
| justify-content: center !important; | |
| }} | |
| /* "Close" button in bottom-nav of step 3/3: transparent bg, red text */ | |
| .tut-close-btn.widget-button {{ | |
| background-color: transparent !important; | |
| color: {_RED} !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| }} | |
| .tut-close-btn.widget-button:disabled {{ | |
| background-color: transparent !important; | |
| color: #e07070 !important; | |
| }} | |
| </style> | |
| """ | |
| 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("<hr style='margin: 0 12px; border: none; border-top: 1px solid #ccc;'>") | |
| def make_spacer(height='15px'): | |
| """Return an invisible HTML widget that occupies the given vertical height.""" | |
| return widgets.HTML(f"<div style='height:{height};'></div>") | |
| def make_section_header(text): | |
| """Return a bold 16 px HTML widget suitable for labelling an app section.""" | |
| return widgets.HTML(f"<b style='font-size:16px;color:{_DARK_GREY};'>{text}</b>") | |
| 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"<span style='{_DL_SPAN}'>" | |
| f"<a href='data:text/csv;base64,{b64}' download='{stem}.csv' style='{_DL_LINK}'>Download {n} articles</a>" | |
| f"</span>" | |
| ) | |
| 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"<span style='{_DL_SPAN}'>" | |
| f"<a id='csv-{uid}' href='data:text/csv;base64,{csv_b64}' download='{stem}.csv' style='display:none'></a>" | |
| f"<a id='ris-{uid}' href='data:application/x-research-info-systems;base64,{ris_b64}' download='{stem}.ris' style='display:none'></a>" | |
| f"<a href='#' style='{_DL_LINK}'" | |
| f" onclick=\"document.getElementById('csv-{uid}').click();" | |
| f"setTimeout(function(){{document.getElementById('ris-{uid}').click();}},150);" | |
| f"return false;\">Download {len(table.df)} articles identified after screening</a>" | |
| f"</span>" | |
| ) | |
| 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"<span style='{_DL_SPAN}'>" | |
| f"<a href='data:text/csv;base64,{b64}' download='{stem}.csv' style='{_DL_LINK}'>Download results for all {n} articles</a>" | |
| f"</span>" | |
| ) | |
| 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')) | |