Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1409 (2022-12-18 20:26:15)

refactor: Improve design of task rendering code & add tests
<!--- Provide a general summary of your changes in the Title above -->

# Description

As part of https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/877, this PR offers a pure refactor (no change in functionality) to the code of task rendering to HTML, and tests coverage to areas that were previously unreachable by the tests.

This includes:

- A new `TaskLineRenderer.ts` file that encapsulates the rendering flow of a task, from a `Task` object into an HTML. Some code from `Task` moved to here.
- Expansion of the existing `LayoutOptions` concept into a `TaskLayout` class. The legacy `LayoutOptions` class translates directly to query options, but it does not represent the complete product (it says what to display or hide, but not how the task should eventually look like). `LayoutOptions` was moved into `TaskLayout.ts` and a `TaskLayout` can be created by a `LayoutOptions` object.
- The conversion of a task to string (`Task.toString`) now passes through a `TaskLayout`, which formalizes the structure of the rendered task, and renders each component separately with a `componentToString` method. This enables to reuse the same code when other layout combinations are required.
- Tests that utilize this new structure to cover the rendering flow of a task and its layout options.

## Motivation and Context

As agreed in the discussion above, I split the work on adding theming capabilities to Tasks into a pure refactor PR (this one) and a 2nd PR that will build on top of these changes to add fine-grained classes to task components.

## How has this been tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

Tests were added to the rendering flow.

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [x] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


esm7: I would appreciate anyone who has the time to review and test this branch. I know I need to update it (it's from a few weeks back), but other than that, queries here should work exactly the same as before this refactor. Boring but important ;)
claremacrae: Wow, brilliant, thanks @esm7.

I will of course review it. I should be able to get to it in the next day or two.

Do you have any suggestions as to how I can efficiently review the code that changed in files that got renamed please?
The first one I noticed is `LayoutOptions` to `TaskLayout`?
I normally review the diffs side-by-side in GitHub and my IDE but that won't work in this case?

claremacrae: > Do you have any suggestions as to how I can efficiently review the code that changed in files that got renamed please? The first one I noticed is `LayoutOptions` to `TaskLayout`? I normally review the diffs side-by-side in GitHub and my IDE but that won't work in this case?

Ooohhh - maybe on my machine, with this branch checked out, I could use WebStorm refactoring to rename the new name (file, class, whatever) back to the old one temporarily... Then I can diff against the code at the start of your branch and it should be easy to see what else changed other than the file names...

I think that should work.


claremacrae: Closing and reopening to try and force the GitHub Actions to run.
claremacrae: Odd. I wonder if the tests won't run until the conflicts have been resolved?
claremacrae: > I will of course review it. I should be able to get to it in the next day or two.

Just to set expectations... I don't have much time available over the next few days.
And I always review PRs thoroughly.
For a PR as large and wide-ranging as this, I see I'm gonna need more than a day or two of free time.
But I will definitely make it a priority to review it!

claremacrae: > ## How has this been tested?
> Tests were added to the rendering flow.

Could you give me an idea of how much testing you've done to confirm that the appearance is indeed unchanged please? 
For example, what kinds of combinations of layout options have you tested with?
Thanks.
claremacrae: Hi @esm7 I was so excited about this I couldn't wait and have gone ahead and reviewed it and tested it! 🤣 

Here's a bit of a summary of what I did..

## Merged main and pushed, to make a build

- Created a branch with merged code: [GitHub - obsidian-tasks-group/obsidian-tasks at refactor-task-rendering-clare-rendering](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/refactor-task-rendering-clare-rendering)
    - Build good: [Merge branch 'main' into refactor-task-rendering-clare-rendering · obsidian-tasks-group/obsidian-tasks@3effd76 · GitHub](https://github.com/obsidian-tasks-group/obsidian-tasks/actions/runs/3731324150)
    - A zip of the Tasks-Demo vault, with the new code (including main merged in) can be downloaded from the above GitHub Actions page.

## About merging from main.

I'm happy to be the person that merges main in to your branch, before merging your branch to main.

It took me 2 or 3 tries to get a working code from the merge, and it was only this quick because I understand all the changes made on main since the version you were starting from.

## Manual testing

I set up two vaults side-by-side. 

- This build - of main with - in the left:
    - [refactor: TagsField now implements 'sort by tag' (#1411) · obsidian-tasks-group/obsidian-tasks@61c3958 · GitHub](https://github.com/obsidian-tasks-group/obsidian-tasks/actions/runs/3730513870)
- And this build with the PR code and the merge from main on the right:
    - [Merge branch 'main' into refactor-task-rendering-clare-rendering · obsidian-tasks-group/obsidian-tasks@3effd76 · GitHub](https://github.com/obsidian-tasks-group/obsidian-tasks/actions/runs/3731324150)

Here's what I did with those two vaults - pretty much all using existing pages in the Tasks-Demo vault.

- Confirmed `explain` still works (as it was done on main after this PR branch was created)
- Checked both the backlink styling snippets in sample vault worked
- Checked that SlrVb's Alternative Checkboxes still worked
- Saved as PDF and diffed the two outputs
    - Wrote a query to show every task the test vault, with urgency enabled
    - In both vaults, I exported the Reading view as PDF
    - Then opened both PDFs in one Acrobat window
    - And went down one page at a time, switching between the two files
    - Any slight differences in the rendering would have stood out.
    - There were no differences!
- Tried a bunch of different show/hide options - see below.
- Did some inspecting of HTML in the developer tools and couldn't find any differences

For exploratory testing the new code, I incrementally worked through adding the following lines, one by one - and comparing left and right:

````text
```tasks
explain

show urgency

hide backlink
hide scheduled date
hide start date
hide due date
hide done date
hide priority

hide edit button
hide recurrence rule

show start date
```
````

claremacrae: Note to self:

Ooooh.. I was going to say that my branch mentioned above isn't suitable for merging, as it contains some cruft from the review process. Like the only way I could figure out how to diff the code removed from Task with the code adding in a new file was by using a temporary intermediate file, which I rearranged until the order matched that in the new code, so I could diff it.

But... because this project squashes all commits when merging, if I remove all traces of my edits from my branch and then merge that, the end result will be fine to merge.

I could then clean up the commit messages slightly at the point when I merge to main.
esm7: Just wanted to drop a note here saying that I'm having a super busy day, but I was excited to skim through the comments, and either tomorrow or on Wednesday I'll reply to everything!
esm7: > > ## How has this been tested?
> > Tests were added to the rendering flow.
> 
> Could you give me an idea of how much testing you've done to confirm that the appearance is indeed unchanged please? For example, what kinds of combinations of layout options have you tested with? Thanks.

Maybe this question is no longer relevant since you discussed very thorough tests later on, but I did a comparison similar to what you described: tested all the layout options I could think of and compared the resulting DIV structure and look.

Patch:
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml
index 8058d485..c9d74795 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yaml
@@ -8,7 +8,7 @@ body:
       label: "⚠️ Please check that this feature request hasn't been suggested before."
       description: "There are two locations for previous feature requests. Please search in both. Thank you. The **Label filters** may help make your search more focussed."
       options:
-        - label: "I searched previous [Ideas in Discussions](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/categories/ideas?discussions_q=category%3AIdeas+-label%3A%22status%3A+released%22+-label%3Aduplicate+sort%3Atop) didn't find any similar feature requests."
+        - label: "I searched previous [Ideas in Discussions](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/categories/ideas-any-new-feature-requests-go-in-issues-please?discussions_q=category%3A%22Ideas%3A+Any+New+Feature+Requests+go+in+Issues+please%22+-label%3A%22status%3A+released%22+-label%3Aduplicate+sort%3Atop) didn't find any similar feature requests."
           required: true
         - label: "I searched previous [Issues](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22+sort%3Acomments-desc) didn't find any similar feature requests."
           required: true
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 2e0dc85d..3035f09a 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -12,13 +12,17 @@
 name: "CodeQL"
 
 on:
-  push:
-    branches: [ "*" ]
-  pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ main ]
+# Not run on PRs as too slow: See https://github.com/obsidian-tasks-group/obsidian-tasks/issues/1415
+#  push:
+#    branches: [ "*" ]
+#  pull_request:
+#    # The branches below must be a subset of the branches above
+#    branches: [ main ]
   schedule:
     - cron: '35 18 * * 0'
+  workflow_dispatch:
+    # Inputs the workflow accepts.
+    inputs:
 
 jobs:
   analyze:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9e4ac34e..54462373 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -445,7 +445,7 @@ You can toggle a task‘s status by:
 The code is located as follows:
 
 - For 1.: `./src/Commands/ToggleDone.ts`
-- Numbers 2. and 4. use a checkbox created by `Task.toLi()`. There, the checkbox gets a click event handler.
+- Numbers 2. and 4. use a checkbox created by `TaskLineRenderer.renderTaskLine`. There, the checkbox gets a click event handler.
 - For 3.: `./src/LivePreviewExtension.ts`
 
 Toggle behavior:
@@ -563,21 +563,24 @@ Follow the steps in `resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Tes
 
 ### How do I make a release?
 
-1. Check out the `main` branch
-2. Check for the current version in `package.json` (for example, `1.4.1`) and decide on a next version
+1. Go to the [CodeQL Actions page](https://github.com/obsidian-tasks-group/obsidian-tasks/actions/workflows/codeql-analysis.yml) and click `Run workflow` to run the CodeQL checks
+    - Wait for them to succeed
+    - If they fail, put the release on hold and fix any issues first. (Failures are very rare.)
+2. Check out the `main` branch
+3. Check for the current version in `package.json` (for example, `1.4.1`) and decide on a next version
     - Backwards incompatible change: increase major version
     - New functionality: increase minor version
     - Only bug fixes: increase patch version
-3. Having decided on the new version, replace all `X.Y.Z` in the documentation with the new version number.
-4. Check the current version of the obsidian dependency in `package.json` (for example, `0.13.21`)
-5. Run `./release.sh <new tasks version> <obsidian version>`
+4. Having decided on the new version, replace all `X.Y.Z` in the documentation with the new version number.
+5. Check the current version of the obsidian dependency in `package.json` (for example, `0.13.21`)
+6. Run `./release.sh <new tasks version> <obsidian version>`
     - Make sure there are no uncommitted changes. Stash them if necessary.
-6. Wait for [GitHub Actions](https://github.com/obsidian-tasks-group/obsidian-tasks/actions/workflows/release.yml) to create the new release
-7. Update the release description with the changes of the release, which will be a Draft.
+7. Wait for [GitHub Actions](https://github.com/obsidian-tasks-group/obsidian-tasks/actions/workflows/release.yml) to create the new release
+8. Update the release description with the changes of the release, which will be a Draft.
     - On the release page, GitHub provides a button to auto-generate release notes which works nicely as a good starting point.
-8. When you are happy with the release notes, hit the Publish button.
+9. When you are happy with the release notes, hit the Publish button.
     - At this point, GitHub will send an email automatically to everyone who is subscribed to Tasks releases.
-9. Optional: Post to
+10. Optional: Post to
     - Obsidian Discord
         - Add a post in the `#updates` channel, with detail about the release
         - Add a one-liner in the `#task-management` channel, linking to the first post
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 542d72bc..695dddfe 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -14,7 +14,7 @@ GEM
       execjs
     coffee-script-source (1.11.1)
     colorator (1.1.0)
-    commonmarker (0.23.5)
+    commonmarker (0.23.6)
     concurrent-ruby (1.1.10)
     dnsruby (1.61.9)
       simpleidn (~> 0.1)
@@ -206,12 +206,14 @@ GEM
       rb-fsevent (~> 0.10, >= 0.10.3)
       rb-inotify (~> 0.9, >= 0.9.10)
     mercenary (0.3.6)
+    mini_portile2 (2.8.1)
     minima (2.5.1)
       jekyll (>= 3.5, < 5.0)
       jekyll-feed (~> 0.9)
       jekyll-seo-tag (~> 2.1)
     minitest (5.16.2)
-    nokogiri (1.13.8)
+    nokogiri (1.13.10)
+      mini_portile2 (~> 2.8.0)
       racc (~> 1.4)
     octokit (4.25.1)
       faraday (>= 1, < 3)
@@ -219,7 +221,7 @@ GEM
     pathutil (0.16.2)
       forwardable-extended (~> 2.6)
     public_suffix (4.0.7)
-    racc (1.6.0)
+    racc (1.6.2)
     rb-fsevent (0.11.1)
     rb-inotify (0.10.1)
       ffi (~> 1.0)
diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss
index 492d17dd..135d09ed 100644
--- a/docs/_sass/custom/custom.scss
+++ b/docs/_sass/custom/custom.scss
@@ -6,3 +6,7 @@ blockquote {
     padding-left: 15px;
     border-left: 3px solid #ccc;
 }
+
+td {
+    vertical-align: top;
+}
diff --git a/docs/queries/grouping.md b/docs/queries/grouping.md
index 6c5e8179..f691e784 100644
--- a/docs/queries/grouping.md
+++ b/docs/queries/grouping.md
@@ -37,7 +37,7 @@ File locations:
 1. `path` (the path to the file that contains the task, that is, the folder and the filename)
 1. `root` (the top-level folder of the file that contains the task, that is, the first directory in the path, which will be `/` for files in root of the vault)
 1. `folder` (the folder to the file that contains the task, which will be `/` for files in root of the vault)
-1. `filename` (the filename of the file that contains the task, without the `.md` extension)
+1. `filename` (the link to the file that contains the task, without the `.md` extension)
     - Note that tasks from different notes with the same file name will be grouped together in the same group.
 
 > `root` grouping option was introduced in Tasks 1.11.0.
diff --git a/docs/queries/sorting.md b/docs/queries/sorting.md
index 37289659..5fc0c2f2 100644
--- a/docs/queries/sorting.md
+++ b/docs/queries/sorting.md
@@ -26,23 +26,50 @@ By default Tasks sorts tasks by [a calculated score we call "urgency"]({{ site.b
 
 To sort the results of a query different from the default, you must add at least one `sort by` line to the query.
 
+### Available sorting properties
+
 You can sort tasks by the following properties:
 
+File locations:
+
+1. `path` (the path to the file that contains the task)
+1. `filename` (the filename of the file that contains the task, with its extension)
+    - Note that tasks from different notes with the same file name will be sorter.
+
+> `sort by filename` was introduced in Tasks 1.21.0.
+
+File contents:
+
+1. `sort by heading` (the heading preceding the task; files with empty headings sort before other tasks)
+
+> `sort by heading` was introduced in Tasks 1.21.0.
+
+Task date properties:
+
+1. `start` (the date when the task starts)
+1. `scheduled` (the date when the task is scheduled)
+1. `due` (the date when the task is due)
+1. `done` (the date when the task was done)
+1. `happens` (the earliest of start date, scheduled date, and due date)
+
+> `sort by happens` was introduced in Tasks 1.21.0.
+
+Task properties - other:
+
+1. `description` (the description of the task)
+1. `status` (done or todo)
+1. `priority` (priority of the task; "low" is below "none": [priorities]({{ site.baseurl }}{% link getting-started/priority.md %}))
 1. `urgency` ([urgency]({{ site.baseurl }}{% link advanced/urgency.md %}))
-2. `status` (done or todo)
-3. `priority` (priority of the task; "low" is below "none": [priorities]({{ site.baseurl }}{% link getting-started/priority.md %}))
-4. `start` (the date when the task starts)
-5. `scheduled` (the date when the task is scheduled)
-6. `due` (the date when the task is due)
-7. `done` (the date when the task was done)
-8. `path` (the path to the file that contains the task)
-9. `description` (the description of the task)
-10. `tag` (the description of the task)
+1. `tag` (the description of the task)
+
+## Multiple sort criteria
 
 You can add multiple `sort by` query options, each on an extra line.
 The first sort has the highest priority.
 Each subsequent `sort` will sort within the existing sorting.
 
+## Notes
+
 <div class="code-example" markdown="1">
 Info
 {: .label .label-blue }
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index e22d12f6..fbcb1008 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -22,16 +22,16 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `starts (before, after, on) <date>`<br>`has start date`<br>`no  start date`<br>`start date is invalid`                                                                                              | `sort by start`                             | `group by start`      | `hide start date`      |
 | `scheduled (before, after, on) <date>`<br>`has scheduled date`<br>`no  scheduled date`<br>`scheduled date is invalid`                                                                               | `sort by scheduled`                         | `group by scheduled`  | `hide scheduled date`  |
 | `due (before, after, on) <date>`<br>`has due date`<br>`no  due date`<br>`due date is invalid`                                                                                                       | `sort by due`                               | `group by due`        | `hide due date`        |
-| `happens (before, after, on) <date>`<br>`has happens date`<br>`no  happens date`                                                                                                                    |                                             | `group by happens`    |                        |
+| `happens (before, after, on) <date>`<br>`has happens date`<br>`no  happens date`                                                                                                                    | `sort by happens`                           | `group by happens`    |                        |
 | `is recurring`<br>`is not recurring`                                                                                                                                                                |                                             | `group by recurring`  |                        |
 |                                                                                                                                                                                                     |                                             | `group by recurrence` | `hide recurrence rule` |
-| `priority is (above, below, not)? (low, none, medium, high)`                                                                                                                                         | `sort by priority`                          | `group by priority`   | `hide priority`        |
+| `priority is (above, below, not)? (low, none, medium, high)`                                                                                                                                        | `sort by priority`                          | `group by priority`   | `hide priority`        |
 |                                                                                                                                                                                                     | `sort by urgency`                           |                       | `show urgency`         |
 | `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                                                                                                 | `sort by path`                              | `group by path`       |                        |
 |                                                                                                                                                                                                     |                                             | `group by root`       |                        |
 |                                                                                                                                                                                                     |                                             | `group by folder`     |                        |
-| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                     |                                             | `group by filename`   |                        |
-| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                         |                                             | `group by heading`    |                        |
+| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`                                                                                     | `sort by filename`                          | `group by filename`   |                        |
+| `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                         | `sort by heading`                           | `group by heading`    |                        |
 |                                                                                                                                                                                                     |                                             | `group by backlink`   | `hide backlink`        |
 | `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                 | `sort by description`                       |                       |                        |
 | `tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i` | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`       |                        |
diff --git a/package.json b/package.json
index 38963a19..ff396613 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,7 @@
     "prettier": "^2.2.1",
     "prettier-plugin-svelte": "^2.7.0",
     "svelte": "^3.48.0",
-    "svelte-check": "^2.9.0",
+    "svelte-check": "^2.10.3",
     "svelte-preprocess": "^4.10.1",
     "ts-jest": "^29.0.3",
     "tslib": "^2.3.1",
diff --git a/src/IQuery.ts b/src/IQuery.ts
index ba785164..8fbac3f4 100644
--- a/src/IQuery.ts
+++ b/src/IQuery.ts
@@ -1,4 +1,4 @@
-import type { LayoutOptions } from './LayoutOptions';
+import type { LayoutOptions } from './TaskLayout';
 import type { Task } from './Task';
 import type { TaskGroups } from './Query/TaskGroups';
 import type { Grouping } from './Query/Query';
diff --git a/src/LayoutOptions.ts b/src/LayoutOptions.ts
deleted file mode 100644
index 3cf5faa7..00000000
--- a/src/LayoutOptions.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export class LayoutOptions {
-    hideTaskCount: boolean = false;
-    hideBacklinks: boolean = false;
-    hidePriority: boolean = false;
-    hideStartDate: boolean = false;
-    hideScheduledDate: boolean = false;
-    hideDoneDate: boolean = false;
-    hideDueDate: boolean = false;
-    hideRecurrenceRule: boolean = false;
-    hideEditButton: boolean = false;
-    hideUrgency: boolean = true;
-    shortMode: boolean = false;
-    explainQuery: boolean = false;
-}
diff --git a/src/Query/Filter/DateField.ts b/src/Query/Filter/DateField.ts
index b2f3d186..68881d9f 100644
--- a/src/Query/Filter/DateField.ts
+++ b/src/Query/Filter/DateField.ts
@@ -2,7 +2,7 @@ import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
 import { Explanation } from '../Explain/Explanation';
-import type { Comparator } from '../Sorting';
+import type { Comparator } from '../Sorter';
 import { Field } from './Field';
 import { Filter, FilterOrErrorMessage } from './Filter';
 import { FilterInstructions } from './FilterInstructions';
diff --git a/src/Query/Filter/DescriptionField.ts b/src/Query/Filter/DescriptionField.ts
index 61ffe153..6b7475f9 100644
--- a/src/Query/Filter/DescriptionField.ts
+++ b/src/Query/Filter/DescriptionField.ts
@@ -1,6 +1,6 @@
 import { getSettings } from '../../Config/Settings';
 import type { Task } from '../../Task';
-import type { Comparator } from '../Sorting';
+import type { Comparator } from '../Sorter';
 import { TextField } from './TextField';
 
 /**
@@ -43,9 +43,9 @@ export class DescriptionField extends TextField {
      */
     public comparator(): Comparator {
         return (a: Task, b: Task) => {
-            return DescriptionField.cleanDescription(a.description).localeCompare(
-                DescriptionField.cleanDescription(b.description),
-            );
+            const descriptionA = DescriptionField.cleanDescription(a.description);
+            const descriptionB = DescriptionField.cleanDescription(b.description);
+            return descriptionA.localeCompare(descriptionB, undefined, { numeric: true });
         };
     }
 
@@ -55,11 +55,11 @@ export class DescriptionField extends TextField {
      * Will remove them only if they are closing.
      * Properly reads links [[like this|one]] (note pipe).
      */
-    private static cleanDescription(description: string): string {
+    public static cleanDescription(description: string): string {
         const globalFilter = getSettings().globalFilter;
         description = description.replace(globalFilter, '').trim();
 
-        const startsWithLinkRegex = /^\[\[?([^\]]*)\]/;
+        const startsWithLinkRegex = /^\[\[?([^\]]*)]]?/;
         const linkRegexMatch = description.match(startsWithLinkRegex);
         if (linkRegexMatch !== null) {
             const innerLinkText = linkRegexMatch[1];
@@ -69,20 +69,26 @@ export class DescriptionField extends TextField {
                 innerLinkText.substring(innerLinkText.indexOf('|') + 1) + description.replace(startsWithLinkRegex, '');
         }
 
-        const startsWithItalicOrBoldRegex = /^\*\*?([^*]*)\*/;
-        const italicBoldRegexMatch = description.match(startsWithItalicOrBoldRegex);
+        description = this.replaceFormatting(description, /^\*\*([^*]+)\*\*/);
+        description = this.replaceFormatting(description, /^\*([^*]+)\*/);
+        description = this.replaceFormatting(description, /^==([^=]+)==/);
+        description = this.replaceFormatting(description, /^__([^_]+)__/);
+        description = this.replaceFormatting(description, /^_([^_]+)_/);
+
+        return description;
+    }
+
+    /**
+     * Remove some formatting from text
+     * @param description
+     * @param regExp A regular expression - all matching text is discarded except the first group
+     */
+    private static replaceFormatting(description: string, regExp: RegExp) {
+        const italicBoldRegexMatch = description.match(regExp);
         if (italicBoldRegexMatch !== null) {
             const innerItalicBoldText = italicBoldRegexMatch[1];
-            description = innerItalicBoldText + description.replace(startsWithLinkRegex, '');
-        }
-
-        const startsWithHighlightRegex = /^==?([^=]*)==/;
-        const highlightRegexMatch = description.match(startsWithHighlightRegex);
-        if (highlightRegexMatch !== null) {
-            const innerHighlightsText = highlightRegexMatch[1];
-            description = innerHighlightsText + description.replace(startsWithHighlightRegex, '');
+            description = innerItalicBoldText + description.replace(regExp, '');
         }
-
         return description;
     }
 }
diff --git a/src/Query/Filter/Field.ts b/src/Query/Filter/Field.ts
index c89b53bf..96cf3c8a 100644
--- a/src/Query/Filter/Field.ts
+++ b/src/Query/Filter/Field.ts
@@ -1,5 +1,5 @@
-import { Sorting } from '../Sorting';
-import type { Comparator } from '../Sorting';
+import { Sorter } from '../Sorter';
+import type { Comparator } from '../Sorter';
 import type { FilterOrErrorMessage } from './Filter';
 
 /**
@@ -104,20 +104,22 @@ export abstract class Field {
     // -----------------------------------------------------------------------------------------------------------------
 
     /**
-     * Return whether the code for this field implements sorting of tasks
+     * Return whether the code for this field implements sorting of tasks.
+     *
+     * If overriding this to return true, in order to enable sorting,
+     * the method ${@link comparator} must also be overridden.
      */
     public supportsSorting(): boolean {
-        // TODO Make abstract
         return false;
     }
 
     /**
-     * Parse a 'sort by' line and return a Sorting object.
+     * Parse a 'sort by' line and return a {@link Sorter} object.
      *
      * Returns null line does not match this field or is invalid,
      * or this field does not support sorting.
      */
-    public parseSortLine(line: string): Sorting | null {
+    public parseSortLine(line: string): Sorter | null {
         if (!this.supportsSorting()) {
             return null;
         }
@@ -126,11 +128,7 @@ export abstract class Field {
             return null;
         }
 
-        const sorting = this.createSorterFromLine(line);
-        if (sorting) {
-            return sorting;
-        }
-        return null;
+        return this.createSorterFromLine(line);
     }
 
     /**
@@ -151,7 +149,7 @@ export abstract class Field {
     }
 
     /**
-     * Parse the line, and return either a {@link Sorting} object or null.
+     * Parse the line, and return either a {@link Sorter} object or null.
      *
      * This default implementation works for all fields that support
      * the default sorting pattern of `sort by <fieldName> (reverse)?`.
@@ -163,7 +161,7 @@ export abstract class Field {
      *
      * @see {@link canCreateSorterForLine}
      */
-    public createSorterFromLine(line: string): Sorting | null {
+    public createSorterFromLine(line: string): Sorter | null {
         if (!this.supportsSorting()) {
             return null;
         }
@@ -198,37 +196,39 @@ export abstract class Field {
 
     /**
      * Return a function to compare two Task objects, for use in sorting by this field's value.
+     *
+     * See ${@link supportsSorting} for what to do, to enable support of sorting in a
+     * particular ${@link Field} implementation.
      */
     public comparator(): Comparator {
-        // TODO Make abstract
         throw Error(`comparator() unimplemented for ${this.fieldNameSingular()}`);
     }
 
     /**
-     * Create a {@link Sorting} object for sorting tasks by this field's value.
+     * Create a {@link Sorter} object for sorting tasks by this field's value.
      * @param reverse - false for normal sort order, true for reverse sort order.
      */
-    public createSorter(reverse: boolean): Sorting {
-        return new Sorting(reverse, this.fieldNameSingular(), this.comparator());
+    public createSorter(reverse: boolean): Sorter {
+        return new Sorter(this.fieldNameSingular(), this.comparator(), reverse);
     }
 
     /**
-     * Create a {@link Sorting} object for sorting tasks by this field's value,
+     * Create a {@link Sorter} object for sorting tasks by this field's value,
      * in the standard/normal sort order for this field.
      *
      * @see {@link createReverseSorter}
      */
-    public createNormalSorter(): Sorting {
+    public createNormalSorter(): Sorter {
         return this.createSorter(false);
     }
 
     /**
-     * Create a {@link Sorting} object for sorting tasks by this field's value,
+     * Create a {@link Sorter} object for sorting tasks by this field's value,
      * in the reverse of the standard/normal sort order for this field.
      *
      * @see {@link createNormalSorter}
      */
-    public createReverseSorter(): Sorting {
+    public createReverseSorter(): Sorter {
         return this.createSorter(true);
     }
 }
diff --git a/src/Query/Filter/FilenameField.ts b/src/Query/Filter/FilenameField.ts
index c6d30423..88972e08 100644
--- a/src/Query/Filter/FilenameField.ts
+++ b/src/Query/Filter/FilenameField.ts
@@ -24,4 +24,8 @@ export class FilenameField extends TextField {
         }
         return filename + '.md';
     }
+
+    supportsSorting(): boolean {
+        return true;
+    }
 }
diff --git a/src/Query/Filter/HappensDateField.ts b/src/Query/Filter/HappensDateField.ts
index b598f055..bf9cf187 100644
--- a/src/Query/Filter/HappensDateField.ts
+++ b/src/Query/Filter/HappensDateField.ts
@@ -2,6 +2,7 @@ import type { Moment } from 'moment';
 import type { Task } from '../../Task';
 import { DateParser } from '../DateParser';
 import { Explanation } from '../Explain/Explanation';
+import type { Comparator } from '../Sorter';
 import { Field } from './Field';
 import { Filter, FilterOrErrorMessage } from './Filter';
 import { FilterInstructions } from './FilterInstructions';
@@ -109,4 +110,17 @@ export class HappensDateField extends Field {
     public fieldName(): string {
         return 'happens';
     }
+
+    public supportsSorting(): boolean {
+        return true;
+    }
+
+    /**
+     * This sorts on the earliest of start, scheduled and due dates.
+     */
+    public comparator(): Comparator {
+        return (a: Task, b: Task) => {
+            return DateField.compareByDate(this.earliestDate(a), this.earliestDate(b));
+        };
+    }
 }
diff --git a/src/Query/Filter/HeadingField.ts b/src/Query/Filter/HeadingField.ts
index 83cd961c..986c575b 100644
--- a/src/Query/Filter/HeadingField.ts
+++ b/src/Query/Filter/HeadingField.ts
@@ -21,4 +21,8 @@ export class HeadingField extends TextField {
             return '';
         }
     }
+
+    supportsSorting(): boolean {
+        return true;
+    }
 }
diff --git a/src/Query/Filter/PathField.ts b/src/Query/Filter/PathField.ts
index c018ab7d..0db74d6e 100644
--- a/src/Query/Filter/PathField.ts
+++ b/src/Query/Filter/PathField.ts
@@ -1,5 +1,4 @@
 import type { Task } from '../../Task';
-import type { Comparator } from '../Sorting';
 import { TextField } from './TextField';
 
 /** Support the 'path' search instruction.
@@ -25,16 +24,4 @@ export class PathField extends TextField {
     public supportsSorting(): boolean {
         return true;
     }
-
-    public comparator(): Comparator {
-        return (a: Task, b: Task) => {
-            if (a.path < b.path) {
-                return -1;
-            } else if (a.path > b.path) {
-                return 1;
-            } else {
-                return 0;
-            }
-        };
-    }
 }
diff --git a/src/Query/Filter/PriorityField.ts b/src/Query/Filter/PriorityField.ts
index 07110d8d..344c056e 100644
--- a/src/Query/Filter/PriorityField.ts
+++ b/src/Query/Filter/PriorityField.ts
@@ -1,6 +1,6 @@
 import { Priority, Task } from '../../Task';
 import { Explanation } from '../Explain/Explanation';
-import type { Comparator } from '../Sorting';
+import type { Comparator } from '../Sorter';
 import { Field } from './Field';
 import { Filter, FilterOrErrorMessage } from './Filter';
 
diff --git a/src/Query/Filter/StatusField.ts b/src/Query/Filter/StatusField.ts
index 5d6a1df5..85dca098 100644
--- a/src/Query/Filter/StatusField.ts
+++ b/src/Query/Filter/StatusField.ts
@@ -1,5 +1,5 @@
 import { Status, Task } from '../../Task';
-import type { Comparator } from '../Sorting';
+import type { Comparator } from '../Sorter';
 import { FilterInstructionsBasedField } from './FilterInstructionsBasedField';
 
 export class StatusField extends FilterInstructionsBasedField {
@@ -21,7 +21,6 @@ export class StatusField extends FilterInstructionsBasedField {
     /**
      * Return a function to compare two Task objects, for use in sorting by status.
      */
-    // TODO Make comparator() in Field through if unimplemented
     public comparator(): Comparator {
         return (a: Task, b: Task) => {
             if (a.status < b.status) {
diff --git a/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index e8f8ce39..324b0086 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -1,6 +1,6 @@
 import type { Task } from '../../Task';
-import type { Comparator } from '../Sorting';
-import { Sorting } from '../Sorting';
+import type { Comparator } from '../Sorter';
+import { Sorter } from '../Sorter';
 import { MultiTextField } from './MultiTextField';
 
 /**
@@ -33,7 +33,7 @@ export class TagsField extends MultiTextField {
      *
      * @param line
      */
-    public createSorterFromLine(line: string): Sorting | null {
+    public createSorterFromLine(line: string): Sorter | null {
         const match = line.match(this.sorterRegExp());
         if (match === null) {
             return null;
@@ -42,7 +42,7 @@ export class TagsField extends MultiTextField {
         const reverse = !!match[1];
         const propertyInstance = isNaN(+match[2]) ? 1 : +match[2];
         const comparator = TagsField.makeCompareByTagComparator(propertyInstance);
-        return new Sorting(reverse, this.fieldNameSingular(), comparator);
+        return new Sorter(this.fieldNameSingular(), comparator, reverse);
     }
 
     /**
@@ -87,13 +87,9 @@ export class TagsField extends MultiTextField {
                 return 0;
             }
 
-            if (a.tags[tagInstanceToSortBy] < b.tags[tagInstanceToSortBy]) {
-                return -1;
-            } else if (a.tags[tagInstanceToSortBy] > b.tags[tagInstanceToSortBy]) {
-                return 1;
-            } else {
-                return 0;
-            }
+            const tagA = a.tags[tagInstanceToSortBy];
+            const tagB = b.tags[tagInstanceToSortBy];
+            return tagA.localeCompare(tagB, undefined, { numeric: true });
         };
     }
 }
diff --git a/src/Query/Filter/TextField.ts b/src/Query/Filter/TextField.ts
index db93db89..694b16e6 100644
--- a/src/Query/Filter/TextField.ts
+++ b/src/Query/Filter/TextField.ts
@@ -3,6 +3,7 @@ import { SubstringMatcher } from '../Matchers/SubstringMatcher';
 import { RegexMatcher } from '../Matchers/RegexMatcher';
 import type { IStringMatcher } from '../Matchers/IStringMatcher';
 import { Explanation } from '../Explain/Explanation';
+import type { Comparator } from '../Sorter';
 import { Field } from './Field';
 import type { FilterFunction } from './Filter';
 import { Filter, FilterOrErrorMessage } from './Filter';
@@ -89,4 +90,17 @@ export abstract class TextField extends Field {
             return negate ? !match : match;
         };
     }
+
+    /**
+     * A default implementation of sorting, for text fields where simple locale-aware sorting is the
+     * desired behaviour.
+     *
+     * Each class that wants to use this will need to override supportsSorting() to return true,
+     * to turn on sorting.
+     */
+    comparator(): Comparator {
+        return (a: Task, b: Task) => {
+            return this.value(a).localeCompare(this.value(b), undefined, { numeric: true });
+        };
+    }
 }
diff --git a/src/Query/Filter/UrgencyField.ts b/src/Query/Filter/UrgencyField.ts
index 93bab692..a9ddb1b0 100644
--- a/src/Query/Filter/UrgencyField.ts
+++ b/src/Query/Filter/UrgencyField.ts
@@ -1,4 +1,4 @@
-import type { Comparator } from '../Sorting';
+import type { Comparator } from '../Sorter';
 import type { Task } from '../../Task';
 import { Field } from './Field';
 import { FilterOrErrorMessage } from './Filter';
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index cada02a1..f7bfaa05 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -16,7 +16,7 @@ import { FilenameField } from './Filter/FilenameField';
 import { UrgencyField } from './Filter/UrgencyField';
 
 import type { FilterOrErrorMessage } from './Filter/Filter';
-import type { Sorting } from './Sorting';
+import type { Sorter } from './Sorter';
 
 const fieldCreators = [
     () => new StatusField(),
@@ -45,7 +45,7 @@ export function parseFilter(filterString: string): FilterOrErrorMessage | null {
     return null;
 }
 
-export function parseSorter(sorterString: string): Sorting | null {
+export function parseSorter(sorterString: string): Sorter | null {
     // New style parsing, using sorting which is done by the Field classes.
 
     // Optimisation: Check whether line begins with 'sort by'
diff --git a/src/Query/Group.ts b/src/Query/Group.ts
index a62668b9..0001dae1 100644
--- a/src/Query/Group.ts
+++ b/src/Query/Group.ts
@@ -148,7 +148,7 @@ export class Group {
         if (filename === null) {
             return ['Unknown Location'];
         }
-        return [Group.escapeMarkdownCharacters(filename)];
+        return ['[[' + Group.escapeMarkdownCharacters(filename) + ']]'];
     }
 
     private static groupByRoot(task: Task): string[] {
@@ -166,12 +166,18 @@ export class Group {
             return ['Unknown Location'];
         }
 
-        // Markdown characters in the file name must be escaped.
-        // Markdown characters in the heading must NOT be escaped.
-        const filenameComponent = Group.groupByFileName(task)[0];
+        let filenameComponent = 'Unknown Location';
+
+        if (task.filename !== null) {
+            // Markdown characters in the file name must be escaped.
+            filenameComponent = Group.escapeMarkdownCharacters(task.filename);
+        }
+
         if (task.precedingHeader === null || task.precedingHeader.length === 0) {
             return [filenameComponent];
         }
+
+        // Markdown characters in the heading must NOT be escaped.
         const headingComponent = Group.groupByHeading(task)[0];
 
         if (filenameComponent === headingComponent) {
diff --git a/src/Query/Query.ts b/src/Query/Query.ts
index 136b8145..c9bca724 100644
--- a/src/Query/Query.ts
+++ b/src/Query/Query.ts
@@ -1,9 +1,9 @@
-import { LayoutOptions } from '../LayoutOptions';
+import { LayoutOptions } from '../TaskLayout';
 import type { Task } from '../Task';
 import type { IQuery } from '../IQuery';
 import { getSettings } from '../Config/Settings';
 import { Sort } from './Sort';
-import type { Sorting } from './Sorting';
+import type { Sorter } from './Sorter';
 import type { TaskGroups } from './TaskGroups';
 import { parseFilter, parseSorter } from './FilterParser';
 import { Group } from './Group';
@@ -35,7 +35,7 @@ export class Query implements IQuery {
     private _layoutOptions: LayoutOptions = new LayoutOptions();
     private _filters: Filter[] = [];
     private _error: string | undefined = undefined;
-    private _sorting: Sorting[] = [];
+    private _sorting: Sorter[] = [];
     private _grouping: Grouping[] = [];
 
     private readonly groupByRegexp =
@@ -68,7 +68,7 @@ export class Query implements IQuery {
                     case this.limitRegexp.test(line):
                         this.parseLimit({ line });
                         break;
-                    case this.parseSortBy2({ line }):
+                    case this.parseSortBy({ line }):
                         break;
                     case this.groupByRegexp.test(line):
                         this.parseGroupBy({ line });
@@ -149,7 +149,7 @@ export class Query implements IQuery {
             tasks = tasks.filter(filter.filterFunction);
         });
 
-        const tasksSortedLimited = Sort.by(this, tasks).slice(0, this.limit);
+        const tasksSortedLimited = Sort.by(this.sorting, tasks).slice(0, this.limit);
         return Group.by(this.grouping, tasksSortedLimited);
     }
 
@@ -216,7 +216,7 @@ export class Query implements IQuery {
         }
     }
 
-    private parseSortBy2({ line }: { line: string }): boolean {
+    private parseSortBy({ line }: { line: string }): boolean {
         const sortingMaybe = parseSorter(line);
         if (sortingMaybe) {
             this._sorting.push(sortingMaybe);
diff --git a/src/Query/Sort.ts b/src/Query/Sort.ts
index e356a4b1..b660e72b 100644
--- a/src/Query/Sort.ts
+++ b/src/Query/Sort.ts
@@ -1,7 +1,6 @@
 import type { Task } from '../Task';
-import type { Comparator } from './Sorting';
-import type { Query } from './Query';
-// TODO Remove the cyclic dependency between StatusField and Sort.
+import type { Comparator } from './Sorter';
+import type { Sorter } from './Sorter';
 import { StatusField } from './Filter/StatusField';
 import { DueDateField } from './Filter/DueDateField';
 import { PriorityField } from './Filter/PriorityField';
@@ -9,8 +8,7 @@ import { PathField } from './Filter/PathField';
 import { UrgencyField } from './Filter/UrgencyField';
 
 export class Sort {
-    public static by(query: Pick<Query, 'sorting'>, tasks: Task[]): Task[] {
-        // TODO Move code for creating default comparators to separate file
+    public static by(sorters: Sorter[], tasks: Task[]) {
         const defaultComparators: Comparator[] = [
             new UrgencyField().comparator(),
             new StatusField().comparator(),
@@ -21,8 +19,8 @@ export class Sort {
 
         const userComparators: Comparator[] = [];
 
-        for (const sorting of query.sorting) {
-            userComparators.push(sorting.comparator);
+        for (const sorter of sorters) {
+            userComparators.push(sorter.comparator);
         }
 
         return tasks.sort(Sort.makeCompositeComparator([...userComparators, ...defaultComparators]));
diff --git a/src/Query/Sorting.ts b/src/Query/Sorter.ts
similarity index 58%
rename from src/Query/Sorting.ts
rename to src/Query/Sorter.ts
index 18ce6539..5ab233f9 100644
--- a/src/Query/Sorting.ts
+++ b/src/Query/Sorter.ts
@@ -7,35 +7,33 @@ import type { Task } from '../Task';
  * - `+1` or some other positive number, if a is greater than b by the ordering criterion.
  * - `0` or sometimes `-0`, if a equals b by the ordering criterion.
  *
- * Typically Comparator functions are stored in a {@link Sorting} object.
+ * Typically Comparator functions are stored in a {@link Sorter} object.
  */
 export type Comparator = (a: Task, b: Task) => number;
 
 /**
- * Sorting represents a single 'sort by' instruction.
+ * Sorter represents a single 'sort by' instruction.
  * It stores the comparison function as a {@link Comparator}.
  */
-export class Sorting {
+export class Sorter {
     public readonly property: string;
     public readonly comparator: Comparator;
 
     /**
      * Constructor.
      *
-     * TODO Once SortingProperty has been removed, re-order the parameters so the comparator comes first.
-     *
+     * @param property - the name of the property.
+     * @param comparator - {@link Comparator} function, for sorting in the standard direction.
+     *                     If `reverse` is true, it will automatically be converted to reverse the sort direction.
      * @param reverse - whether the sort order should be reversed.
-     * @param property - the name of the property. If {@link comparator} is not supplied, this string must match
-     *                   one of the values in ${@link SortingProperty}.
-     * @param comparator - {@link Comparator} function.
      */
-    constructor(reverse: boolean, property: string, comparator: Comparator) {
+    constructor(property: string, comparator: Comparator, reverse: boolean) {
         this.property = property;
-        this.comparator = Sorting.maybeReverse(reverse, comparator);
+        this.comparator = Sorter.maybeReverse(reverse, comparator);
     }
 
     private static maybeReverse(reverse: boolean, comparator: Comparator) {
-        return reverse ? Sorting.makeReversedComparator(comparator) : comparator;
+        return reverse ? Sorter.makeReversedComparator(comparator) : comparator;
     }
 
     private static makeReversedComparator(comparator: Comparator): Comparator {
diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts
index edaa5e87..4002c2db 100644
--- a/src/QueryRenderer.ts
+++ b/src/QueryRenderer.ts
@@ -221,7 +221,7 @@ class QueryRenderChild extends MarkdownRenderChild {
         return { taskList, tasksCount };
     }
 
-    private addEditButton(listItem: HTMLLIElement, task: Task) {
+    private addEditButton(listItem: HTMLElement, task: Task) {
         const editTaskPencil = listItem.createEl('a', {
             cls: 'tasks-edit',
         });
@@ -245,7 +245,7 @@ class QueryRenderChild extends MarkdownRenderChild {
         });
     }
 
-    private addUrgency(listItem: HTMLLIElement, task: Task) {
+    private addUrgency(listItem: HTMLElement, task: Task) {
         const text = new Intl.NumberFormat().format(task.urgency);
         listItem.createSpan({ text, cls: 'tasks-urgency' });
     }
@@ -285,12 +285,7 @@ class QueryRenderChild extends MarkdownRenderChild {
         await MarkdownRenderer.renderMarkdown(group.name, header, this.filePath, this);
     }
 
-    private addBacklinks(
-        listItem: HTMLLIElement,
-        task: Task,
-        shortMode: boolean,
-        isFilenameUnique: boolean | undefined,
-    ) {
+    private addBacklinks(listItem: HTMLElement, task: Task, shortMode: boolean, isFilenameUnique: boolean | undefined) {
         const backLink = listItem.createSpan({ cls: 'tasks-backlink' });
 
         if (!shortMode) {
diff --git a/src/Task.ts b/src/Task.ts
index 59db4f5b..891e9488 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -1,11 +1,12 @@
 import type { Moment } from 'moment';
-import { Component, MarkdownRenderer } from 'obsidian';
-import { replaceTaskWithTasks } from './File';
-import { LayoutOptions } from './LayoutOptions';
+import { LayoutOptions, TaskLayout } from './TaskLayout';
+import type { TaskLayoutComponent } from './TaskLayout';
 import { Recurrence } from './Recurrence';
 import { getSettings } from './Config/Settings';
 import { Urgency } from './Urgency';
 import { DateField } from './Query/Filter/DateField';
+import { renderTaskLine } from './TaskLineRenderer';
+import type { TaskLineRenderDetails } from './TaskLineRenderer';
 import { DateFallback } from './DateFallback';
 
 /**
@@ -445,159 +446,78 @@ export class Task {
         });
     }
 
-    public async toLi({
-        parentUlElement,
-        listIndex,
-        layoutOptions,
-        isFilenameUnique,
-    }: {
-        parentUlElement: HTMLElement;
-        /** The nth item in this list (including non-tasks). */
-        listIndex: number;
-        layoutOptions?: LayoutOptions;
-        isFilenameUnique?: boolean;
-    }): Promise<HTMLLIElement> {
-        const li: HTMLLIElement = parentUlElement.createEl('li');
-        li.addClasses(['task-list-item', 'plugin-tasks-list-item']);
-
-        let taskAsString = this.toString(layoutOptions);
-        const { globalFilter, removeGlobalFilter } = getSettings();
-        if (removeGlobalFilter) {
-            taskAsString = taskAsString.replace(globalFilter, '').trim();
-        }
-
-        const textSpan = li.createSpan();
-        textSpan.addClass('tasks-list-text');
-
-        await MarkdownRenderer.renderMarkdown(taskAsString, textSpan, this.path, null as unknown as Component);
-
-        // If the task is a block quote, the block quote wraps the p-tag that contains the content.
-        // In that case, we need to unwrap the p-tag *inside* the surrounding block quote.
-        // Otherwise, we unwrap the p-tag as a direct descendant of the textSpan.
-        const blockQuote = textSpan.querySelector('blockquote');
-        const directParentOfPTag = blockQuote ?? textSpan;
-
-        // Unwrap the p-tag that was created by the MarkdownRenderer:
-        const pElement = directParentOfPTag.querySelector('p');
-        if (pElement !== null) {
-            while (pElement.firstChild) {
-                directParentOfPTag.insertBefore(pElement.firstChild, pElement);
-            }
-            pElement.remove();
-        }
-
-        // Remove an empty trailing p-tag that the MarkdownRenderer appends when there is a block link:
-        textSpan.findAll('p').forEach((pElement) => {
-            if (!pElement.hasChildNodes()) {
-                pElement.remove();
-            }
-        });
-
-        // Remove the footnote that the MarkdownRenderer appends when there is a footnote in the task:
-        textSpan.findAll('.footnotes').forEach((footnoteElement) => {
-            footnoteElement.remove();
-        });
-
-        const checkbox = li.createEl('input');
-        checkbox.addClass('task-list-item-checkbox');
-        checkbox.type = 'checkbox';
-        if (this.status !== Status.TODO) {
-            checkbox.checked = true;
-            li.addClass('is-checked');
-        }
-        checkbox.onClickEvent((event: MouseEvent) => {
-            event.preventDefault();
-            // It is required to stop propagation so that obsidian won't write the file with the
-            // checkbox (un)checked. Obsidian would write after us and overwrite our change.
-            event.stopPropagation();
-
-            // Should be re-rendered as enabled after update in file.
-            checkbox.disabled = true;
-            const toggledTasks = this.toggle();
-            replaceTaskWithTasks({
-                originalTask: this,
-                newTasks: toggledTasks,
-            });
-        });
-
-        li.prepend(checkbox);
-
-        // Set these to be compatible with stock obsidian lists:
-        li.setAttr('data-task', this.originalStatusCharacter.trim()); // Trim to ensure empty attribute for space. Same way as obsidian.
-        li.setAttr('data-line', listIndex);
-        checkbox.setAttr('data-line', listIndex);
-
-        if (layoutOptions?.shortMode) {
-            this.addTooltip({ element: textSpan, isFilenameUnique });
-        }
-
-        return li;
+    /**
+     * Create an HTML rendered List Item element (LI) for the current task.
+     * @param {renderTails}
+     */
+    public async toLi(renderDetails: TaskLineRenderDetails): Promise<HTMLLIElement> {
+        return renderTaskLine(this, renderDetails);
     }
 
     /**
-     *
-     *
+     * Flatten the task as a string that includes all its components.
      * @param {LayoutOptions} [layoutOptions]
      * @return {*}  {string}
      * @memberof Task
      */
     public toString(layoutOptions?: LayoutOptions): string {
-        layoutOptions = layoutOptions ?? new LayoutOptions();
-        let taskString = this.description;
-
-        if (!layoutOptions.hidePriority) {
-            let priority: string = '';
-
-            if (this.priority === Priority.High) {
-                priority = ' ' + prioritySymbols.High;
-            } else if (this.priority === Priority.Medium) {
-                priority = ' ' + prioritySymbols.Medium;
-            } else if (this.priority === Priority.Low) {
-                priority = ' ' + prioritySymbols.Low;
-            }
-
-            taskString += priority;
-        }
-
-        if (!layoutOptions.hideRecurrenceRule && this.recurrence) {
-            const recurrenceRule: string = layoutOptions.shortMode
-                ? ' ' + recurrenceSymbol
-                : ` ${recurrenceSymbol} ${this.recurrence.toText()}`;
-            taskString += recurrenceRule;
-        }
-
-        if (!layoutOptions.hideStartDate && this.startDate) {
-            const startDate: string = layoutOptions.shortMode
-                ? ' ' + startDateSymbol
-                : ` ${startDateSymbol} ${this.startDate.format(TaskRegularExpressions.dateFormat)}`;
-            taskString += startDate;
-        }
-
-        if (!layoutOptions.hideScheduledDate && this.scheduledDate && !this.scheduledDateIsInferred) {
-            const scheduledDate: string = layoutOptions.shortMode
-                ? ' ' + scheduledDateSymbol
-                : ` ${scheduledDateSymbol} ${this.scheduledDate.format(TaskRegularExpressions.dateFormat)}`;
-            taskString += scheduledDate;
-        }
-
-        if (!layoutOptions.hideDueDate && this.dueDate) {
-            const dueDate: string = layoutOptions.shortMode
-                ? ' ' + dueDateSymbol
-                : ` ${dueDateSymbol} ${this.dueDate.format(TaskRegularExpressions.dateFormat)}`;
-            taskString += dueDate;
+        const taskLayout = new TaskLayout(layoutOptions);
+        let taskString = '';
+        for (const component of taskLayout.layoutComponents) {
+            taskString += this.componentToString(taskLayout, component);
         }
+        return taskString;
+    }
 
-        if (!layoutOptions.hideDoneDate && this.doneDate) {
-            const doneDate: string = layoutOptions.shortMode
-                ? ' ' + doneDateSymbol
-                : ` ${doneDateSymbol} ${this.doneDate.format(TaskRegularExpressions.dateFormat)}`;
-            taskString += doneDate;
+    /**
+     * Renders a specific TaskLayoutComponent of the task (its description, priority, etc) as a string.
+     */
+    public componentToString(layout: TaskLayout, component: TaskLayoutComponent) {
+        switch (component) {
+            case 'description':
+                return this.description;
+            case 'priority': {
+                let priority: string = '';
+
+                if (this.priority === Priority.High) {
+                    priority = ' ' + prioritySymbols.High;
+                } else if (this.priority === Priority.Medium) {
+                    priority = ' ' + prioritySymbols.Medium;
+                } else if (this.priority === Priority.Low) {
+                    priority = ' ' + prioritySymbols.Low;
+                }
+                return priority;
+            }
+            case 'startDate':
+                if (!this.startDate) return '';
+                return layout.options.shortMode
+                    ? ' ' + startDateSymbol
+                    : ` ${startDateSymbol} ${this.startDate.format(TaskRegularExpressions.dateFormat)}`;
+            case 'scheduledDate':
+                if (!this.scheduledDate || this.scheduledDateIsInferred) return '';
+                return layout.options.shortMode
+                    ? ' ' + scheduledDateSymbol
+                    : ` ${scheduledDateSymbol} ${this.scheduledDate.format(TaskRegularExpressions.dateFormat)}`;
+            case 'doneDate':
+                if (!this.doneDate) return '';
+                return layout.options.shortMode
+                    ? ' ' + doneDateSymbol
+                    : ` ${doneDateSymbol} ${this.doneDate.format(TaskRegularExpressions.dateFormat)}`;
+            case 'dueDate':
+                if (!this.dueDate) return '';
+                return layout.options.shortMode
+                    ? ' ' + dueDateSymbol
+                    : ` ${dueDateSymbol} ${this.dueDate.format(TaskRegularExpressions.dateFormat)}`;
+            case 'recurrenceRule':
+                if (!this.recurrence) return '';
+                return layout.options.shortMode
+                    ? ' ' + recurrenceSymbol
+                    : ` ${recurrenceSymbol} ${this.recurrence.toText()}`;
+            case 'blockLink':
+                return this.blockLink ?? '';
+            default:
+                throw new Error(`Don't know how to render task component of type '${component}'`);
         }
-
-        const blockLink: string = this.blockLink ?? '';
-        taskString += blockLink;
-
-        return taskString;
     }
 
     /**
@@ -809,80 +729,6 @@ export class Task {
         return true;
     }
 
-    private addTooltip({
-        element,
-        isFilenameUnique,
-    }: {
-        element: HTMLElement;
-        isFilenameUnique: boolean | undefined;
-    }): void {
-        element.addEventListener('mouseenter', () => {
-            const tooltip = element.createDiv();
-            tooltip.addClasses(['tooltip', 'mod-right']);
-
-            if (this.recurrence) {
-                const recurrenceDiv = tooltip.createDiv();
-                recurrenceDiv.setText(`${recurrenceSymbol} ${this.recurrence.toText()}`);
-            }
-
-            if (this.startDate) {
-                const startDateDiv = tooltip.createDiv();
-                startDateDiv.setText(
-                    Task.toTooltipDate({
-                        signifier: startDateSymbol,
-                        date: this.startDate,
-                    }),
-                );
-            }
-
-            if (this.scheduledDate) {
-                const scheduledDateDiv = tooltip.createDiv();
-                scheduledDateDiv.setText(
-                    Task.toTooltipDate({
-                        signifier: scheduledDateSymbol,
-                        date: this.scheduledDate,
-                    }),
-                );
-            }
-
-            if (this.dueDate) {
-                const dueDateDiv = tooltip.createDiv();
-                dueDateDiv.setText(
-                    Task.toTooltipDate({
-                        signifier: dueDateSymbol,
-                        date: this.dueDate,
-                    }),
-                );
-            }
-
-            if (this.doneDate) {
-                const doneDateDiv = tooltip.createDiv();
-                doneDateDiv.setText(
-                    Task.toTooltipDate({
-                        signifier: doneDateSymbol,
-                        date: this.doneDate,
-                    }),
-                );
-            }
-
-            const linkText = this.getLinkText({ isFilenameUnique });
-            if (linkText) {
-                const backlinkDiv = tooltip.createDiv();
-                backlinkDiv.setText(`🔗 ${linkText}`);
-            }
-
-            element.addEventListener('mouseleave', () => {
-                tooltip.remove();
-            });
-        });
-    }
-
-    private static toTooltipDate({ signifier, date }: { signifier: string; date: Moment }): string {
-        return `${signifier} ${date.format(TaskRegularExpressions.dateFormat)} (${date.from(
-            window.moment().startOf('day'),
-        )})`;
-    }
-
     /**
      * Escape a string so it can be used as part of a RegExp literally.
      * Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
diff --git a/src/TaskLayout.ts b/src/TaskLayout.ts
new file mode 100644
index 00000000..29a0a1b9
--- /dev/null
+++ b/src/TaskLayout.ts
@@ -0,0 +1,90 @@
+/**
+ * Various rendering options for a query.
+ */
+export class LayoutOptions {
+    hideTaskCount: boolean = false;
+    hideBacklinks: boolean = false;
+    hidePriority: boolean = false;
+    hideStartDate: boolean = false;
+    hideScheduledDate: boolean = false;
+    hideDoneDate: boolean = false;
+    hideDueDate: boolean = false;
+    hideRecurrenceRule: boolean = false;
+    hideEditButton: boolean = false;
+    hideUrgency: boolean = true;
+    shortMode: boolean = false;
+    explainQuery: boolean = false;
+}
+
+export type TaskLayoutComponent =
+    | 'description'
+    | 'priority'
+    | 'recurrenceRule'
+    | 'startDate'
+    | 'scheduledDate'
+    | 'dueDate'
+    | 'doneDate'
+    | 'blockLink';
+
+/**
+ * This represents the desired layout of tasks when they are rendered in a given configuration.
+ * The layout is used when flattening the task to a string and when rendering queries, and can be
+ * modified by applying {@link LayoutOptions} objects.
+ */
+export class TaskLayout {
+    public defaultLayout: TaskLayoutComponent[] = [
+        'description',
+        'priority',
+        'recurrenceRule',
+        'startDate',
+        'scheduledDate',
+        'dueDate',
+        'doneDate',
+        'blockLink',
+    ];
+    public layoutComponents: TaskLayoutComponent[];
+    public options: LayoutOptions;
+
+    constructor(options?: LayoutOptions, components?: TaskLayoutComponent[]) {
+        if (options) {
+            this.options = options;
+        } else {
+            this.options = new LayoutOptions();
+        }
+        if (components) {
+            this.layoutComponents = components;
+        } else {
+            this.layoutComponents = this.defaultLayout;
+        }
+
+        this.layoutComponents = this.applyOptions(this.options);
+    }
+
+    /**
+     * Return a new list of components with the given options applied.
+     */
+    applyOptions(layoutOptions: LayoutOptions): TaskLayoutComponent[] {
+        // Remove a component from the taskComponents array if the given layoutOption criteria is met
+        const removeIf = (
+            taskComponents: TaskLayoutComponent[],
+            shouldRemove: boolean,
+            componentToRemove: TaskLayoutComponent,
+        ) => {
+            if (shouldRemove) {
+                return taskComponents.filter((element) => element != componentToRemove);
+            } else {
+                return taskComponents;
+            }
+        };
+        // Remove components from the layout according to the task options. These represent the existing task options,
+        // so some components (e.g. the description) are not here because there are no layout options to remove them.
+        let newComponents = this.layoutComponents;
+        newComponents = removeIf(newComponents, layoutOptions.hidePriority, 'priority');
+        newComponents = removeIf(newComponents, layoutOptions.hideRecurrenceRule, 'recurrenceRule');
+        newComponents = removeIf(newComponents, layoutOptions.hideStartDate, 'startDate');
+        newComponents = removeIf(newComponents, layoutOptions.hideScheduledDate, 'scheduledDate');
+        newComponents = removeIf(newComponents, layoutOptions.hideDueDate, 'dueDate');
+        newComponents = removeIf(newComponents, layoutOptions.hideDoneDate, 'doneDate');
+        return newComponents;
+    }
+}
diff --git a/src/TaskLineRenderer.ts b/src/TaskLineRenderer.ts
new file mode 100644
index 00000000..5e195590
--- /dev/null
+++ b/src/TaskLineRenderer.ts
@@ -0,0 +1,238 @@
+import { Component, MarkdownRenderer } from 'obsidian';
+import type { Moment } from 'moment';
+import type { Task } from './Task';
+import * as taskModule from './Task';
+import type { LayoutOptions, TaskLayoutComponent } from './TaskLayout';
+import { TaskLayout } from './TaskLayout';
+import { replaceTaskWithTasks } from './File';
+import { getSettings } from './Config/Settings';
+
+export type TaskLineRenderDetails = {
+    parentUlElement: HTMLElement;
+    /** The nth item in this list (including non-tasks). */
+    listIndex: number;
+    layoutOptions?: LayoutOptions;
+    isFilenameUnique?: boolean;
+};
+
+/**
+ * The function used to render a Markdown task line into an existing HTML element.
+ */
+export type TextRenderer = (text: string, element: HTMLSpanElement, path: string) => Promise<void>;
+
+async function obsidianMarkdownRenderer(text: string, element: HTMLSpanElement, path: string) {
+    await MarkdownRenderer.renderMarkdown(text, element, path, null as unknown as Component);
+}
+
+/**
+ * Renders a given Task object into an HTML List Item (LI) element, using the given renderDetails
+ * configuration and a supplied TextRenderer (typically the Obsidian Markdown renderer, but for testing
+ * purposes it can be a simpler one).
+ * The element includes the task and its various components (description, priority, block link etc), the
+ * checkbox on the left with its event handling of completing the task, and the button for editing the task.
+ */
+export async function renderTaskLine(
+    task: Task,
+    renderDetails: TaskLineRenderDetails,
+    textRenderer: TextRenderer | null = null,
+): Promise<HTMLLIElement> {
+    if (!textRenderer) textRenderer = obsidianMarkdownRenderer;
+    const li: HTMLLIElement = document.createElement('li');
+    renderDetails.parentUlElement.appendChild(li);
+
+    li.classList.add('task-list-item', 'plugin-tasks-list-item');
+
+    // Maintenance note:
+    //  We don't use the Obsidian convenience function li.createEl() here, because we don't have it available
+    //  when running tests, and we want the tests to be able to create the full div and span structure,
+    //  so had to convert all of these to the equivalent but more elaborate document.createElement() and
+    //  appendChild() calls.
+
+    const textSpan = document.createElement('span');
+    li.appendChild(textSpan);
+    textSpan.classList.add('tasks-list-text');
+    await taskToHtml(task, renderDetails, textSpan, textRenderer);
+
+    // NOTE: this area is mentioned in `CONTRIBUTING.md` under "How does Tasks handle status changes". When
+    // moving the code, remember to update that reference too.
+    const checkbox = document.createElement('input');
+    li.appendChild(checkbox);
+    checkbox.classList.add('task-list-item-checkbox');
+    checkbox.type = 'checkbox';
+    if (task.status !== taskModule.Status.TODO) {
+        checkbox.checked = true;
+        li.classList.add('is-checked');
+    }
+
+    checkbox.addEventListener('click', (event: MouseEvent) => {
+        event.preventDefault();
+        // It is required to stop propagation so that obsidian won't write the file with the
+        // checkbox (un)checked. Obsidian would write after us and overwrite our change.
+        event.stopPropagation();
+
+        // Should be re-rendered as enabled after update in file.
+        checkbox.disabled = true;
+        const toggledTasks = task.toggle();
+        replaceTaskWithTasks({
+            originalTask: task,
+            newTasks: toggledTasks,
+        });
+    });
+
+    li.prepend(checkbox);
+
+    // Set these to be compatible with stock obsidian lists:
+    li.setAttribute('data-task', task.originalStatusCharacter.trim()); // Trim to ensure empty attribute for space. Same way as obsidian.
+    li.setAttribute('data-line', renderDetails.listIndex.toString());
+    checkbox.setAttribute('data-line', renderDetails.listIndex.toString());
+
+    if (renderDetails.layoutOptions?.shortMode) {
+        addTooltip({ task, element: textSpan, isFilenameUnique: renderDetails.isFilenameUnique });
+    }
+
+    return li;
+}
+
+async function taskToHtml(
+    task: Task,
+    renderDetails: TaskLineRenderDetails,
+    parentElement: HTMLElement,
+    textRenderer: TextRenderer,
+) {
+    let taskAsString = '';
+    const taskLayout = new TaskLayout(renderDetails.layoutOptions);
+    for (const component of taskLayout.layoutComponents) {
+        let componentString = task.componentToString(taskLayout, component);
+        if (componentString) {
+            if (component === 'description') componentString = removeGlobalFilterIfNeeded(componentString);
+            taskAsString += componentString;
+        }
+    }
+
+    await renderComponentText(parentElement, taskAsString, 'description', task, textRenderer);
+}
+
+async function renderComponentText(
+    span: HTMLSpanElement,
+    componentString: string,
+    component: TaskLayoutComponent,
+    task: Task,
+    textRenderer: TextRenderer,
+) {
+    if (component === 'description') {
+        await textRenderer(componentString, span, task.path);
+
+        // If the task is a block quote, the block quote wraps the p-tag that contains the content.
+        // In that case, we need to unwrap the p-tag *inside* the surrounding block quote.
+        // Otherwise, we unwrap the p-tag as a direct descendant of the span.
+        const blockQuote = span.querySelector('blockquote');
+        const directParentOfPTag = blockQuote ?? span;
+
+        // Unwrap the p-tag that was created by the MarkdownRenderer:
+        const pElement = directParentOfPTag.querySelector('p');
+        if (pElement !== null) {
+            while (pElement.firstChild) {
+                directParentOfPTag.insertBefore(pElement.firstChild, pElement);
+            }
+            pElement.remove();
+        }
+
+        // Remove an empty trailing p-tag that the MarkdownRenderer appends when there is a block link:
+        span.querySelectorAll('p').forEach((pElement) => {
+            if (!pElement.hasChildNodes()) {
+                pElement.remove();
+            }
+        });
+
+        // Remove the footnote that the MarkdownRenderer appends when there is a footnote in the task:
+        span.querySelectorAll('.footnotes').forEach((footnoteElement) => {
+            footnoteElement.remove();
+        });
+    } else {
+        span.innerHTML = componentString;
+    }
+}
+
+function addTooltip({
+    task,
+    element,
+    isFilenameUnique,
+}: {
+    task: Task;
+    element: HTMLElement;
+    isFilenameUnique: boolean | undefined;
+}): void {
+    element.addEventListener('mouseenter', () => {
+        const tooltip = element.createDiv();
+        tooltip.addClasses(['tooltip', 'mod-right']);
+
+        if (task.recurrence) {
+            const recurrenceDiv = tooltip.createDiv();
+            recurrenceDiv.setText(`${taskModule.recurrenceSymbol} ${task.recurrence.toText()}`);
+        }
+
+        if (task.startDate) {
+            const startDateDiv = tooltip.createDiv();
+            startDateDiv.setText(
+                toTooltipDate({
+                    signifier: taskModule.startDateSymbol,
+                    date: task.startDate,
+                }),
+            );
+        }
+
+        if (task.scheduledDate) {
+            const scheduledDateDiv = tooltip.createDiv();
+            scheduledDateDiv.setText(
+                toTooltipDate({
+                    signifier: taskModule.scheduledDateSymbol,
+                    date: task.scheduledDate,
+                }),
+            );
+        }
+
+        if (task.dueDate) {
+            const dueDateDiv = tooltip.createDiv();
+            dueDateDiv.setText(
+                toTooltipDate({
+                    signifier: taskModule.dueDateSymbol,
+                    date: task.dueDate,
+                }),
+            );
+        }
+
+        if (task.doneDate) {
+            const doneDateDiv = tooltip.createDiv();
+            doneDateDiv.setText(
+                toTooltipDate({
+                    signifier: taskModule.doneDateSymbol,
+                    date: task.doneDate,
+                }),
+            );
+        }
+
+        const linkText = task.getLinkText({ isFilenameUnique });
+        if (linkText) {
+            const backlinkDiv = tooltip.createDiv();
+            backlinkDiv.setText(`🔗 ${linkText}`);
+        }
+
+        element.addEventListener('mouseleave', () => {
+            tooltip.remove();
+        });
+    });
+}
+
+function toTooltipDate({ signifier, date }: { signifier: string; date: Moment }): string {
+    return `${signifier} ${date.format(taskModule.TaskRegularExpressions.dateFormat)} (${date.from(
+        window.moment().startOf('day'),
+    )})`;
+}
+
+function removeGlobalFilterIfNeeded(description: string) {
+    const { globalFilter, removeGlobalFilter } = getSettings();
+    if (removeGlobalFilter) {
+        return description.replace(globalFilter, '').trim();
+    }
+    return description;
+}
diff --git a/yarn.lock b/yarn.lock
index edc2779e..1d142597 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -813,7 +813,7 @@
     "@jridgewell/sourcemap-codec" "^1.4.10"
     "@jridgewell/trace-mapping" "^0.3.9"
 
-"@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3":
+"@jridgewell/resolve-uri@3.1.0":
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
   integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
@@ -828,15 +828,7 @@
   resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
   integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
 
-"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.9":
-  version "0.3.14"
-  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed"
-  integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==
-  dependencies:
-    "@jridgewell/resolve-uri" "^3.0.3"
-    "@jridgewell/sourcemap-codec" "^1.4.10"
-
-"@jridgewell/trace-mapping@^0.3.15":
+"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.15", "@jridgewell/trace-mapping@^0.3.9":
   version "0.3.17"
   resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985"
   integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
@@ -4221,10 +4213,10 @@ supports-preserve-symlinks-flag@^1.0.0:
   resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
-svelte-check@^2.9.0:
-  version "2.9.0"
-  resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-2.9.0.tgz#94a4bbe5bccade5a9edae987f417040ab7af4a2a"
-  integrity sha512-9AVrtP7WbfDgCdqTZNPdj5CCCy1OrYMxFVWAWzNw7fl93c9klFJFtqzVXa6fovfQ050CcpUyJE2dPFL9TFAREw==
+svelte-check@^2.10.3:
+  version "2.10.3"
+  resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-2.10.3.tgz#c16426c47630e024bf3cd5fab9b8c4f1702db34a"
+  integrity sha512-Nt1aWHTOKFReBpmJ1vPug0aGysqPwJh2seM1OvICfM2oeyaA62mOiy5EvkXhltGfhCcIQcq2LoE0l1CwcWPjlw==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.9"
     chokidar "^3.4.1"


Test patch:
diff --git a/tests/CustomMatchers/CustomMatchersForSorting.ts b/tests/CustomMatchers/CustomMatchersForSorting.ts
index 8f6d1699..f5a18b8b 100644
--- a/tests/CustomMatchers/CustomMatchersForSorting.ts
+++ b/tests/CustomMatchers/CustomMatchersForSorting.ts
@@ -1,7 +1,7 @@
 import type moment from 'moment';
 import { DateParser } from '../../src/Query/DateParser';
 import { DateField } from '../../src/Query/Filter/DateField';
-import type { Sorting } from '../../src/Query/Sorting';
+import type { Sorter } from '../../src/Query/Sorter';
 import type { Task } from '../../src/Task';
 
 declare global {
@@ -97,21 +97,21 @@ function testCompareByDateBothWays(dateA: string | null, dateB: string | null, e
 // Sorting Tasks
 // ---------------------------------------------------------------------
 
-export function expectTaskComparesBefore(sorting: Sorting, taskA: Task, taskB: Task) {
-    testCompareTasksBothWays(sorting, taskA, taskB, before);
+export function expectTaskComparesBefore(sorter: Sorter, taskA: Task, taskB: Task) {
+    testCompareTasksBothWays(sorter, taskA, taskB, before);
 }
 
-export function expectTaskComparesEqual(sorting: Sorting, taskA: Task, taskB: Task) {
-    testCompareTasksBothWays(sorting, taskA, taskB, equal);
+export function expectTaskComparesEqual(sorter: Sorter, taskA: Task, taskB: Task) {
+    testCompareTasksBothWays(sorter, taskA, taskB, equal);
 }
 
-export function expectTaskComparesAfter(sorting: Sorting, taskA: Task, taskB: Task) {
-    testCompareTasksBothWays(sorting, taskA, taskB, after);
+export function expectTaskComparesAfter(sorter: Sorter, taskA: Task, taskB: Task) {
+    testCompareTasksBothWays(sorter, taskA, taskB, after);
 }
 
-function testCompareTasksBothWays(sorting: Sorting, taskA: Task, taskB: Task, expected: -1 | 0 | 1) {
-    expect({ sorting, tasks: [taskA, taskB] }).toCompareTasksWithResult(expected);
+function testCompareTasksBothWays(sorter: Sorter, taskA: Task, taskB: Task, expected: -1 | 0 | 1) {
+    expect({ sorting: sorter, tasks: [taskA, taskB] }).toCompareTasksWithResult(expected);
 
     const reverseExpected = expected === equal ? equal : -expected;
-    expect({ sorting, tasks: [taskB, taskA] }).toCompareTasksWithResult(reverseExpected);
+    expect({ sorting: sorter, tasks: [taskB, taskA] }).toCompareTasksWithResult(reverseExpected);
 }
diff --git a/tests/Group.test.ts b/tests/Group.test.ts
index 37ccc76a..88cbde50 100644
--- a/tests/Group.test.ts
+++ b/tests/Group.test.ts
@@ -193,22 +193,22 @@ describe('Grouping tasks', () => {
         // Assert
         expect(groups.toString()).toMatchInlineSnapshot(`
             "
-            Group names: [folder\\_a/folder\\_b/,file\\_c]
+            Group names: [folder\\_a/folder\\_b/,[[file\\_c]]]
             #### folder\\_a/folder\\_b/
-            ##### file\\_c
+            ##### [[file\\_c]]
             - [ ] Task 3 - but path is 1st, alphabetically
 
             ---
 
-            Group names: [folder\\_b/folder\\_c/,file\\_c]
+            Group names: [folder\\_b/folder\\_c/,[[file\\_c]]]
             #### folder\\_b/folder\\_c/
-            ##### file\\_c
+            ##### [[file\\_c]]
             - [ ] Task 1 - but path is 2nd, alphabetically
 
             ---
 
-            Group names: [folder\\_b/folder\\_c/,file\\_d]
-            ##### file\\_d
+            Group names: [folder\\_b/folder\\_c/,[[file\\_d]]]
+            ##### [[file\\_d]]
             - [ ] Task 2 - but path is 2nd, alphabetically
 
             ---
@@ -300,13 +300,13 @@ describe('Group names', () => {
         {
             groupBy: 'filename',
             taskLine: '- [ ] a',
-            expectedGroupNames: ['c'],
+            expectedGroupNames: ['[[c]]'],
             path: 'a/b/c.md',
         },
         {
             groupBy: 'filename',
             taskLine: '- [ ] a',
-            expectedGroupNames: ['\\_c\\_'], // underscores in file names are escaped
+            expectedGroupNames: ['[[\\_c\\_]]'], // underscores in file names are escaped
             path: 'a/b/_c_.md',
         },
 
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index e3a06df7..266fb6e6 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -131,6 +131,9 @@ describe('Query parsing', () => {
             'sort by done',
             'sort by due reverse',
             'sort by due',
+            'sort by filename',
+            'sort by happens',
+            'sort by heading',
             'sort by path reverse',
             'sort by path',
             'sort by priority reverse',
diff --git a/tests/Query/Filter/DescriptionField.test.ts b/tests/Query/Filter/DescriptionField.test.ts
index b41ca7cd..4d0d7533 100644
--- a/tests/Query/Filter/DescriptionField.test.ts
+++ b/tests/Query/Filter/DescriptionField.test.ts
@@ -263,17 +263,25 @@ describe('sorting by description', () => {
         return new TaskBuilder().description(description).build();
     }
 
-    it('sort by path', () => {
+    it('sort by description', () => {
         // Arrange
         const sorter = new DescriptionField().createNormalSorter();
 
         // Assert
         expectTaskComparesEqual(sorter, with_description('Aaa'), with_description('Aaa'));
         expectTaskComparesBefore(sorter, with_description('AAA'), with_description('ZZZ'));
-        expectTaskComparesAfter(sorter, with_description('AAA'), with_description('aaa')); // case-sensitive - capitals come last - TODO WHY? In description searches, capitals appear to come first.
+
+        // Ignores case if strings differ
+        expectTaskComparesBefore(sorter, with_description('AAA'), with_description('bbb'));
+        expectTaskComparesBefore(sorter, with_description('aaa'), with_description('BBB'));
+        expectTaskComparesBefore(sorter, with_description('aaa'), with_description('AAA'));
+
+        // Sorts text with numbers in correctly
+        expectTaskComparesBefore(sorter, with_description('9 x'), with_description('11 x'));
+        expectTaskComparesBefore(sorter, with_description('x 9'), with_description('x 11'));
     });
 
-    it('sort by path reverse', () => {
+    it('sort by description reverse', () => {
         // Single example just to prove reverse works.
         // (There's no need to repeat all the examples above)
         const sorter = new DescriptionField().createReverseSorter();
@@ -296,21 +304,35 @@ describe('sorting by description', () => {
             );
         });
 
+        // All the strings should be treated as though they contain 'un-format'
+        it.each([
+            '*un-format*',
+            '**un-format**',
+            '_un-format_',
+            '__un-format__',
+            '[[un-format]]',
+            '[[some-other-file-name|un-format]]',
+            '[un-format]',
+            '==un-format==',
+        ])('simple description "%s" is cleaned to "un-format"', (originalDescription: string) => {
+            expect(DescriptionField.cleanDescription(originalDescription)).toStrictEqual('un-format');
+        });
+
         // Each of these pairs of strings is:
         // 1. A task description
         // 2. The result of running that description through the description-cleaning code.
         it.each([
             [
                 '[[Better be second]] most [] removed so these sort equal',
-                'Better be second] most [] removed so these sort equal',
+                'Better be second most [] removed so these sort equal',
             ],
             [
                 '[[Another|Third it should be]] alias is used from 1st link but not 2nd [last|ZZZ]',
-                'Third it should be] alias is used from 1st link but not 2nd [last|ZZZ]',
+                'Third it should be alias is used from 1st link but not 2nd [last|ZZZ]',
             ],
             [
-                '*Very italic text* - this looks completely wrong',
-                'Very italic text*Very italic text* - this looks completely wrong',
+                '*Very italic text*', // (comment to override formatting)
+                'Very italic text',
             ],
             [
                 '[@Zebra|Zebra] alias is used single []*', // (comment to override formatting)
@@ -327,13 +349,14 @@ describe('sorting by description', () => {
             ],
             [
                 '**bold** then ordinary text', // (comment to override formatting)
-                'bold**bold** then ordinary text',
+                'bold then ordinary text',
             ],
             [
                 '*italic* then ordinary text', // (comment to override formatting)
-                'italic*italic* then ordinary text',
+                'italic then ordinary text',
             ],
         ])('description "%s" is cleaned to "%s"', (originalDescription: string, cleanedDescription: string) => {
+            expect(DescriptionField.cleanDescription(originalDescription)).toStrictEqual(cleanedDescription);
             expectTaskComparesEqual(
                 sorter,
                 new TaskBuilder().description(originalDescription).build(),
@@ -342,6 +365,18 @@ describe('sorting by description', () => {
         });
     });
 
+    // All the strings are expected to be treated as unchanged.
+    // They typically have unbalanced numbers of formatting characters,
+    // or other behaviours not yet supported by Description.
+    it.each([
+        '**originalDescription* following text',
+        '__originalDescription_ following text',
+        '**hello * world** - formatting character inside formatting', // it would be nice for this to become 'hello * world'
+        '=un-format= single hyphen is not a valid formatting, so do not remove it',
+    ])('description "%s" is unchanged when cleaned"', (originalDescription: string) => {
+        expect(DescriptionField.cleanDescription(originalDescription)).toStrictEqual(originalDescription);
+    });
+
     it('sorts correctly by the link name and not the markdown', () => {
         const one = fromLine({
             line: '- [ ] *ZZZ An early task that starts with an A; actually not italic since only one asterisk',
@@ -360,13 +395,8 @@ describe('sorting by description', () => {
         });
 
         const expectedOrder = [one, two, three, four, five];
-        expect(
-            Sort.by(
-                {
-                    sorting: [new DescriptionField().createNormalSorter()],
-                },
-                [two, one, five, four, three],
-            ),
-        ).toEqual(expectedOrder);
+        expect(Sort.by([new DescriptionField().createNormalSorter()], [two, one, five, four, three])).toEqual(
+            expectedOrder,
+        );
     });
 });
diff --git a/tests/Query/Filter/Field.test.ts b/tests/Query/Filter/Field.test.ts
index 7613a4bf..e1d8e2fc 100644
--- a/tests/Query/Filter/Field.test.ts
+++ b/tests/Query/Filter/Field.test.ts
@@ -1,6 +1,6 @@
 import { Field } from '../../../src/Query/Filter/Field';
 import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
-import type { Comparator } from '../../../src/Query/Sorting';
+import type { Comparator } from '../../../src/Query/Sorter';
 import type { Task } from '../../../src/Task';
 import { expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts
index 8dc8f8c8..f077fe78 100644
--- a/tests/Query/Filter/FilenameField.test.ts
+++ b/tests/Query/Filter/FilenameField.test.ts
@@ -1,6 +1,7 @@
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { FilenameField } from '../../../src/Query/Filter/FilenameField';
 import { toBeValid, toMatchTaskWithPath } from '../../CustomMatchers/CustomMatchersForFilters';
+import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
 
 expect.extend({
     toMatchTaskWithPath,
@@ -75,3 +76,37 @@ describe('filename', () => {
         expect(filter).not.toMatchTaskWithPath('/some/path/wibble.md');
     });
 });
+
+describe('sorting by filename', () => {
+    it('supports Field sorting methods correctly', () => {
+        const field = new FilenameField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    // Helper function to create a task with a given path
+    function with_path(path: string) {
+        return new TaskBuilder().path(path).build();
+    }
+
+    it('sort by filename', () => {
+        // Arrange
+        const sorter = new FilenameField().createNormalSorter();
+
+        // Assert
+        CustomMatchersForSorting.expectTaskComparesEqual(
+            sorter,
+            with_path('some path/filename.md'), // Only sorts file name - ignores folders (which differ)
+            with_path('other path/filename.md'),
+        );
+        // Beginning with numbers
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_path('c/1.md'), with_path('c/9.md'));
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_path('c/9.md'), with_path('c/11.md'));
+    });
+
+    it('sort by filename reverse', () => {
+        // Single example just to prove reverse works.
+        // (There's no need to repeat all the examples above)
+        const sorter = new FilenameField().createReverseSorter();
+        CustomMatchersForSorting.expectTaskComparesAfter(sorter, with_path('a/b.md'), with_path('c/d.md'));
+    });
+});
diff --git a/tests/Query/Filter/HappensDateField.test.ts b/tests/Query/Filter/HappensDateField.test.ts
index 9376a690..67073577 100644
--- a/tests/Query/Filter/HappensDateField.test.ts
+++ b/tests/Query/Filter/HappensDateField.test.ts
@@ -6,6 +6,7 @@ import { HappensDateField } from '../../../src/Query/Filter/HappensDateField';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { toHaveExplanation } from '../../CustomMatchers/CustomMatchersForFilters';
+import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
 
 window.moment = moment;
 
@@ -119,3 +120,21 @@ describe('explain happens date queries', () => {
         );
     });
 });
+
+describe('sorting by happens', () => {
+    it('supports Field sorting methods correctly', () => {
+        const field = new HappensDateField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    const date1 = new TaskBuilder().startDate('2021-01-12').build();
+    const date2 = new TaskBuilder().scheduledDate('2022-12-23').build();
+
+    it('sort by happens', () => {
+        CustomMatchersForSorting.expectTaskComparesBefore(new HappensDateField().createNormalSorter(), date1, date2);
+    });
+
+    it('sort by happens reverse', () => {
+        CustomMatchersForSorting.expectTaskComparesAfter(new HappensDateField().createReverseSorter(), date1, date2);
+    });
+});
diff --git a/tests/Query/Filter/HeadingField.test.ts b/tests/Query/Filter/HeadingField.test.ts
index a9f3d782..7866f204 100644
--- a/tests/Query/Filter/HeadingField.test.ts
+++ b/tests/Query/Filter/HeadingField.test.ts
@@ -3,6 +3,7 @@ import type { FilterOrErrorMessage } from '../../../src/Query/Filter/Filter';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { testFilter } from '../../TestingTools/FilterTestHelpers';
 import { toBeValid, toMatchTaskWithHeading } from '../../CustomMatchers/CustomMatchersForFilters';
+import * as CustomMatchersForSorting from '../../CustomMatchers/CustomMatchersForSorting';
 
 function testTaskFilterForHeading(filter: FilterOrErrorMessage, precedingHeader: string | null, expected: boolean) {
     const builder = new TaskBuilder();
@@ -64,3 +65,34 @@ describe('heading', () => {
         expect(filter).not.toMatchTaskWithHeading('SoMe InteResting HeaDing');
     });
 });
+
+describe('sorting by heading', () => {
+    it('supports Field sorting methods correctly', () => {
+        const field = new HeadingField();
+        expect(field.supportsSorting()).toEqual(true);
+    });
+
+    // Helper function to create a task with a given path
+    function with_heading(heading: string) {
+        return new TaskBuilder().precedingHeader(heading).build();
+    }
+
+    it('sort by heading', () => {
+        // Arrange
+        const sorter = new HeadingField().createNormalSorter();
+
+        // Assert
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading(''), with_heading('Non-empty heading')); // Empty heading comes first
+        // Beginning with numbers
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('1 Stuff'), with_heading('2 Stuff'));
+        CustomMatchersForSorting.expectTaskComparesBefore(sorter, with_heading('9 Stuff'), with_heading('11 Stuff'));
+    });
+
+    it('sort by heading reverse', () => {
+        // Single example just to prove reverse works.
+        // (There's no need to repeat all the examples above)
+        const sorter = new HeadingField().createReverseSorter();
+        CustomMatchersForSorting.expectTaskComparesAfter(sorter, with_heading('Heading 1'), with_heading('Heading 2'));
+    });
+});
diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts
index ecc6957b..c3fa9f12 100644
--- a/tests/Query/Filter/PathField.test.ts
+++ b/tests/Query/Filter/PathField.test.ts
@@ -134,11 +134,15 @@ describe('sorting by path', () => {
         // Assert
         expectTaskComparesEqual(sorter, with_path('a/b.md'), with_path('a/b.md'));
         expectTaskComparesBefore(sorter, with_path('a/b.md'), with_path('c/d.md'));
-        expectTaskComparesBefore(sorter, with_path('c/D.md'), with_path('c/d.md')); // case-sensitive - capitals come first
+
+        // Ignores case if strings differ
+        expectTaskComparesBefore(sorter, with_path('aaaa/bbbb.md'), with_path('CCCC/DDDD.md'));
+        expectTaskComparesBefore(sorter, with_path('AAAA/BBBB.md'), with_path('cccc/dddd.md'));
+        expectTaskComparesBefore(sorter, with_path('aaaa/bbbb.md'), with_path('AAAA/BBBB.md'));
 
         // Beginning with numbers
         expectTaskComparesBefore(sorter, with_path('c/1.md'), with_path('c/9.md'));
-        expectTaskComparesBefore(sorter, with_path('c/11.md'), with_path('c/9.md')); // TODO want 11 to compare after 9
+        expectTaskComparesBefore(sorter, with_path('c/9.md'), with_path('c/11.md'));
     });
 
     it('sort by path reverse', () => {
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index f689980c..c4f15f2c 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -303,6 +303,11 @@ describe('Sort by tags', () => {
         const tags_a_c = new TaskBuilder().tags(['#b', '#c']).build();
         const tags_a_d = new TaskBuilder().tags(['#a', '#d']).build();
 
+        // Helper function to create a task with a given path
+        function with_tags(tags: string[]) {
+            return new TaskBuilder().tags(tags).build();
+        }
+
         it('should create a default comparator, sorting by first tag', () => {
             const comparator = tagsField.comparator();
             expect(comparator(tags_a_d, tags_a_c)).toBeLessThan(0);
@@ -327,6 +332,19 @@ describe('Sort by tags', () => {
             expectTaskComparesAfter(sorter!, tags_a_b, tags_a_c);
         });
 
+        it('should sort by tags case-insensitively', () => {
+            const sorter = tagsField.createNormalSorter();
+            expectTaskComparesBefore(sorter, with_tags(['#AAAA']), with_tags(['#bbbb']));
+            expectTaskComparesBefore(sorter, with_tags(['#aaaa']), with_tags(['#BBBB']));
+            expectTaskComparesBefore(sorter, with_tags(['#aaaa']), with_tags(['#AAAA']));
+        });
+
+        it('should sort by tags with numbers correctly', () => {
+            const sorter = tagsField.createNormalSorter();
+            expectTaskComparesBefore(sorter, with_tags(['#a-0']), with_tags(['#a-9']));
+            expectTaskComparesBefore(sorter, with_tags(['#a-9']), with_tags(['#a-11']));
+        });
+
         it('should fail to parse a invalid line', () => {
             const line = 'sort by jsdajhasdfa';
             expect(tagsField.canCreateSorterForLine(line)).toBe(false);
@@ -352,12 +370,7 @@ describe('Sort by tags', () => {
 
         // Act / Assert
         expect(
-            Sort.by(
-                {
-                    sorting: [new TagsField().parseSortLine('sort by tag 1')!],
-                },
-                [t1, t3, t5, t7, t6, t4, t2, t8, t9, t10],
-            ),
+            Sort.by([new TagsField().parseSortLine('sort by tag 1')!], [t1, t3, t5, t7, t6, t4, t2, t8, t9, t10]),
         ).toEqual(expectedOrder);
     });
 
@@ -378,9 +391,7 @@ describe('Sort by tags', () => {
         // Act / Assert
         expect(
             Sort.by(
-                {
-                    sorting: [new TagsField().parseSortLine('sort by tag reverse 1')!],
-                },
+                [new TagsField().parseSortLine('sort by tag reverse 1')!],
                 [t1, t3, t5, t7, t6, t4, t2, t8, t9, t10],
             ),
         ).toEqual(expectedOrder);
@@ -393,14 +404,7 @@ describe('Sort by tags', () => {
         const t4 = fromLine({ line: '- [ ] a #iii #ddd' });
         const t5 = fromLine({ line: '- [ ] a #hhh #eee' });
         const expectedOrder = [t1, t2, t3, t4, t5];
-        expect(
-            Sort.by(
-                {
-                    sorting: [new TagsField().parseSortLine('sort by tag 2')!],
-                },
-                [t4, t3, t2, t1, t5],
-            ),
-        ).toEqual(expectedOrder);
+        expect(Sort.by([new TagsField().parseSortLine('sort by tag 2')!], [t4, t3, t2, t1, t5])).toEqual(expectedOrder);
     });
 
     it('should sort correctly reversed by second tag with no global filter', () => {
@@ -410,14 +414,9 @@ describe('Sort by tags', () => {
         const t4 = fromLine({ line: '- [ ] a #iii #ddd' });
         const t5 = fromLine({ line: '- [ ] a #hhh #eee' });
         const expectedOrder = [t5, t4, t3, t2, t1];
-        expect(
-            Sort.by(
-                {
-                    sorting: [new TagsField().parseSortLine('sort by tag reverse 2')!],
-                },
-                [t4, t3, t2, t1, t5],
-            ),
-        ).toEqual(expectedOrder);
+        expect(Sort.by([new TagsField().parseSortLine('sort by tag reverse 2')!], [t4, t3, t2, t1, t5])).toEqual(
+            expectedOrder,
+        );
     });
 
     it('should sort correctly by tag defaulting to first with global filter', () => {
@@ -443,9 +442,7 @@ describe('Sort by tags', () => {
         // Act
         expect(
             Sort.by(
-                {
-                    sorting: [new TagsField().parseSortLine('sort by tag 1')!],
-                },
+                [new TagsField().parseSortLine('sort by tag 1')!],
                 [t1, t12, t3, t13, t5, t7, t6, t4, t2, t8, t9, t10, t11],
             ),
         ).toEqual(expectedOrder);
@@ -477,9 +474,7 @@ describe('Sort by tags', () => {
         // Act
         expect(
             Sort.by(
-                {
-                    sorting: [new TagsField().parseSortLine('sort by tag reverse 1')!],
-                },
+                [new TagsField().parseSortLine('sort by tag reverse 1')!],
                 [t1, t12, t3, t13, t5, t7, t6, t4, t2, t8, t9, t10, t11],
             ),
         ).toEqual(expectedOrder);
@@ -503,12 +498,7 @@ describe('Sort by tags', () => {
         const expectedOrder = [t1, t2, t3, t4, t5, t6, t7, t8];
 
         // Act
-        const result = Sort.by(
-            {
-                sorting: [new TagsField().parseSortLine('sort by tag 2')!],
-            },
-            [t4, t7, t5, t2, t3, t1, t8, t6],
-        );
+        const result = Sort.by([new TagsField().parseSortLine('sort by tag 2')!], [t4, t7, t5, t2, t3, t1, t8, t6]);
 
         // Assert
         expect(result).toEqual(expectedOrder);
@@ -533,9 +523,7 @@ describe('Sort by tags', () => {
 
         // Act
         const result = Sort.by(
-            {
-                sorting: [new TagsField().parseSortLine('sort by tag reverse 2')!],
-            },
+            [new TagsField().parseSortLine('sort by tag reverse 2')!],
             [t4, t7, t5, t2, t3, t1, t8, t6],
         );
 
@@ -572,13 +560,11 @@ describe('Sort by tags', () => {
         ];
         expect(
             Sort.by(
-                {
-                    sorting: [
-                        new TagsField().parseSortLine('sort by tag 2')!, // tag 2 - ascending
-                        new TagsField().parseSortLine('sort by tag 1')!, // tag 1 - ascending
-                        new DescriptionField().createNormalSorter(), // then description - ascending
-                    ],
-                },
+                [
+                    new TagsField().parseSortLine('sort by tag 2')!, // tag 2 - ascending
+                    new TagsField().parseSortLine('sort by tag 1')!, // tag 1 - ascending
+                    new DescriptionField().createNormalSorter(), // then description - ascending
+                ],
                 input,
             ),
         ).toEqual(correctExpectedOrder);
diff --git a/tests/Sort.test.ts b/tests/Sort.test.ts
index e2e4a3a5..a0e6fdb1 100644
--- a/tests/Sort.test.ts
+++ b/tests/Sort.test.ts
@@ -5,9 +5,9 @@ import moment from 'moment';
 
 window.moment = moment;
 
-import type { Comparator } from '../src/Query/Sorting';
+import type { Comparator } from '../src/Query/Sorter';
 import { Sort } from '../src/Query/Sort';
-import { Sorting } from '../src/Query/Sorting';
+import { Sorter } from '../src/Query/Sorter';
 import type { Task } from '../src/Task';
 import { StatusField } from '../src/Query/Filter/StatusField';
 import { DoneDateField } from '../src/Query/Filter/DoneDateField';
@@ -38,7 +38,7 @@ describe('Sort', () => {
 
         // Normal way round
         {
-            const sortByDescriptionLength = new Sorting(false, 'junk', comparator);
+            const sortByDescriptionLength = new Sorter('junk', comparator, false);
             expect(sortByDescriptionLength.comparator(short, long)).toEqual(1);
             expect(sortByDescriptionLength.comparator(short, short)).toEqual(0);
             expect(sortByDescriptionLength.comparator(long, short)).toEqual(-1);
@@ -46,7 +46,7 @@ describe('Sort', () => {
 
         // Reversed
         {
-            const sortByDescriptionLength = new Sorting(true, 'junk', comparator);
+            const sortByDescriptionLength = new Sorter('junk', comparator, true);
             expect(sortByDescriptionLength.comparator(short, long)).toEqual(-1);
             expect(sortByDescriptionLength.comparator(short, short)).toEqual(-0);
             expect(sortByDescriptionLength.comparator(long, short)).toEqual(1);
@@ -61,7 +61,7 @@ describe('Sort', () => {
         const five = fromLine({ line: '- [x] b 📅 1970-01-02', path: '3' });
         const six = fromLine({ line: '- [x] d 📅 1970-01-03', path: '2' });
         const expectedOrder = [one, two, three, four, five, six];
-        expect(Sort.by({ sorting: [] }, [six, five, one, four, two, three])).toEqual(expectedOrder);
+        expect(Sort.by([], [six, five, one, four, two, three])).toEqual(expectedOrder);
     });
 
     // TODO Add some tests based on easy-to-reason-about custom comparators, then delete the remaining old-style tests
@@ -85,13 +85,11 @@ describe('Sort', () => {
         ];
         expect(
             Sort.by(
-                {
-                    sorting: [
-                        new DueDateField().createNormalSorter(),
-                        new PathField().createNormalSorter(),
-                        new StatusField().createNormalSorter(),
-                    ],
-                },
+                [
+                    new DueDateField().createNormalSorter(),
+                    new PathField().createNormalSorter(),
+                    new StatusField().createNormalSorter(),
+                ],
                 [one, four, two, three],
             ),
         ).toEqual(expectedOrder);
@@ -118,9 +116,7 @@ describe('Sort', () => {
         const expectedOrder = [one, two, three, four];
         expect(
             Sort.by(
-                {
-                    sorting: [new DescriptionField().createNormalSorter(), new DoneDateField().createNormalSorter()],
-                },
+                [new DescriptionField().createNormalSorter(), new DoneDateField().createNormalSorter()],
                 [three, one, two, four],
             ),
         ).toEqual(expectedOrder);
@@ -147,9 +143,7 @@ describe('Sort', () => {
         const expectedOrder = [one, two, three, four];
         expect(
             Sort.by(
-                {
-                    sorting: [new DescriptionField().createReverseSorter(), new DoneDateField().createNormalSorter()],
-                },
+                [new DescriptionField().createReverseSorter(), new DoneDateField().createNormalSorter()],
                 [two, four, three, one],
             ),
         ).toEqual(expectedOrder);
@@ -167,13 +161,11 @@ describe('Sort', () => {
 
         expect(
             Sort.by(
-                {
-                    sorting: [
-                        new StatusField().createReverseSorter(),
-                        new DueDateField().createReverseSorter(),
-                        new PathField().createNormalSorter(),
-                    ],
-                },
+                [
+                    new StatusField().createReverseSorter(),
+                    new DueDateField().createReverseSorter(),
+                    new PathField().createNormalSorter(),
+                ],
                 [six, five, one, four, three, two],
             ),
         ).toEqual(expectedOrder);
diff --git a/tests/TaskLineRenderer.test.ts b/tests/TaskLineRenderer.test.ts
new file mode 100644
index 00000000..ac90c738
--- /dev/null
+++ b/tests/TaskLineRenderer.test.ts
@@ -0,0 +1,186 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { renderTaskLine } from '../src/TaskLineRenderer';
+import { resetSettings, updateSettings } from '../src/Config/Settings';
+import { LayoutOptions } from '../src/TaskLayout';
+import type { Task } from '../src/Task';
+import { fromLine } from './TestHelpers';
+
+jest.mock('obsidian');
+window.moment = moment;
+
+/**
+ * Creates a dummy 'parent element' to host a task render, renders a task inside it,
+ * and returns it for inspection.
+ */
+async function createMockParentAndRender(task: Task, layoutOptions?: LayoutOptions) {
+    const parentElement = document.createElement('div');
+    const mockTextRenderer = async (text: string, element: HTMLSpanElement, _path: string) => {
+        element.innerText = text;
+    };
+    await renderTaskLine(
+        task,
+        {
+            parentUlElement: parentElement,
+            listIndex: 0,
+            layoutOptions: layoutOptions,
+        },
+        mockTextRenderer,
+    );
+    return parentElement;
+}
+
+function getTextSpan(parentElement: HTMLElement) {
+    const li = parentElement.children[0];
+    const textSpan = li.children[1] as HTMLSpanElement;
+    return textSpan;
+}
+
+function getDescriptionText(parentElement: HTMLElement) {
+    const textSpan = getTextSpan(parentElement);
+    return textSpan.innerText;
+}
+
+describe('task line rendering', () => {
+    it('creates the correct span structure for a basic task', async () => {
+        const taskLine = '- [ ] This is a simple task';
+        const task = fromLine({
+            line: taskLine,
+        });
+        const parentRender = await createMockParentAndRender(task);
+
+        // Check what we have one child, which is the rendered child
+        expect(parentRender.children.length).toEqual(1);
+        const li = parentRender.children[0];
+
+        // Check that it's an element of type LI
+        expect(li.nodeName).toEqual('LI');
+
+        // Check that it has two children: a checkbox and a text span
+        expect(li.children.length).toEqual(2);
+
+        const checkbox = li.children[0];
+        expect(checkbox.nodeName).toEqual('INPUT');
+        expect(checkbox.classList.contains('task-list-item-checkbox')).toBeTruthy();
+
+        const textSpan = li.children[1];
+        expect(textSpan.nodeName).toEqual('SPAN');
+        expect(textSpan.classList.contains('tasks-list-text')).toBeTruthy();
+        expect((textSpan as HTMLSpanElement).innerText).toEqual('This is a simple task');
+    });
+
+    it('hides the global filter if and only if required', async () => {
+        const getDescriptionTest = async () => {
+            const taskLine = '- [ ] This is a simple task with a #global filter';
+            const task = fromLine({
+                line: taskLine,
+            });
+            const parentRender = await createMockParentAndRender(task);
+            return getDescriptionText(parentRender);
+        };
+
+        const descriptionWithFilter = await getDescriptionTest();
+        expect(descriptionWithFilter).toEqual('This is a simple task with a #global filter');
+
+        updateSettings({ globalFilter: '#global', removeGlobalFilter: true });
+        const descriptionWithoutFilter = await getDescriptionTest();
+        expect(descriptionWithoutFilter).toEqual('This is a simple task with a  filter');
+        resetSettings();
+    });
+
+    const testLayoutOptions = async (
+        taskLine: string,
+        layoutOptions: Partial<LayoutOptions>,
+        expectedRender: string,
+    ) => {
+        const task = fromLine({
+            line: taskLine,
+        });
+        const fullLayoutOptions = { ...new LayoutOptions(), ...layoutOptions };
+        const parentRender = await createMockParentAndRender(task, fullLayoutOptions);
+        const renderedDescription = getDescriptionText(parentRender);
+        expect(renderedDescription).toEqual(expectedRender);
+    };
+
+    it('renders correctly with the default layout options', async () => {
+        await testLayoutOptions(
+            '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            {},
+            'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02',
+        );
+    });
+
+    it('renders without priority', async () => {
+        await testLayoutOptions(
+            '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            { hidePriority: true },
+            'Full task 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02',
+        );
+    });
+
+    it('renders without start date', async () => {
+        await testLayoutOptions(
+            '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            { hideStartDate: true },
+            'Full task ⏫ 🔁 every day ⏳ 2022-07-03 📅 2022-07-02',
+        );
+    });
+
+    it('renders without scheduled date', async () => {
+        await testLayoutOptions(
+            '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            { hideScheduledDate: true },
+            'Full task ⏫ 🔁 every day 🛫 2022-07-04 📅 2022-07-02',
+        );
+    });
+
+    it('renders without due date', async () => {
+        await testLayoutOptions(
+            '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            { hideDueDate: true },
+            'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03',
+        );
+    });
+
+    it('renders without recurrence rule', async () => {
+        await testLayoutOptions(
+            '- [ ] Full task ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            { hideRecurrenceRule: true },
+            'Full task ⏫ 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02',
+        );
+    });
+
+    it('renders a done task correctly with the default layout', async () => {
+        await testLayoutOptions(
+            '- [x] Full task ✅ 2022-07-05 ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            {},
+            'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02 ✅ 2022-07-05',
+        );
+    });
+
+    it('renders a done task without the done date', async () => {
+        await testLayoutOptions(
+            '- [x] Full task ✅ 2022-07-05 ⏫ 📅 2022-07-02 ⏳ 2022-07-03 🛫 2022-07-04 🔁 every day',
+            { hideDoneDate: true },
+            'Full task ⏫ 🔁 every day 🛫 2022-07-04 ⏳ 2022-07-03 📅 2022-07-02',
+        );
+    });
+
+    it('writes a placeholder message if a date is invalid', async () => {
+        await testLayoutOptions(
+            '- [ ] Task with invalid due date 📅 2023-13-02',
+            {},
+            'Task with invalid due date 📅 Invalid date',
+        );
+    });
+
+    it('standardise the recurrence rule, even if the rule is invalid', async () => {
+        await testLayoutOptions(
+            '- [ ] Task with invalid recurrence rule 🔁 every month on the 32nd',
+            {},
+            'Task with invalid recurrence rule 🔁 every month on the 32th',
+        );
+    });
+});