Compare commits
4 commits
807d11ca75
...
c853be5eff
| Author | SHA1 | Date | |
|---|---|---|---|
| c853be5eff | |||
| 5f18b6daec | |||
| ab1ed55830 | |||
| 4ae9797588 |
7 changed files with 260 additions and 30 deletions
|
|
@ -86,9 +86,10 @@ report from within the app, or optionally to check for new versions to upgrade t
|
|||
|
||||
## How to install
|
||||
|
||||
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||
Unless you are using the Debian option below:
|
||||
|
||||
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||
* Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
|
||||
* If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
|
||||
|
||||
### Debian 13 ('Trixie')
|
||||
|
||||
|
|
|
|||
13
debian/changelog
vendored
13
debian/changelog
vendored
|
|
@ -1,3 +1,16 @@
|
|||
bouquin (0.8.0) unstable; urgency=medium
|
||||
|
||||
* Add .desktop file for Debian
|
||||
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down)
|
||||
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
|
||||
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
|
||||
* Add ability to collapse/expand sections of text.
|
||||
* Add 'Last Month' date range for timesheet reports
|
||||
* Add missing strings (for English and French)
|
||||
* Don't offer to download latest AppImage unless we are running as an AppImage already
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Tue, 23 Dec 2025 17:30:00 +1100
|
||||
|
||||
bouquin (0.7.5) unstable; urgency=medium
|
||||
|
||||
* Add libxcb-cursor0 dependency
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -eou pipefail
|
||||
|
||||
DISTS=(
|
||||
debian:trixie
|
||||
)
|
||||
|
||||
for dist in ${DISTS[@]}; do
|
||||
release=$(echo ${dist} | cut -d: -f2)
|
||||
mkdir -p dist/${release}
|
||||
|
||||
docker build -f Dockerfile.debbuild -t bouquin-deb:${release} \
|
||||
--no-cache \
|
||||
--progress=plain \
|
||||
--build-arg BASE_IMAGE=${dist} .
|
||||
|
||||
docker run --rm \
|
||||
-e SUITE="${release}" \
|
||||
-v "$PWD":/src \
|
||||
-v "$PWD/dist/${release}":/out \
|
||||
bouquin-deb:${release}
|
||||
|
||||
debfile=$(ls -1 dist/${release}/*.deb)
|
||||
done
|
||||
|
|
@ -3,8 +3,8 @@ from bouquin.code_block_editor_dialog import (
|
|||
CodeBlockEditorDialog,
|
||||
CodeEditorWithLineNumbers,
|
||||
)
|
||||
from PySide6.QtCore import QRect, QSize
|
||||
from PySide6.QtGui import QFont, QPaintEvent
|
||||
from PySide6.QtCore import QRect, QSize, Qt
|
||||
from PySide6.QtGui import QFont, QPaintEvent, QTextCursor
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
|
||||
|
|
@ -323,3 +323,42 @@ def test_code_editor_viewport_margins(qtbot, app):
|
|||
assert margins.top() == 0
|
||||
assert margins.right() == 0
|
||||
assert margins.bottom() == 0
|
||||
|
||||
|
||||
def test_code_editor_retains_indentation_on_enter(qtbot, app):
|
||||
"""Pressing Enter on an indented line retains indentation in code editor."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("\tfoo")
|
||||
editor.show()
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
|
||||
assert editor.toPlainText().endswith("\tfoo\n\t")
|
||||
|
||||
|
||||
def test_code_editor_double_enter_on_empty_indent_resets(qtbot, app):
|
||||
"""Second Enter on an indentation-only line clears the indent in code editor."""
|
||||
editor = CodeEditorWithLineNumbers()
|
||||
qtbot.addWidget(editor)
|
||||
editor.setPlainText("\tfoo")
|
||||
editor.show()
|
||||
|
||||
cursor = editor.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(cursor)
|
||||
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
assert editor.toPlainText().endswith("\tfoo\n\t")
|
||||
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
|
||||
assert editor.toPlainText().endswith("\tfoo\n\n")
|
||||
assert editor.textCursor().block().text() == ""
|
||||
|
|
|
|||
|
|
@ -150,6 +150,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
|
|||
assert "\n\u2022 " in txt
|
||||
|
||||
|
||||
def test_tab_indentation_is_retained_on_newline(editor, qtbot):
|
||||
"""Pressing Enter on an indented line should retain the indentation."""
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.setPlainText("\tfoo")
|
||||
editor.moveCursor(QTextCursor.End)
|
||||
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
|
||||
assert editor.toPlainText().endswith("\tfoo\n\t")
|
||||
|
||||
|
||||
def test_double_enter_on_empty_indented_line_resets_indent(editor, qtbot):
|
||||
"""A second Enter on an indentation-only line should reset to column 0."""
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.setPlainText("\tfoo")
|
||||
editor.moveCursor(QTextCursor.End)
|
||||
|
||||
# First Enter inserts a new indented line
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
assert editor.toPlainText().endswith("\tfoo\n\t")
|
||||
|
||||
# Second Enter on the now-empty indented line removes the indent
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
|
||||
assert editor.toPlainText().endswith("\tfoo\n\n")
|
||||
# Cursor should be on a fresh unindented blank line
|
||||
assert editor.textCursor().block().text() == ""
|
||||
|
||||
|
||||
def test_nested_list_continuation_preserves_indentation(editor, qtbot):
|
||||
"""Enter on an indented bullet should keep indent + bullet prefix."""
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.from_markdown("\t- item")
|
||||
editor.moveCursor(QTextCursor.End)
|
||||
|
||||
qtbot.keyPress(editor, Qt.Key_Return)
|
||||
qtbot.wait(0)
|
||||
|
||||
assert "\n\t\u2022 " in editor.toPlainText()
|
||||
|
||||
|
||||
def test_enter_on_empty_list_marks_empty(qtbot, editor):
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
|
|
@ -181,6 +228,116 @@ def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor,
|
|||
assert t == ""
|
||||
|
||||
|
||||
def _find_first_block(doc, predicate):
|
||||
b = doc.begin()
|
||||
while b.isValid():
|
||||
if predicate(b):
|
||||
return b
|
||||
b = b.next()
|
||||
return None
|
||||
|
||||
|
||||
def test_collapse_selection_wraps_and_hides_blocks(editor, qtbot):
|
||||
"""Collapsing a selection should insert header/end marker and hide content."""
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.setPlainText("a\nb\nc\n")
|
||||
doc = editor.document()
|
||||
|
||||
# Select lines b..c
|
||||
b_block = doc.findBlockByNumber(1)
|
||||
c_block = doc.findBlockByNumber(2)
|
||||
cur = editor.textCursor()
|
||||
cur.setPosition(b_block.position())
|
||||
cur.setPosition(c_block.position() + c_block.length() - 1, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cur)
|
||||
|
||||
editor.collapse_selection()
|
||||
qtbot.wait(0)
|
||||
|
||||
# Header and end marker should exist as their own blocks
|
||||
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸"))
|
||||
assert header is not None
|
||||
assert "▸" in header.text() and "expand" in header.text()
|
||||
|
||||
end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text())
|
||||
assert end_marker is not None
|
||||
|
||||
# Inner blocks should be hidden; end marker always hidden
|
||||
inner1 = header.next()
|
||||
inner2 = inner1.next()
|
||||
assert inner1.text() == "b"
|
||||
assert inner2.text() == "c"
|
||||
assert inner1.isVisible() is False
|
||||
assert inner2.isVisible() is False
|
||||
assert end_marker.isVisible() is False
|
||||
|
||||
|
||||
def test_toggle_collapse_expands_and_updates_header(editor, qtbot):
|
||||
"""Toggling a collapse header should reveal hidden blocks and flip label."""
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.setPlainText("a\nb\nc\n")
|
||||
doc = editor.document()
|
||||
|
||||
# Select b..c and collapse
|
||||
b_block = doc.findBlockByNumber(1)
|
||||
c_block = doc.findBlockByNumber(2)
|
||||
cur = editor.textCursor()
|
||||
cur.setPosition(b_block.position(), QTextCursor.MoveMode.MoveAnchor)
|
||||
cur.setPosition(
|
||||
c_block.position() + c_block.length() - 1, QTextCursor.MoveMode.KeepAnchor
|
||||
)
|
||||
editor.setTextCursor(cur)
|
||||
editor.collapse_selection()
|
||||
qtbot.wait(0)
|
||||
|
||||
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸"))
|
||||
assert header is not None
|
||||
|
||||
# Toggle to expand
|
||||
editor._toggle_collapse_at_block(header)
|
||||
qtbot.wait(0)
|
||||
|
||||
header2 = doc.findBlock(header.position())
|
||||
assert "▾" in header2.text() and "collapse" in header2.text()
|
||||
assert header2.next().isVisible() is True
|
||||
assert header2.next().next().isVisible() is True
|
||||
|
||||
|
||||
def test_collapse_selection_without_trailing_newline_keeps_marker_on_own_line(
|
||||
editor, qtbot
|
||||
):
|
||||
"""Selections reaching EOF without a trailing newline should still fold correctly."""
|
||||
qtbot.addWidget(editor)
|
||||
editor.show()
|
||||
editor.setPlainText("a\nb\nc") # no trailing newline
|
||||
doc = editor.document()
|
||||
|
||||
# Bottom-up selection of last two lines (c..b)
|
||||
b_block = doc.findBlockByNumber(1)
|
||||
c_block = doc.findBlockByNumber(2)
|
||||
cur = editor.textCursor()
|
||||
cur.setPosition(c_block.position() + len(c_block.text()))
|
||||
cur.setPosition(b_block.position(), QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(cur)
|
||||
|
||||
editor.collapse_selection()
|
||||
qtbot.wait(0)
|
||||
|
||||
end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text())
|
||||
assert end_marker is not None
|
||||
|
||||
# End marker is its own block, and remains hidden
|
||||
assert end_marker.text().strip() == "<!-- bouquin:collapse:end -->"
|
||||
assert end_marker.isVisible() is False
|
||||
|
||||
# The last content line should be hidden (folded)
|
||||
header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸"))
|
||||
assert header is not None
|
||||
assert header.next().isVisible() is False
|
||||
|
||||
|
||||
def test_down_escapes_from_last_code_line(editor, qtbot):
|
||||
editor.from_markdown("```\nLINE\n```\n")
|
||||
# Put caret at end of "LINE"
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours(
|
|||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
|
||||
"""Elapsed time should be rounded up to the nearest 0.25 hours."""
|
||||
"""Elapsed time should be rounded to a 0.25-hour increment."""
|
||||
parent = DummyMainWindow(app)
|
||||
qtbot.addWidget(parent)
|
||||
qtbot.addWidget(parent.time_log)
|
||||
|
|
@ -300,6 +300,31 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey
|
|||
assert hours_set * 4 == int(hours_set * 4)
|
||||
|
||||
|
||||
def test_seconds_to_logged_hours_nearest_quarter_rounding():
|
||||
"""Seconds -> hours uses nearest-quarter rounding with a 15-min minimum."""
|
||||
# Import the pure conversion helper directly (no Qt required)
|
||||
from bouquin.pomodoro_timer import PomodoroManager
|
||||
|
||||
# <15 minutes always rounds up to 0.25
|
||||
assert PomodoroManager._seconds_to_logged_hours(1) == 0.25
|
||||
assert PomodoroManager._seconds_to_logged_hours(899) == 0.25
|
||||
|
||||
# 15 minutes exact
|
||||
assert PomodoroManager._seconds_to_logged_hours(900) == 0.25
|
||||
|
||||
# Examples from the spec: closest quarter-hour
|
||||
# 33 minutes -> closer to 0.50 than 0.75
|
||||
assert PomodoroManager._seconds_to_logged_hours(33 * 60) == 0.50
|
||||
# 40 minutes -> closer to 0.75 than 0.50
|
||||
assert PomodoroManager._seconds_to_logged_hours(40 * 60) == 0.75
|
||||
|
||||
# Halfway case: 22.5 min is exactly between 0.25 and 0.50 -> round up
|
||||
assert PomodoroManager._seconds_to_logged_hours(int(22.5 * 60)) == 0.50
|
||||
|
||||
# Sanity: 1 hour stays 1.0
|
||||
assert PomodoroManager._seconds_to_logged_hours(60 * 60) == 1.00
|
||||
|
||||
|
||||
def test_pomodoro_manager_on_timer_stopped_prefills_note(
|
||||
qtbot, app, fresh_db, monkeypatch
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1211,6 +1211,26 @@ def test_time_report_dialog_default_date_range(qtbot, fresh_db):
|
|||
assert dialog.to_date.date() == today
|
||||
|
||||
|
||||
def test_time_report_dialog_last_month_preset_sets_full_previous_month(qtbot, fresh_db):
|
||||
"""Selecting 'Last month' sets the date range to the previous calendar month."""
|
||||
strings.load_strings("en")
|
||||
dialog = TimeReportDialog(fresh_db)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
idx = dialog.range_preset.findData("last_month")
|
||||
assert idx != -1
|
||||
|
||||
today = QDate.currentDate()
|
||||
start_of_this_month = QDate(today.year(), today.month(), 1)
|
||||
expected_start = start_of_this_month.addMonths(-1)
|
||||
expected_end = start_of_this_month.addDays(-1)
|
||||
|
||||
dialog.range_preset.setCurrentIndex(idx)
|
||||
|
||||
assert dialog.from_date.date() == expected_start
|
||||
assert dialog.to_date.date() == expected_end
|
||||
|
||||
|
||||
def test_time_report_dialog_run_report(qtbot, fresh_db):
|
||||
"""Run a time report."""
|
||||
strings.load_strings("en")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue