Add font family choice to the text widget, move toolbar below the editor
Six more vendored families alongside the existing Noto Sans (Inter, Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/ serif/slab/mono variety), all OFL-licensed with their own per-family license file in app/fonts/ since each has a different copyright holder. Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only families (Inter and Source Sans's current Google Fonts releases, plus Playfair Display/Lora/Merriweather) were skipped in favor of static builds from their own upstream repos, keeping every family's loading code uniform with what was already there. Considered but deliberately left out: Georgia -- a proprietary Microsoft core font, not freely redistributable, unlike everything else vendored here. Also moves the bold/italic/underline/color toolbar below the contenteditable box per request, and reorders the dialog's Settings card to a more natural family-then-size order. Fixes a latent migration bug this surfaced: migration 20 (static image widget) used Base.metadata.create_all, which creates every table declared in Base.metadata that's missing, not just its own new one -- harmless when nothing else pending, but once TextWidgetConfig existed it would silently pre-create text_widget_configs (in whatever shape models.py currently declares) before migration 21 got a turn, so migration 21's own CREATE TABLE (or a later ALTER TABLE adding font_family) would collide with a table create_all had already leaked into existence. Both migrations 20 and 21 now use raw, frozen CREATE TABLE SQL instead, matching migration 17's existing precedent for exactly this reason.
This commit is contained in:
@@ -77,6 +77,8 @@ def test_expected_columns_exist_on_current_schema():
|
||||
assert "name" in task_widget_columns # migration 19
|
||||
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
||||
assert "text_widget_configs" in inspector.get_table_names() # migration 21
|
||||
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
|
||||
assert "font_family" in text_widget_columns # migration 22
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
@@ -185,8 +187,21 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
||||
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
|
||||
migrate."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs are migration 20/21
|
||||
# tables (also post-16, like the rest of this list) -- dropped
|
||||
# here too so a real version-15 database is what's actually
|
||||
# being simulated, not "version 15 plus two tables that
|
||||
# wouldn't exist yet". Harmless to omit as long as no migration
|
||||
# after the one that creates a table also ALTERs it (that's
|
||||
# what let static_widget_configs go unlisted safely so far --
|
||||
# Base.metadata.create_all is idempotent against an
|
||||
# already-present table with no later ALTER to collide with),
|
||||
# but text_widget_configs' migration 22 ALTER makes the gap a
|
||||
# real "table already exists"/"duplicate column" collision
|
||||
# instead of a silent no-op.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"text_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
@@ -253,9 +268,18 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
|
||||
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
||||
calendar_key columns either."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs dropped too -- see
|
||||
# the comment on the identical setup in
|
||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
|
||||
# 20/21's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 22 and neither
|
||||
# table would really exist yet at a genuine pre-migration-17
|
||||
# schema_version).
|
||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||
conn.execute(text("DROP TABLE frame_task_lists"))
|
||||
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
||||
conn.execute(text("DROP TABLE static_widget_configs"))
|
||||
conn.execute(text("DROP TABLE text_widget_configs"))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
@@ -350,8 +374,12 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
|
||||
the widget backfill, not as a numbered migration racing ahead of
|
||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs dropped too -- see
|
||||
# the comment on the identical setup in
|
||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"text_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
|
||||
@@ -206,6 +206,7 @@ def test_config_save_updates_a_text_widget(client, db_session):
|
||||
data={
|
||||
"text_html": '<div>Hello <b>world</b></div>',
|
||||
"text_font_size": "40",
|
||||
"text_font_family": "serif",
|
||||
"text_align": "center",
|
||||
"text_background_color": "#ffdb00",
|
||||
},
|
||||
@@ -219,23 +220,26 @@ def test_config_save_updates_a_text_widget(client, db_session):
|
||||
{"text": "world", "bold": True, "italic": False, "underline": False, "color": None, "bg": None},
|
||||
]]
|
||||
assert cfg.font_size == 40
|
||||
assert cfg.font_family == "serif"
|
||||
assert cfg.align == "center"
|
||||
assert cfg.background_color == "#ffdb00"
|
||||
|
||||
|
||||
def test_config_save_clamps_text_font_size_and_rejects_bad_align_and_color(client, db_session):
|
||||
def test_config_save_clamps_text_font_size_and_rejects_bad_family_align_and_color(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_text_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"text_font_size": "500", "text_align": "diagonal", "text_background_color": "not-a-color"},
|
||||
data={"text_font_size": "500", "text_font_family": "comic-sans", "text_align": "diagonal",
|
||||
"text_background_color": "not-a-color"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(TextWidgetConfig, widget.id)
|
||||
assert cfg.font_size == 96 # clamped to MAX_TEXT_FONT_SIZE
|
||||
assert cfg.font_family == "sans" # fell back to DEFAULT_FONT_FAMILY
|
||||
assert cfg.align == "left" # fell back to the default
|
||||
assert cfg.background_color == "#ffffff" # fell back to the default
|
||||
|
||||
|
||||
@@ -100,6 +100,23 @@ def test_render_respects_alignment(db_session):
|
||||
assert img.size == (200, 100)
|
||||
|
||||
|
||||
def test_render_supports_every_font_family(db_session):
|
||||
for family in widgets.text.FONT_FAMILIES:
|
||||
frame, widget = _make_widget(db_session, content=[[_run("The quick brown fox")]], font_family=family)
|
||||
img = widgets.text.render(db_session, frame, widget, 250, 100)
|
||||
assert img.size == (250, 100)
|
||||
|
||||
|
||||
def test_render_falls_back_to_default_family_for_an_unrecognized_value(db_session):
|
||||
"""A stale/tampered font_family value (e.g. a family removed in a
|
||||
later release) never crashes render() -- falls back to the default
|
||||
the same way an unrecognized align/display_mode value does
|
||||
elsewhere in this codebase."""
|
||||
frame, widget = _make_widget(db_session, content=[[_run("hi")]], font_family="does-not-exist")
|
||||
img = widgets.text.render(db_session, frame, widget, 200, 100)
|
||||
assert img.size == (200, 100)
|
||||
|
||||
|
||||
def test_no_button_actions():
|
||||
"""Fixed authored text -- nothing to advance/back/check."""
|
||||
assert widgets.text.ACTIONS == {}
|
||||
|
||||
Reference in New Issue
Block a user