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'))