mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-18 21:20:57 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203f02361e |
No files matched your search
@@ -232,31 +232,6 @@ jobs:
|
|||||||
mkdir -p ${{ runner.temp }}/profile-check-results
|
mkdir -p ${{ runner.temp }}/profile-check-results
|
||||||
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
||||||
|
|
||||||
# Builds a readable diff of the custom G-code the PR changes, which GitHub shows as
|
|
||||||
# one long line per setting, for check_profiles_comment.yml to post.
|
|
||||||
- name: Diff custom G-code
|
|
||||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags --depth=2 origin "$(git rev-parse HEAD)"
|
|
||||||
results=${{ runner.temp }}/profile-check-results
|
|
||||||
diff=${{ runner.temp }}/gcode_diff.md
|
|
||||||
# HEAD is the PR's merge commit, so HEAD^1 is the base it merges onto.
|
|
||||||
python3 ./scripts/orca_profile_tool.py gcode-diff --base HEAD^1 --head HEAD > "$diff"
|
|
||||||
if [ -s "$diff" ]; then
|
|
||||||
{
|
|
||||||
echo "<!-- profile-gcode-diff-comment -->"
|
|
||||||
cat "$diff"
|
|
||||||
echo ""
|
|
||||||
# The comment workflow finds this line as the only one starting with <sub>.
|
|
||||||
echo "<sub>For commit ${PR_HEAD_SHA} of this PR.</sub>"
|
|
||||||
} > "$results/gcode_diff.md"
|
|
||||||
fi
|
|
||||||
# Tells the comment workflow that a missing gcode_diff.md means no G-code changed.
|
|
||||||
touch "$results/gcode_diff_done.txt"
|
|
||||||
|
|
||||||
- name: Prepare comment artifact
|
- name: Prepare comment artifact
|
||||||
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.legacy_compat.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GH_REPO: ${{ github.repository }}
|
GH_REPO: ${{ github.repository }}
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f pr_number.txt ]; then
|
if [ ! -f pr_number.txt ]; then
|
||||||
echo "No pr_number.txt in artifact, skipping."
|
echo "No pr_number.txt in artifact, skipping."
|
||||||
@@ -50,72 +49,18 @@ jobs:
|
|||||||
echo "Invalid PR number: $PR_NUMBER"
|
echo "Invalid PR number: $PR_NUMBER"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# PR code wrote the artifact, so only a run for the named PR's current head may post.
|
|
||||||
if [ -z "$HEAD_SHA" ]; then
|
|
||||||
echo "The triggering run names no head commit."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! PR_HEAD=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq .head.sha); then
|
|
||||||
echo "Cannot look up PR #${PR_NUMBER}."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$HEAD_SHA" != "$PR_HEAD" ]; then
|
|
||||||
echo "${HEAD_SHA} is not the head of PR #${PR_NUMBER}; skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Lists every bot comment on the PR, one JSON object per line.
|
|
||||||
COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
||||||
--jq '.[] | select(.user.login == "github-actions[bot]") | {id, body} | @json')
|
|
||||||
|
|
||||||
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
|
# Delete prior comments matching the marker (from check_profiles.yml) or the legacy heading.
|
||||||
OLD_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
OLD_IDS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
||||||
|
--jq '.[] | select(.user.login == "github-actions[bot]") | select((.body | startswith("<!-- profile-validation-comment -->")) or (.body | startswith("## :x: Profile Validation Errors"))) | .id')
|
||||||
for comment_id in $OLD_IDS; do
|
for comment_id in $OLD_IDS; do
|
||||||
echo "Deleting outdated profile-validation comment ${comment_id}"
|
echo "Deleting outdated profile-validation comment ${comment_id}"
|
||||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
||||||
done
|
done
|
||||||
|
|
||||||
# Post a new comment only when validation failed (pr_comment.md present).
|
# Post a new comment only when validation failed (pr_comment.md present).
|
||||||
# A failed post fails the job only after the G-code diff is handled.
|
|
||||||
failed=0
|
|
||||||
if [ -f pr_comment.md ]; then
|
if [ -f pr_comment.md ]; then
|
||||||
gh pr comment "$PR_NUMBER" --body-file pr_comment.md || failed=1
|
gh pr comment "$PR_NUMBER" --body-file pr_comment.md
|
||||||
else
|
else
|
||||||
echo "Validation succeeded; cleaned up prior comments without posting."
|
echo "Validation succeeded; cleaned up prior comments without posting."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Makes the G-code diff comment match gcode_diff.md, or deletes it if there is none.
|
|
||||||
update_gcode_diff_comment() {
|
|
||||||
if [ ! -f gcode_diff_done.txt ]; then
|
|
||||||
echo "The G-code diff step did not finish; leaving any G-code diff comment as it is."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
GCODE_IDS=$(printf '%s\n' "$COMMENTS" | jq -r 'select(.body | startswith("<!-- profile-gcode-diff-comment -->")) | .id')
|
|
||||||
if [ -f gcode_diff.md ] && [ "$(wc -c < gcode_diff.md)" -gt 65536 ]; then
|
|
||||||
echo "gcode_diff.md is over GitHub's comment limit; not posting it."
|
|
||||||
rm gcode_diff.md
|
|
||||||
fi
|
|
||||||
if [ -f gcode_diff.md ] && [ "$(echo "$GCODE_IDS" | wc -w)" -eq 1 ]; then
|
|
||||||
OLD_BODY=$(printf '%s\n' "$COMMENTS" | jq -r --argjson id "$GCODE_IDS" 'select(.id == $id) | .body' | tr -d '\r')
|
|
||||||
NEW_BODY=$(tr -d '\r' < gcode_diff.md)
|
|
||||||
if [ "$OLD_BODY" = "$NEW_BODY" ]; then
|
|
||||||
echo "G-code diff unchanged; keeping comment ${GCODE_IDS}."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
# Edits in place when only the <sub> line differs, since edits notify nobody.
|
|
||||||
if [ "$(grep -v '^<sub>' <<< "$OLD_BODY")" = "$(grep -v '^<sub>' <<< "$NEW_BODY")" ]; then
|
|
||||||
echo "G-code diff unchanged at a new commit; updating comment ${GCODE_IDS}."
|
|
||||||
gh api -X PATCH "repos/${GH_REPO}/issues/comments/${GCODE_IDS}" -F body=@gcode_diff.md > /dev/null
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
for comment_id in $GCODE_IDS; do
|
|
||||||
echo "Deleting outdated G-code diff comment ${comment_id}"
|
|
||||||
gh api -X DELETE "repos/${GH_REPO}/issues/comments/${comment_id}" || true
|
|
||||||
done
|
|
||||||
if [ -f gcode_diff.md ]; then
|
|
||||||
gh pr comment "$PR_NUMBER" --body-file gcode_diff.md
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
update_gcode_diff_comment
|
|
||||||
exit "$failed"
|
|
||||||
@@ -53,13 +53,6 @@ Space counts as typing. The speed dial's default is the one bare Space, and
|
|||||||
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
|
`MainFrame` leaves it to a focused control that uses Space itself (text fields, buttons,
|
||||||
combo boxes), so it opens the dial from the canvases and the tab strip only.
|
combo boxes), so it opens the dial from the canvases and the tab strip only.
|
||||||
|
|
||||||
`MainFrame::handle_global_shortcut` skips a chord that `KeyChord::edits_text()` reports
|
|
||||||
while a text field has the focus (a wx text entry, spin control, web view or ImGui
|
|
||||||
InputText), and skips every chord while an ImGui modal is open, as a wx modal dialog
|
|
||||||
would. `ImGuiWrapper::owns_key()` reads the active InputText and the popup stack
|
|
||||||
directly, since `WantCaptureKeyboard` lags a frame, and is called only when the focus is on
|
|
||||||
a visible canvas from `OpenGLManager::create_wxglcanvas()`.
|
|
||||||
|
|
||||||
## Which event a chord matches
|
## Which event a chord matches
|
||||||
|
|
||||||
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
|
Letters, digits and special keys match on `wxEVT_KEY_DOWN`. Its key codes do not depend
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ commands:
|
|||||||
trim delete profile files no <vendor>.json list references
|
trim delete profile files no <vendor>.json list references
|
||||||
update-index regenerate the *_list sections of <vendor>.json
|
update-index regenerate the *_list sections of <vendor>.json
|
||||||
legacy-compat write the printers user presets can be migrated back to
|
legacy-compat write the printers user presets can be migrated back to
|
||||||
gcode-diff print a readable markdown diff of changed custom G-code
|
|
||||||
|
|
||||||
options shared by several commands:
|
options shared by several commands:
|
||||||
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
|
--vendor VENDOR act on one vendor bundle only; repeatable, empty means all
|
||||||
@@ -95,14 +94,11 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import difflib
|
|
||||||
import functools
|
import functools
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
@@ -196,7 +192,6 @@ VECTOR_KEYS = {
|
|||||||
|
|
||||||
PRINT_CONFIG_CPP = os.path.normpath(
|
PRINT_CONFIG_CPP = os.path.normpath(
|
||||||
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
|
os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp"))
|
||||||
TAB_CPP = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "src", "slic3r", "GUI", "Tab.cpp"))
|
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=None)
|
@functools.lru_cache(maxsize=None)
|
||||||
@@ -318,11 +313,11 @@ REMEDY_HINTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_error(msg, file=None):
|
def print_error(msg):
|
||||||
print(f"\033[91m[ERROR]\033[0m {msg}", file=file) # Red
|
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
|
||||||
|
|
||||||
def print_warning(msg, file=None):
|
def print_warning(msg):
|
||||||
print(f"\033[93m[WARNING]\033[0m {msg}", file=file) # Yellow
|
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
|
||||||
|
|
||||||
def print_info(msg):
|
def print_info(msg):
|
||||||
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
|
||||||
@@ -3214,223 +3209,6 @@ def run_legacy_compat(base_dir, profiles_dir, output, check):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# gcode-diff
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# UTF-8 bytes, kept well under GitHub's 65,536-character comment limit.
|
|
||||||
GCODE_DIFF_BUDGET = 60000
|
|
||||||
# The file names a section's summary shows before "and N more".
|
|
||||||
GCODE_DIFF_SUMMARY_FILES = 3
|
|
||||||
|
|
||||||
GCODE_DIFF_HEADER = (
|
|
||||||
"## Custom G-code changes\n"
|
|
||||||
"\n"
|
|
||||||
"GitHub shows custom G-code in a profile as one long line, so below is a line by line "
|
|
||||||
"diff of each changed G-code or other text setting.\n")
|
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=None)
|
|
||||||
def _text_option_keys():
|
|
||||||
"""The string options PrintConfig.cpp defines as G-code or other multi-line text
|
|
||||||
(every *_gcode option, and every one it marks multiline, such as notes), plus the
|
|
||||||
ones Tab.cpp edits as code, such as filename_format."""
|
|
||||||
with open(PRINT_CONFIG_CPP, encoding="utf-8") as f:
|
|
||||||
source = f.read()
|
|
||||||
keys = set()
|
|
||||||
for body in re.split(r"\bdef\s*=\s*this->add(?:_nullable)?\(", source)[1:]:
|
|
||||||
match = re.match(r'"(\w+)",\s*coStrings?\b', body)
|
|
||||||
if match and (match.group(1).endswith("_gcode")
|
|
||||||
or re.search(r"def->multiline\s*=\s*true", body)):
|
|
||||||
keys.add(match.group(1))
|
|
||||||
if "machine_start_gcode" not in keys:
|
|
||||||
raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines machine_start_gcode")
|
|
||||||
# Tab.cpp only adds keys, so a missing file is not an error.
|
|
||||||
try:
|
|
||||||
with open(TAB_CPP, encoding="utf-8") as f:
|
|
||||||
gui = f.read()
|
|
||||||
except OSError:
|
|
||||||
gui = ""
|
|
||||||
keys.update(re.findall(r'get_option\("(\w+)"\);(?:(?!get_option\().)*?\bis_code\s*=\s*true',
|
|
||||||
gui, re.DOTALL))
|
|
||||||
return frozenset(keys)
|
|
||||||
|
|
||||||
|
|
||||||
def _git(cwd, *args, stdin=None):
|
|
||||||
return subprocess.run(["git", "-C", cwd, *args], input=stdin, check=True,
|
|
||||||
capture_output=True).stdout
|
|
||||||
|
|
||||||
|
|
||||||
def _blobs_at(profiles_dir, specs):
|
|
||||||
"""{(ref, path): bytes, or None if not a file there} for (ref, path relative to
|
|
||||||
profiles_dir) pairs, all read by one git cat-file process."""
|
|
||||||
prefix = _git(profiles_dir, "rev-parse", "--show-prefix").decode("utf-8").strip()
|
|
||||||
out = _git(profiles_dir, "cat-file", "--batch",
|
|
||||||
stdin="".join(f"{ref}:{prefix}{rel}\n" for ref, rel in specs).encode("utf-8"))
|
|
||||||
blobs, pos = {}, 0
|
|
||||||
for spec in specs:
|
|
||||||
end = out.index(b"\n", pos)
|
|
||||||
header = out[pos:end]
|
|
||||||
pos = end + 1
|
|
||||||
# "<name> missing" and "<name> ambiguous" have no content; "<oid> <type> <size>" does.
|
|
||||||
if header.endswith((b" missing", b" ambiguous")):
|
|
||||||
blobs[spec] = None
|
|
||||||
continue
|
|
||||||
_oid, kind, size = header.split()
|
|
||||||
blobs[spec] = out[pos:pos + int(size)] if kind == b"blob" else None
|
|
||||||
pos += int(size) + 1
|
|
||||||
return blobs
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_profile(raw, rel, where):
|
|
||||||
"""The JSON in raw, or None with a warning when it is absent or not JSON."""
|
|
||||||
if raw is None:
|
|
||||||
print_warning(f"{rel}: skipped, not found at {where}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(raw.decode("utf-8-sig"))
|
|
||||||
except ValueError as e:
|
|
||||||
print_warning(f"{rel}: skipped, cannot read it at {where}: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _changed_profiles(profiles_dir, base, head):
|
|
||||||
"""(old path, new path) of each JSON file under profiles_dir that differs between
|
|
||||||
base and head (the working tree, untracked files included, when None); the absent
|
|
||||||
side of an added or deleted file is None, and so is a side that is not JSON. Renames
|
|
||||||
are paired."""
|
|
||||||
out = _git(profiles_dir, "diff", "--name-status", "-z", "-M", "--relative", base,
|
|
||||||
*([head] if head else []), "--", ".")
|
|
||||||
fields = out.decode("utf-8").split("\0")
|
|
||||||
pairs, i = [], 0
|
|
||||||
while i + 1 < len(fields):
|
|
||||||
status = fields[i]
|
|
||||||
old = new = fields[i + 1]
|
|
||||||
if status[0] in "RC":
|
|
||||||
new = fields[i + 2]
|
|
||||||
i += 3
|
|
||||||
else:
|
|
||||||
i += 2
|
|
||||||
old = None if status[0] == "A" or not old.endswith(".json") else old
|
|
||||||
new = None if status[0] == "D" or not new.endswith(".json") else new
|
|
||||||
if old or new:
|
|
||||||
pairs.append((old, new))
|
|
||||||
if head is None:
|
|
||||||
out = _git(profiles_dir, "ls-files", "-z", "--others", "--exclude-standard", "--", ".")
|
|
||||||
pairs += [(None, rel) for rel in out.decode("utf-8").split("\0")
|
|
||||||
if rel.endswith(".json") and rel.split("/")[0] != USER_DIR]
|
|
||||||
return pairs
|
|
||||||
|
|
||||||
|
|
||||||
def _text_lines(value):
|
|
||||||
return [line.rstrip() for line in value.split("\n")] if value else []
|
|
||||||
|
|
||||||
|
|
||||||
def _text_changes(old, new):
|
|
||||||
"""(setting, unified diff) for every G-code or other text value that differs
|
|
||||||
between two profiles; a vector option with several values gives one per value."""
|
|
||||||
changes = []
|
|
||||||
for key in list(new) + [k for k in old if k not in new]:
|
|
||||||
a, b = _as_list(old.get(key, [])), _as_list(new.get(key, []))
|
|
||||||
if a == b or not all(isinstance(v, str) for v in a + b):
|
|
||||||
continue
|
|
||||||
if key not in _text_option_keys() and not any("\n" in v for v in a + b):
|
|
||||||
continue
|
|
||||||
width = max(len(a), len(b))
|
|
||||||
for i in range(width):
|
|
||||||
diff = list(difflib.unified_diff(
|
|
||||||
_text_lines(a[i] if i < len(a) else ""), _text_lines(b[i] if i < len(b) else ""),
|
|
||||||
lineterm=""))[2:] # drops the ---/+++ file header
|
|
||||||
if diff:
|
|
||||||
changes.append((key if width == 1 else f"{key}[{i}]", "\n".join(diff)))
|
|
||||||
return changes
|
|
||||||
|
|
||||||
|
|
||||||
def _gcode_diff_section(files, setting, diff):
|
|
||||||
# A fence longer than any backtick run in the G-code cannot be closed by it.
|
|
||||||
fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", diff)])
|
|
||||||
# In <code>, the "@BBL" of a filament file name does not mention a GitHub user.
|
|
||||||
def code(text):
|
|
||||||
return f"<code>{html.escape(text, quote=False)}</code>"
|
|
||||||
names, listing = ", ".join(map(code, files)), ""
|
|
||||||
if len(files) > GCODE_DIFF_SUMMARY_FILES + 1: # never "and 1 more"
|
|
||||||
listing = f"<p>{names}</p>\n\n" # an HTML block, so markdown in names stays literal
|
|
||||||
names = (", ".join(map(code, files[:GCODE_DIFF_SUMMARY_FILES]))
|
|
||||||
+ f" and {len(files) - GCODE_DIFF_SUMMARY_FILES} more")
|
|
||||||
return (f"\n<details>\n<summary><b>{names} - {code(setting)} (click to expand)</b>"
|
|
||||||
f"</summary>\n\n{listing}{fence}diff\n{diff}\n{fence}\n\n</details>\n")
|
|
||||||
|
|
||||||
|
|
||||||
def gcode_diff(profiles_dir, base, head=None, vendors=None):
|
|
||||||
"""Markdown with one collapsed diff per changed G-code setting between base and
|
|
||||||
head, or "" when none changed. Files whose diff of a setting is identical share
|
|
||||||
one section. Cut to whole sections under GCODE_DIFF_BUDGET."""
|
|
||||||
pairs = [(old, new) for old, new in _changed_profiles(profiles_dir, base, head)
|
|
||||||
if not vendors or any(p and p.split("/")[0] in vendors for p in (old, new))]
|
|
||||||
specs = ([(base, old) for old, _ in pairs if old]
|
|
||||||
+ [(head, new) for _, new in pairs if new and head])
|
|
||||||
blobs = _blobs_at(profiles_dir, specs) if specs else {}
|
|
||||||
|
|
||||||
def load(ref, rel):
|
|
||||||
if rel is None:
|
|
||||||
return {}
|
|
||||||
if ref:
|
|
||||||
return _parse_profile(blobs[(ref, rel)], rel, ref)
|
|
||||||
try:
|
|
||||||
return _parse_profile(Path(profiles_dir, rel).read_bytes(), rel, "the working tree")
|
|
||||||
except OSError as e:
|
|
||||||
print_warning(f"{rel}: skipped, cannot read it: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
groups = {} # (setting, diff) -> files, in the order git lists them
|
|
||||||
for old_rel, new_rel in pairs:
|
|
||||||
old, new = load(base, old_rel), load(head, new_rel)
|
|
||||||
if not isinstance(old, dict) or not isinstance(new, dict):
|
|
||||||
continue
|
|
||||||
for setting, diff in _text_changes(old, new):
|
|
||||||
groups.setdefault((setting, diff), []).append(new_rel or old_rel)
|
|
||||||
if not groups:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
sections = [_gcode_diff_section(files, setting, diff)
|
|
||||||
for (setting, diff), files in groups.items()]
|
|
||||||
budget = GCODE_DIFF_BUDGET - len(GCODE_DIFF_HEADER.encode("utf-8"))
|
|
||||||
shown = 0
|
|
||||||
for section in sections:
|
|
||||||
budget -= len(section.encode("utf-8"))
|
|
||||||
if budget < 0:
|
|
||||||
break
|
|
||||||
shown += 1
|
|
||||||
note, hidden = "", len(sections) - shown
|
|
||||||
if hidden:
|
|
||||||
note = (f"\n{hidden} of {len(sections)} sections {'is' if hidden == 1 else 'are'} not "
|
|
||||||
"shown because the comment would be too long for GitHub. See the Files "
|
|
||||||
"changed tab for the rest.\n")
|
|
||||||
return GCODE_DIFF_HEADER + "".join(sections[:shown]) + note
|
|
||||||
|
|
||||||
|
|
||||||
def _default_gcode_diff_base(profiles_dir, head):
|
|
||||||
for main in ("upstream/main", "origin/main"):
|
|
||||||
try:
|
|
||||||
return _git(profiles_dir, "merge-base", head or "HEAD", main).decode().strip()
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
continue
|
|
||||||
raise RuntimeError("no --base given, and neither upstream/main nor origin/main exists")
|
|
||||||
|
|
||||||
|
|
||||||
def run_gcode_diff(profiles_dir, vendors, base, head):
|
|
||||||
try:
|
|
||||||
markdown = gcode_diff(profiles_dir, base or _default_gcode_diff_base(profiles_dir, head),
|
|
||||||
head, vendors)
|
|
||||||
# UnicodeDecodeError means git listed a file name that is not UTF-8.
|
|
||||||
except (OSError, RuntimeError, UnicodeDecodeError, subprocess.CalledProcessError) as e:
|
|
||||||
stderr = getattr(e, "stderr", None)
|
|
||||||
print_error(stderr.decode(errors="replace").strip() if stderr else str(e), file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
sys.stdout.write(markdown)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3455,9 +3233,6 @@ examples:
|
|||||||
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
|
orca_profile_tool.py legacy-compat --base /tmp/v2.4.2/resources/profiles
|
||||||
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
|
rewrite resources/info/legacy_compatible_printers.json against 2.4.2's
|
||||||
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
|
profiles (git archive v2.4.2 resources/profiles | tar -x -C /tmp/v2.4.2)
|
||||||
orca_profile_tool.py gcode-diff > gcode.md
|
|
||||||
the changed custom G-code of the working tree since it left upstream/main,
|
|
||||||
as markdown with one collapsed diff per setting
|
|
||||||
|
|
||||||
after adding, renaming or deleting profile files, run in this order:
|
after adding, renaming or deleting profile files, run in this order:
|
||||||
normalize -> update-index -> generate-id -> check
|
normalize -> update-index -> generate-id -> check
|
||||||
@@ -3612,25 +3387,6 @@ def build_parser():
|
|||||||
legacy_cmd.add_argument("--check", action="store_true",
|
legacy_cmd.add_argument("--check", action="store_true",
|
||||||
help="fail if the map differs from what --base implies; write nothing")
|
help="fail if the map differs from what --base implies; write nothing")
|
||||||
|
|
||||||
gcode_cmd = add(
|
|
||||||
"gcode-diff", [vendor_opt, profiles_opt],
|
|
||||||
"print a readable markdown diff of changed custom G-code",
|
|
||||||
"Print, as markdown, a unified diff of every G-code or other multi-line\n"
|
|
||||||
"setting that differs between --base and --head in each changed profile\n"
|
|
||||||
"file, one collapsed section per setting. Files whose diff of a setting is\n"
|
|
||||||
"identical share a section. Trailing spaces are removed from every line, and\n"
|
|
||||||
"the output is cut to whole sections to fit a GitHub comment. Prints nothing\n"
|
|
||||||
"when no such setting changed. CI posts it on profile PRs.\n"
|
|
||||||
"\n"
|
|
||||||
"The settings are the *_gcode and multiline string options PrintConfig.cpp\n"
|
|
||||||
"defines, the options Tab.cpp edits as code (such as filename_format), and any\n"
|
|
||||||
"string value that spans several lines.")
|
|
||||||
gcode_cmd.add_argument("--base", metavar="REF",
|
|
||||||
help="compare against this git ref (default: the merge base "
|
|
||||||
"with upstream/main, else origin/main)")
|
|
||||||
gcode_cmd.add_argument("--head", metavar="REF",
|
|
||||||
help="compare this git ref (default: the working tree)")
|
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -3667,9 +3423,6 @@ def main(argv=None):
|
|||||||
if args.command == "legacy-compat":
|
if args.command == "legacy-compat":
|
||||||
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
|
return run_legacy_compat(args.base, profiles_dir, args.output, args.check)
|
||||||
|
|
||||||
if args.command == "gcode-diff":
|
|
||||||
return run_gcode_diff(profiles_dir, vendors, args.base, args.head)
|
|
||||||
|
|
||||||
if args.command == "fix-variant":
|
if args.command == "fix-variant":
|
||||||
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
|
_changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run,
|
||||||
strict=args.strict)
|
strict=args.strict)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
"""Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the
|
||||||
normalize, trim, update-index, fix-variant, check and gcode-diff commands, and the
|
normalize, trim, update-index, fix-variant and check commands, and the subcommand
|
||||||
subcommand dispatch that reaches them (stdlib unittest, no external deps).
|
dispatch that reaches them (stdlib unittest, no external deps).
|
||||||
|
|
||||||
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
The id halves are covered by test_filament_id.py and test_setting_id.py.
|
||||||
|
|
||||||
@@ -14,12 +14,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
@@ -44,24 +41,7 @@ class Tree:
|
|||||||
os.makedirs(self.profiles)
|
os.makedirs(self.profiles)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
root = os.path.abspath(self.dir)
|
shutil.rmtree(self.dir, ignore_errors=True)
|
||||||
|
|
||||||
def writable_retry(func, path, _exc):
|
|
||||||
# Windows refuses to delete read-only files such as git's objects, and POSIX
|
|
||||||
# refuses to delete the entries of a read-only directory.
|
|
||||||
if not os.path.lexists(path): # already removed, as on a second cleanup
|
|
||||||
return
|
|
||||||
parent = os.path.dirname(os.path.abspath(path))
|
|
||||||
try:
|
|
||||||
if os.path.commonpath([parent, root]) == root: # never the temp dir itself
|
|
||||||
os.chmod(parent, stat.S_IRWXU)
|
|
||||||
if not os.path.islink(path): # chmod would follow it out of the tree
|
|
||||||
os.chmod(path, stat.S_IRWXU)
|
|
||||||
func(path)
|
|
||||||
except (OSError, ValueError): # commonpath raises ValueError across drives
|
|
||||||
pass
|
|
||||||
hook = "onexc" if sys.version_info >= (3, 12) else "onerror"
|
|
||||||
shutil.rmtree(self.dir, **{hook: writable_retry})
|
|
||||||
|
|
||||||
def index_path(self, vendor):
|
def index_path(self, vendor):
|
||||||
return os.path.join(self.profiles, vendor + ".json")
|
return os.path.join(self.profiles, vendor + ".json")
|
||||||
@@ -1429,292 +1409,6 @@ class TestDispatch(TreeCase):
|
|||||||
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# gcode-diff
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which("git"), "git not installed")
|
|
||||||
class TestGcodeDiff(TreeCase):
|
|
||||||
START = "G28\nM104 S200\nG1 Z5"
|
|
||||||
|
|
||||||
def git(self, *args):
|
|
||||||
subprocess.run(["git", "-C", self.t.dir, "-c", "user.name=t", "-c", "user.email=t@t",
|
|
||||||
"-c", "commit.gpgsign=false", *args], check=True, capture_output=True)
|
|
||||||
|
|
||||||
def printer(self, name, **extra):
|
|
||||||
self.t.write("V", f"machine/{name}.json", {"type": "machine", "name": name, **extra})
|
|
||||||
|
|
||||||
def commit(self):
|
|
||||||
self.git("add", "-A")
|
|
||||||
self.git("commit", "-q", "--allow-empty", "-m", "x")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
# git's list of the variables that tie a process to a repository, asked with no
|
|
||||||
# GIT_* set since a stray GIT_DIR fails the query.
|
|
||||||
clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
||||||
cls.repo_env_vars = set(subprocess.run(
|
|
||||||
["git", "rev-parse", "--local-env-vars"], env=clean, check=True,
|
|
||||||
capture_output=True, text=True).stdout.split())
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
# A git hook's GIT_DIR or GIT_INDEX_FILE would point every git call here, the
|
|
||||||
# tool's included, at the real repository instead of the temp tree.
|
|
||||||
env = {k: v for k, v in os.environ.items() if k not in self.repo_env_vars}
|
|
||||||
patcher = mock.patch.dict(os.environ, env, clear=True)
|
|
||||||
patcher.start()
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
self.git("init", "-q")
|
|
||||||
for name in ("A", "B", "C"):
|
|
||||||
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
|
||||||
self.printer("Gone", machine_end_gcode="M84")
|
|
||||||
self.commit()
|
|
||||||
|
|
||||||
def diff(self):
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def test_identical_edits_share_one_section(self):
|
|
||||||
for name in ("A", "B"):
|
|
||||||
self.printer(name, machine_start_gcode="G28\nM104 S210 \nG1 Z5")
|
|
||||||
self.printer("C", machine_start_gcode="G28\nM104 S220\nG1 Z5")
|
|
||||||
out = self.diff()
|
|
||||||
self.assertEqual(out.count("<details>"), 2)
|
|
||||||
self.assertIn("<code>V/machine/A.json</code>, <code>V/machine/B.json</code> - "
|
|
||||||
"<code>machine_start_gcode</code>", out)
|
|
||||||
self.assertIn("<code>V/machine/C.json</code> - <code>machine_start_gcode</code>", out)
|
|
||||||
# Trailing spaces are removed.
|
|
||||||
self.assertIn("\n-M104 S200\n+M104 S210\n", out)
|
|
||||||
self.assertIn("\n\n```diff\n@@ ", out)
|
|
||||||
self.assertIn("\n```\n\n</details>", out)
|
|
||||||
|
|
||||||
def test_nothing_is_printed_without_a_text_change(self):
|
|
||||||
self.printer("A", machine_start_gcode=self.START, nozzle_diameter=["0.6"])
|
|
||||||
self.assertEqual(self.diff(), "")
|
|
||||||
|
|
||||||
def test_added_and_deleted_files_show_the_whole_value(self):
|
|
||||||
# An untracked file counts as added; the validator's user dir is not a profile.
|
|
||||||
self.printer("New", machine_start_gcode="G28\nG29")
|
|
||||||
self.t.write("user", "default/machine/U.json", {"machine_start_gcode": "G28"})
|
|
||||||
os.remove(self.t.path("V", "machine/Gone.json"))
|
|
||||||
out = self.diff()
|
|
||||||
self.assertIn("@@ -0,0 +1,2 @@\n+G28\n+G29\n", out)
|
|
||||||
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
|
||||||
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
|
||||||
self.assertNotIn("U.json", out)
|
|
||||||
|
|
||||||
def test_a_multi_line_value_counts_whatever_its_key(self):
|
|
||||||
self.printer("A", machine_start_gcode=self.START, custom_note="one\ntwo")
|
|
||||||
self.assertIn("<code>V/machine/A.json</code> - <code>custom_note</code>", self.diff())
|
|
||||||
|
|
||||||
def test_a_file_name_cannot_mention_a_user(self):
|
|
||||||
self.t.write("V", "filament/PLA @BBL A1.json", {"filament_start_gcode": ["M106"]})
|
|
||||||
self.assertIn("<code>V/filament/PLA @BBL A1.json</code>", self.diff())
|
|
||||||
|
|
||||||
def test_head_compares_two_commits(self):
|
|
||||||
self.printer("A", machine_start_gcode="G28")
|
|
||||||
self.commit()
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("<code>V/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
|
||||||
|
|
||||||
def test_a_renamed_file_diffs_against_its_old_self(self):
|
|
||||||
os.rename(self.t.path("V", "machine/A.json"), self.t.path("V", "machine/A2.json"))
|
|
||||||
self.printer("A2", machine_start_gcode="G28\nM104 S210\nG1 Z5", nozzle_diameter=["0.4"])
|
|
||||||
self.commit()
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("<code>V/machine/A2.json</code> - <code>machine_start_gcode</code>", out)
|
|
||||||
self.assertIn("\n G28\n-M104 S200\n+M104 S210\n G1 Z5\n", out)
|
|
||||||
|
|
||||||
def test_a_file_renamed_off_json_loses_its_values(self):
|
|
||||||
os.rename(self.t.path("V", "machine/Gone.json"), self.t.path("V", "machine/Gone.json.bak"))
|
|
||||||
self.commit()
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("<code>V/machine/Gone.json</code> - <code>machine_end_gcode</code>", out)
|
|
||||||
self.assertIn("@@ -1 +0,0 @@\n-M84\n", out)
|
|
||||||
|
|
||||||
def test_a_file_renamed_onto_json_is_new(self):
|
|
||||||
with open(self.t.path("V", "machine/Draft.txt"), "w", encoding="utf-8") as f:
|
|
||||||
json.dump({"type": "machine", "machine_start_gcode": self.START}, f)
|
|
||||||
self.commit()
|
|
||||||
os.rename(self.t.path("V", "machine/Draft.txt"), self.t.path("V", "machine/Draft.json"))
|
|
||||||
self.commit()
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("@@ -0,0 +1,3 @@\n+G28\n+M104 S200\n+G1 Z5\n", out)
|
|
||||||
|
|
||||||
def test_a_batch_read_survives_paths_that_are_not_files(self):
|
|
||||||
blobs = apt._blobs_at(self.t.profiles, [("HEAD", "V/machine"), ("HEAD", "V/no such.json"),
|
|
||||||
("HEAD", "V/machine/Gone.json")])
|
|
||||||
self.assertIsNone(blobs[("HEAD", "V/machine")])
|
|
||||||
self.assertIsNone(blobs[("HEAD", "V/no such.json")])
|
|
||||||
self.assertEqual(json.loads(blobs[("HEAD", "V/machine/Gone.json")])["machine_end_gcode"],
|
|
||||||
"M84")
|
|
||||||
|
|
||||||
def test_each_value_of_a_vector_option_is_its_own_setting(self):
|
|
||||||
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M107"]})
|
|
||||||
self.commit()
|
|
||||||
self.t.write("V", "filament/F.json", {"filament_start_gcode": ["M106", "M106 S0"]})
|
|
||||||
out = self.diff()
|
|
||||||
self.assertEqual(out.count("<details>"), 1)
|
|
||||||
self.assertIn("<code>filament_start_gcode[1]</code>", out)
|
|
||||||
|
|
||||||
def test_vendor_limits_the_files(self):
|
|
||||||
self.printer("A", machine_start_gcode="G28")
|
|
||||||
self.t.write("W", "machine/X.json", {"machine_start_gcode": "G28"})
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD", "--vendor", "V")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("V/machine/A.json", out)
|
|
||||||
self.assertNotIn("W/machine/X.json", out)
|
|
||||||
|
|
||||||
def test_vendor_includes_files_moved_out_of_it(self):
|
|
||||||
self.t.add_vendor("W")
|
|
||||||
os.rename(self.t.path("V", "machine/A.json"), self.t.path("W", "machine/A.json"))
|
|
||||||
data = self.t.read("W", "machine/A.json")
|
|
||||||
self.t.write("W", "machine/A.json", {**data, "machine_start_gcode": "G28\nM104 S210\nG1 Z5"})
|
|
||||||
self.commit()
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD~1", "--head", "HEAD",
|
|
||||||
"--vendor", "V")
|
|
||||||
self.assertEqual(rc, 0, out)
|
|
||||||
self.assertIn("<code>W/machine/A.json</code> - <code>machine_start_gcode</code>", out)
|
|
||||||
|
|
||||||
def same_edit(self, names):
|
|
||||||
"""The diff after one start G-code edit to each of names; returns (summary, output)."""
|
|
||||||
for name in names:
|
|
||||||
self.printer(name, machine_start_gcode=self.START, nozzle_diameter=["0.4"])
|
|
||||||
self.commit()
|
|
||||||
for name in names:
|
|
||||||
self.printer(name, machine_start_gcode="G28")
|
|
||||||
out = self.diff()
|
|
||||||
return re.search(r"<summary>(.*)</summary>", out).group(1), out
|
|
||||||
|
|
||||||
def test_a_long_file_list_moves_into_the_section(self):
|
|
||||||
summary, out = self.same_edit("ABCDE")
|
|
||||||
self.assertIn("<code>V/machine/C.json</code> and 2 more - ", summary)
|
|
||||||
self.assertNotIn("D.json", summary)
|
|
||||||
self.assertIn("<code>V/machine/D.json</code>, <code>V/machine/E.json</code></p>\n\n```diff",
|
|
||||||
out)
|
|
||||||
|
|
||||||
def test_four_files_are_all_named_in_the_summary(self):
|
|
||||||
summary, _out = self.same_edit("ABCD")
|
|
||||||
self.assertIn("<code>V/machine/D.json</code> - ", summary)
|
|
||||||
self.assertNotIn("more", summary)
|
|
||||||
|
|
||||||
def test_an_error_goes_to_stderr_only(self):
|
|
||||||
err = io.StringIO()
|
|
||||||
with contextlib.redirect_stderr(err):
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "no-such-ref")
|
|
||||||
self.assertEqual(rc, 1)
|
|
||||||
self.assertEqual(out, "")
|
|
||||||
self.assertIn("no-such-ref", err.getvalue())
|
|
||||||
|
|
||||||
def test_a_file_name_that_is_not_utf8_is_an_error(self):
|
|
||||||
bad = UnicodeDecodeError("utf-8", b"\xe9.json", 0, 1, "invalid continuation byte")
|
|
||||||
err = io.StringIO()
|
|
||||||
with mock.patch.object(apt, "_changed_profiles", side_effect=bad), \
|
|
||||||
contextlib.redirect_stderr(err):
|
|
||||||
rc, out = self.run_command("gcode-diff", "--base", "HEAD")
|
|
||||||
self.assertEqual((rc, out), (1, ""))
|
|
||||||
self.assertIn("invalid continuation byte", err.getvalue())
|
|
||||||
|
|
||||||
def test_output_is_cut_to_whole_sections(self):
|
|
||||||
for name in ("A", "B", "C"):
|
|
||||||
self.printer(name, machine_start_gcode=name * 300)
|
|
||||||
size = len(apt.gcode_diff(self.t.profiles, "HEAD").encode("utf-8"))
|
|
||||||
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", size - 1):
|
|
||||||
out = self.diff()
|
|
||||||
self.assertEqual(out.count("<details>"), 2)
|
|
||||||
self.assertEqual(out.count("</details>"), 2)
|
|
||||||
self.assertIn("\n1 of 3 sections is not shown", out)
|
|
||||||
with mock.patch.object(apt, "GCODE_DIFF_BUDGET", 1):
|
|
||||||
self.assertIn("\n3 of 3 sections are not shown", self.diff())
|
|
||||||
|
|
||||||
def test_cleanup_removes_the_repo(self):
|
|
||||||
# setUp's commit leaves read-only git objects, and a read-only directory blocks
|
|
||||||
# deletion on POSIX.
|
|
||||||
os.chmod(self.t.path("V", "machine"), stat.S_IRUSR | stat.S_IXUSR)
|
|
||||||
self.t.cleanup()
|
|
||||||
self.assertFalse(os.path.exists(self.t.dir))
|
|
||||||
with mock.patch.object(os, "chmod") as chmod:
|
|
||||||
self.t.cleanup() # a second cleanup, as addCleanup runs, must not touch the temp dir
|
|
||||||
chmod.assert_not_called()
|
|
||||||
|
|
||||||
def test_only_repository_variables_are_stripped(self):
|
|
||||||
self.assertTrue({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} <= self.repo_env_vars)
|
|
||||||
self.assertFalse({"GIT_SSH", "GIT_EXEC_PATH", "GIT_EDITOR"} & self.repo_env_vars)
|
|
||||||
|
|
||||||
def test_cleanup_leaves_symlink_targets_alone(self):
|
|
||||||
outside = tempfile.mkdtemp(prefix="profile_tool_outside_")
|
|
||||||
self.addCleanup(shutil.rmtree, outside, True)
|
|
||||||
target = os.path.join(outside, "f")
|
|
||||||
open(target, "w").close()
|
|
||||||
os.chmod(target, stat.S_IRUSR)
|
|
||||||
self.addCleanup(os.chmod, target, stat.S_IRWXU) # runs before the rmtree above
|
|
||||||
machine = self.t.path("V", "machine")
|
|
||||||
try:
|
|
||||||
os.symlink(target, os.path.join(machine, "link"))
|
|
||||||
except (OSError, NotImplementedError):
|
|
||||||
self.skipTest("cannot create symlinks here")
|
|
||||||
os.chmod(machine, stat.S_IRUSR | stat.S_IXUSR) # so removing the link fails at first
|
|
||||||
self.t.cleanup()
|
|
||||||
self.assertFalse(os.path.exists(self.t.dir))
|
|
||||||
self.assertEqual(stat.S_IMODE(os.stat(target).st_mode) & stat.S_IWUSR, 0)
|
|
||||||
|
|
||||||
def test_a_git_hook_environment_is_ignored(self):
|
|
||||||
scripts = os.path.join(REPO_ROOT, "scripts")
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "unittest",
|
|
||||||
"tests.test_profile_tool.TestGcodeDiff.test_head_compares_two_commits"],
|
|
||||||
cwd=scripts, capture_output=True, text=True,
|
|
||||||
env={**os.environ, "GIT_DIR": os.path.join(self.t.dir, "nowhere")})
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
|
|
||||||
def test_the_options_are_read_from_the_engine(self):
|
|
||||||
keys = apt._text_option_keys()
|
|
||||||
self.assertTrue({"machine_start_gcode", "filament_start_gcode", "printing_by_object_gcode",
|
|
||||||
"notes", "post_process"} <= keys)
|
|
||||||
self.assertNotIn("emit_machine_limits_to_gcode", keys) # a coBool
|
|
||||||
|
|
||||||
START_DEF = 'def = this->add("machine_start_gcode", coString);\n'
|
|
||||||
|
|
||||||
def keys_from(self, print_config, tab=None):
|
|
||||||
"""_text_option_keys read from these sources; no tab means no Tab.cpp."""
|
|
||||||
paths = [os.path.join(self.t.dir, "PrintConfig.cpp"), os.path.join(self.t.dir, "Tab.cpp")]
|
|
||||||
for path, text in zip(paths, (print_config, tab)):
|
|
||||||
if text is not None:
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(text)
|
|
||||||
apt._text_option_keys.cache_clear()
|
|
||||||
self.addCleanup(apt._text_option_keys.cache_clear)
|
|
||||||
with mock.patch.object(apt, "PRINT_CONFIG_CPP", paths[0]), \
|
|
||||||
mock.patch.object(apt, "TAB_CPP", paths[1]):
|
|
||||||
return apt._text_option_keys()
|
|
||||||
|
|
||||||
def test_multiline_is_read_whatever_its_spacing(self):
|
|
||||||
keys = self.keys_from(self.START_DEF + 'def = this->add("printer_notes", coString);\n'
|
|
||||||
' def->multiline = true;\n')
|
|
||||||
self.assertIn("printer_notes", keys)
|
|
||||||
|
|
||||||
def test_the_code_options_of_tab_cpp_are_read(self):
|
|
||||||
keys = self.keys_from(self.START_DEF, 'option = optgroup->get_option("filename_format");\n'
|
|
||||||
'option.opt.is_code = true;\n'
|
|
||||||
'option = optgroup->get_option("layer_height");\n')
|
|
||||||
self.assertIn("filename_format", keys)
|
|
||||||
self.assertNotIn("layer_height", keys)
|
|
||||||
|
|
||||||
def test_a_missing_tab_cpp_is_not_an_error(self):
|
|
||||||
self.assertIn("machine_start_gcode", self.keys_from(self.START_DEF))
|
|
||||||
|
|
||||||
def test_markdown_in_a_file_name_stays_literal(self):
|
|
||||||
section = apt._gcode_diff_section(["V/a *b*.json", "V/c.json", "V/d.json", "V/e.json",
|
|
||||||
"V/f.json"], "machine_start_gcode", "@@ -1 +1 @@")
|
|
||||||
self.assertIn("\n<p><code>V/a *b*.json</code>, ", section)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# the real tree
|
# the real tree
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
#include "Search.hpp"
|
#include "Search.hpp"
|
||||||
#include "BitmapCache.hpp"
|
#include "BitmapCache.hpp"
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
#include "KeyChord.hpp"
|
|
||||||
|
|
||||||
#include <nanosvg/nanosvg.h>
|
#include <nanosvg/nanosvg.h>
|
||||||
#include <nanosvg/nanosvgrast.h>
|
#include <nanosvg/nanosvgrast.h>
|
||||||
@@ -2135,27 +2134,6 @@ bool ImGuiWrapper::want_any_input() const
|
|||||||
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
|
return io.WantCaptureMouse || io.WantCaptureKeyboard || io.WantTextInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ImGuiWrapper::text_input_active() const
|
|
||||||
{
|
|
||||||
const ImGuiID id = ImGui::GetActiveID();
|
|
||||||
return id != 0 && ImGui::GetInputTextState(id) != nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ImGuiWrapper::modal_popup_open() const
|
|
||||||
{
|
|
||||||
// A modal stays on the popup stack while its canvas is hidden, so it counts only if the last finished
|
|
||||||
// frame drew it, which NewFrame() moves from Active to WasActive.
|
|
||||||
const ImGuiWindow* modal = ImGui::GetTopMostPopupModal();
|
|
||||||
if (modal == nullptr)
|
|
||||||
return false;
|
|
||||||
return ImGui::GetCurrentContext()->WithinFrameScope ? modal->WasActive : modal->Active;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ImGuiWrapper::owns_key(const KeyChord& chord) const
|
|
||||||
{
|
|
||||||
return modal_popup_open() || (chord.edits_text() && text_input_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
|
ImFontAtlasCustomRect* ImGuiWrapper::GetTextureCustomRect(const wchar_t& tex_id)
|
||||||
{
|
{
|
||||||
auto item = m_custom_glyph_rects_ids.find(tex_id);
|
auto item = m_custom_glyph_rects_ids.find(tex_id);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ struct ImRect;
|
|||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
namespace GUI {
|
namespace GUI {
|
||||||
|
|
||||||
struct KeyChord;
|
|
||||||
|
|
||||||
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
|
bool get_data_from_svg(const std::string &filename, unsigned int max_size_px, ThumbnailData &thumbnail_data);
|
||||||
|
|
||||||
@@ -220,12 +219,6 @@ public:
|
|||||||
bool want_keyboard() const;
|
bool want_keyboard() const;
|
||||||
bool want_text_input() const;
|
bool want_text_input() const;
|
||||||
bool want_any_input() const;
|
bool want_any_input() const;
|
||||||
// True while an ImGui InputText is active, without the one-frame lag of want_text_input().
|
|
||||||
bool text_input_active() const;
|
|
||||||
// True while an ImGui modal popup is on screen.
|
|
||||||
bool modal_popup_open() const;
|
|
||||||
// True for every key while a modal is open, and for text-editing keys while an InputText is active.
|
|
||||||
bool owns_key(const KeyChord& chord) const;
|
|
||||||
|
|
||||||
// Optional inputs are used for set up value inside of an optional, with default value
|
// Optional inputs are used for set up value inside of an optional, with default value
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ void KBShortcutsDialog::fill_pages()
|
|||||||
using Section = ShortcutSection;
|
using Section = ShortcutSection;
|
||||||
|
|
||||||
if (wxGetApp().is_editor()) {
|
if (wxGetApp().is_editor()) {
|
||||||
page(_L("Global"), _L("Available anywhere in the window."), ShortcutContext::Global, {
|
page(_L("Global"), _L("Available anywhere in the window, even while typing in a text field."), ShortcutContext::Global, {
|
||||||
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
|
fixed(Section::SpeedDial, { alt, "1-9, 0" }, L("Run favorite 1 to 10")),
|
||||||
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
|
fixed(Section::SpeedDial, { ctrl, "B" }, L("Pin or unpin the selected action")),
|
||||||
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
|
// wx cycles notebook pages on Ctrl+Tab, which is Cmd+Tab on macOS and never arrives there.
|
||||||
@@ -438,16 +438,17 @@ ShortcutCaptureDialog::ShortcutCaptureDialog(wxWindow* parent, Shortcut shortcut
|
|||||||
SetBackgroundColour(*wxWHITE);
|
SetBackgroundColour(*wxWHITE);
|
||||||
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
|
wxBoxSizer* sizer = new wxBoxSizer(wxVERTICAL);
|
||||||
|
|
||||||
// A Global shortcut runs before the focused control gets the key, so its hint names the keys it can use.
|
// A Global shortcut also runs while a text field has the focus, so its hint names the keys it can use.
|
||||||
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
|
const bool global = (shortcut_info(shortcut).contexts & context_bit(ShortcutContext::Global)) != 0;
|
||||||
const wxString advice = global_key_advice();
|
const wxString advice = global_key_advice();
|
||||||
|
const wxString typing = _L("Global shortcuts also apply while typing.");
|
||||||
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
|
const wxString rule = _L("A key that types a character cannot be a global shortcut.");
|
||||||
m_hint = global ? advice : _L("Esc cancels, Enter confirms.");
|
m_hint = global ? typing + "\n" + advice : _L("Esc cancels, Enter confirms.");
|
||||||
m_rejection = rule + "\n" + advice;
|
m_rejection = rule + "\n" + advice;
|
||||||
|
|
||||||
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
|
// Wide enough for each sentence on a line of its own where the translation allows, within limits.
|
||||||
int width = FromDIP(450);
|
int width = FromDIP(450);
|
||||||
for (const wxString& sentence : { rule, advice }) {
|
for (const wxString& sentence : { typing, rule, advice }) {
|
||||||
int extent = 0;
|
int extent = 0;
|
||||||
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
|
GetTextExtent(sentence, &extent, nullptr, nullptr, nullptr, &wxGetApp().normal_font());
|
||||||
width = std::max(width, extent);
|
width = std::max(width, extent);
|
||||||
|
|||||||
@@ -187,40 +187,6 @@ bool KeyChord::is_menu_accelerator() const
|
|||||||
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
|
return valid() && ((modifiers & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_RAW_CONTROL)) != 0 || (!is_printable(key) && key != WXK_SPACE));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool KeyChord::edits_text() const
|
|
||||||
{
|
|
||||||
if (!valid())
|
|
||||||
return false;
|
|
||||||
if (!is_menu_accelerator())
|
|
||||||
return true;
|
|
||||||
const int mods = modifiers & ~wxMOD_SHIFT;
|
|
||||||
switch (key) {
|
|
||||||
case WXK_LEFT: case WXK_RIGHT: case WXK_UP: case WXK_DOWN:
|
|
||||||
case WXK_HOME: case WXK_END: case WXK_BACK: case WXK_DELETE:
|
|
||||||
#ifdef __APPLE__
|
|
||||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL || mods == wxMOD_ALT; // Option moves by word
|
|
||||||
#else
|
|
||||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
|
||||||
#endif
|
|
||||||
case WXK_INSERT:
|
|
||||||
return mods == wxMOD_NONE || mods == wxMOD_CONTROL;
|
|
||||||
case WXK_RETURN: case WXK_TAB: case WXK_ESCAPE: case WXK_PAGEUP: case WXK_PAGEDOWN:
|
|
||||||
return mods == wxMOD_NONE;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!is_printable(key))
|
|
||||||
return false;
|
|
||||||
#ifdef __APPLE__
|
|
||||||
if (mods == wxMOD_ALT || mods == wxMOD_RAW_CONTROL)
|
|
||||||
return true; // Option types a character, Control runs the Cocoa text key bindings
|
|
||||||
#else
|
|
||||||
if (mods == (wxMOD_CONTROL | wxMOD_ALT))
|
|
||||||
return true; // AltGr types a character
|
|
||||||
#endif
|
|
||||||
return mods == wxMOD_CONTROL && (key == 'A' || key == 'C' || key == 'V' || key == 'X' || key == 'Y' || key == 'Z');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a chord the desktop acts on while still delivering it to the app belongs here.
|
// Only a chord the desktop acts on while still delivering it to the app belongs here.
|
||||||
bool KeyChord::is_system_shortcut() const
|
bool KeyChord::is_system_shortcut() const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ struct KeyChord
|
|||||||
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
|
// True when Ctrl or Alt is held or the key is non-printable, the chords a menu can own without
|
||||||
// swallowing typing in text fields.
|
// swallowing typing in text fields.
|
||||||
bool is_menu_accelerator() const;
|
bool is_menu_accelerator() const;
|
||||||
// True for a chord a focused text field acts on: typing a character (AltGr and macOS Option
|
|
||||||
// characters included), moving the caret, selecting, deleting, or the clipboard and undo chords.
|
|
||||||
bool edits_text() const;
|
|
||||||
// True for a chord the desktop acts on although the app receives it, so a binding would
|
// True for a chord the desktop acts on although the app receives it, so a binding would
|
||||||
// take it from the system.
|
// take it from the system.
|
||||||
bool is_system_shortcut() const;
|
bool is_system_shortcut() const;
|
||||||
|
|||||||
@@ -50,7 +50,6 @@
|
|||||||
#include <wx/panel.h>
|
#include <wx/panel.h>
|
||||||
#include <wx/settings.h>
|
#include <wx/settings.h>
|
||||||
#include <wx/textentry.h>
|
#include <wx/textentry.h>
|
||||||
#include <wx/spinctrl.h>
|
|
||||||
#include <wx/notebook.h>
|
#include <wx/notebook.h>
|
||||||
#include <wx/listbook.h>
|
#include <wx/listbook.h>
|
||||||
#include <wx/simplebook.h>
|
#include <wx/simplebook.h>
|
||||||
@@ -108,8 +107,6 @@
|
|||||||
#include "../Utils/PrintHost.hpp"
|
#include "../Utils/PrintHost.hpp"
|
||||||
|
|
||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
#include "ImGuiWrapper.hpp"
|
|
||||||
#include "OpenGLManager.hpp"
|
|
||||||
#include "Shortcuts.hpp"
|
#include "Shortcuts.hpp"
|
||||||
#include "UnsavedChangesDialog.hpp"
|
#include "UnsavedChangesDialog.hpp"
|
||||||
#include "PublishSettingsDialog.hpp"
|
#include "PublishSettingsDialog.hpp"
|
||||||
@@ -187,31 +184,18 @@ enum class ERescaleTarget
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
bool focus_is_text_field(wxWindow* focus)
|
|
||||||
{
|
|
||||||
if (dynamic_cast<wxTextEntryBase*>(focus) || dynamic_cast<wxWebView*>(focus))
|
|
||||||
return true; // a web page can have its own text fields
|
|
||||||
if (dynamic_cast<wxSpinCtrl*>(focus) || dynamic_cast<wxSpinCtrlDouble*>(focus))
|
|
||||||
return true; // the native Windows spin control is not a wxTextEntry
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a scene canvas draws ImGui, and a hidden one can keep the wx focus after a tab switch.
|
|
||||||
const ImGuiWrapper* focused_canvas_imgui(wxWindow* focus)
|
|
||||||
{
|
|
||||||
if (!OpenGLManager::is_scene_canvas(focus) || !focus->IsShownOnScreen())
|
|
||||||
return nullptr;
|
|
||||||
return wxGetApp().imgui();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
|
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
|
||||||
// controls, so only take Space when the focused window has no keyboard-activation meaning of its
|
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
|
||||||
// own. Canvases (GLCanvas3D) and panels are not controls and fall through to "open"; the Notebook
|
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
|
||||||
// itself does too, so Space still opens the dial on any page.
|
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
|
||||||
bool focus_keeps_space(wxWindow* focus)
|
bool focus_keeps_space(wxWindow* focus)
|
||||||
{
|
{
|
||||||
if (!focus)
|
if (!focus)
|
||||||
return false;
|
return false;
|
||||||
|
if (dynamic_cast<wxTextEntryBase*>(focus))
|
||||||
|
return true; // typing a space into a text field
|
||||||
|
if (dynamic_cast<wxWebView*>(focus))
|
||||||
|
return true; // web content scrolls and hosts its own text fields
|
||||||
if (dynamic_cast<::Button*>(focus))
|
if (dynamic_cast<::Button*>(focus))
|
||||||
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
|
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
|
||||||
if (dynamic_cast<StaticBox*>(focus))
|
if (dynamic_cast<StaticBox*>(focus))
|
||||||
@@ -832,12 +816,16 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
|
|||||||
|
|
||||||
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
|
bool MainFrame::handle_global_shortcut(const KeyChord& chord)
|
||||||
{
|
{
|
||||||
// CHAR_HOOK runs before the focused window, so leave text-editing keys to a focused text field and
|
// ImGui draws its text fields (the text tool's box, a sketch value field) on the GL canvas, so
|
||||||
// the keys ImGui owns to the focused canvas.
|
// while one of them is typed into the focused wx window is that canvas or an in-canvas frame:
|
||||||
|
// neither is a wxControl, so focus_keeps_space() cannot see it and CHAR_HOOK takes the chord
|
||||||
|
// here first -- Space opened the Speed Dial, Ctrl+P the Preferences, and the field never saw
|
||||||
|
// the key. ImGui raises WantCaptureKeyboard while one of its items owns the keyboard, so leave
|
||||||
|
// the key to it then. A focused wxControl (text field, list, web view) is untouched: those
|
||||||
|
// keep their own keys, and ImGui's flag can outlive the focus moving to one of them.
|
||||||
wxWindow* const focus = wxWindow::FindFocus();
|
wxWindow* const focus = wxWindow::FindFocus();
|
||||||
if (chord.edits_text() && focus_is_text_field(focus))
|
ImGuiWrapper* const imgui = wxGetApp().imgui();
|
||||||
return false;
|
if (focus != nullptr && imgui != nullptr && imgui->want_keyboard() && dynamic_cast<wxControl*>(focus) == nullptr)
|
||||||
if (const ImGuiWrapper* imgui = focused_canvas_imgui(focus); imgui != nullptr && imgui->owns_key(chord))
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
|
const std::optional<Shortcut> shortcut = wxGetApp().shortcuts().lookup(ShortcutContext::Global, chord);
|
||||||
|
|||||||
@@ -427,8 +427,6 @@ wxGLContext* OpenGLManager::init_glcontext(wxGLCanvas& canvas, const std::pair<i
|
|||||||
return m_context;
|
return m_context;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const char* const SCENE_CANVAS_NAME = "OrcaSceneCanvas";
|
|
||||||
|
|
||||||
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
||||||
{
|
{
|
||||||
int antialiasing_samples = 4;
|
int antialiasing_samples = 4;
|
||||||
@@ -472,17 +470,12 @@ wxGLCanvas* OpenGLManager::create_wxglcanvas(wxWindow& parent)
|
|||||||
attribList[samples_value_idx] = 0;
|
attribList[samples_value_idx] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS, SCENE_CANVAS_NAME);
|
wxGLCanvas* canvas = new wxGLCanvas(&parent, wxID_ANY, attribList, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS);
|
||||||
// The GL canvas paints its entire surface, so background erasing is unnecessary.
|
// The GL canvas paints its entire surface, so background erasing is unnecessary.
|
||||||
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
canvas->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OpenGLManager::is_scene_canvas(const wxWindow* window)
|
|
||||||
{
|
|
||||||
return window != nullptr && window->GetName() == SCENE_CANVAS_NAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
void OpenGLManager::detect_multisample(int* attribList)
|
void OpenGLManager::detect_multisample(int* attribList)
|
||||||
{
|
{
|
||||||
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
|
int wxVersion = wxMAJOR_VERSION * 10000 + wxMINOR_VERSION * 100 + wxRELEASE_NUMBER;
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ public:
|
|||||||
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
|
static bool are_framebuffers_supported() { return (s_framebuffers_type != EFramebufferType::Unknown); }
|
||||||
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
|
static EFramebufferType get_framebuffers_type() { return s_framebuffers_type; }
|
||||||
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
|
static wxGLCanvas* create_wxglcanvas(wxWindow& parent);
|
||||||
// True for a canvas made by create_wxglcanvas(), the kind a GLCanvas3D draws its scene and ImGui into.
|
|
||||||
static bool is_scene_canvas(const wxWindow* window);
|
|
||||||
static const GLInfo& get_gl_info() { return s_gl_info; }
|
static const GLInfo& get_gl_info() { return s_gl_info; }
|
||||||
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
|
static bool force_power_of_two_textures() { return s_force_power_of_two_textures; }
|
||||||
|
|
||||||
|
|||||||
@@ -4087,7 +4087,7 @@ void SelectMachineDialog::update_user_printer()
|
|||||||
{
|
{
|
||||||
m_printer_last_select = "";
|
m_printer_last_select = "";
|
||||||
update_select_layout(nullptr);
|
update_select_layout(nullptr);
|
||||||
m_printer_box->GetPrinterComboBox()->SetTextLabel("");
|
m_printer_box->GetPrinterComboBox()->SetValue(_L("No available printers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||||
|
|||||||
@@ -1229,7 +1229,7 @@ void SendToPrinterDialog::update_user_printer()
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
m_printer_last_select = "";
|
m_printer_last_select = "";
|
||||||
m_comboBox_printer->SetTextLabel("");
|
m_comboBox_printer->SetValue(_L("No available printers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "for send task, current printer id = " << m_printer_last_select << std::endl;
|
||||||
|
|||||||
@@ -74,7 +74,6 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef __LINUX__
|
#ifdef __LINUX__
|
||||||
#include "LinuxDisplayBackend.hpp"
|
|
||||||
#include <wx/textfile.h>
|
#include <wx/textfile.h>
|
||||||
#include <wx/tokenzr.h>
|
#include <wx/tokenzr.h>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -202,11 +201,7 @@ TroubleshootDialog::TroubleshootDialog()
|
|||||||
return std::vector<wxString>{GetOStype()};
|
return std::vector<wxString>{GetOStype()};
|
||||||
return std::vector<wxString>{
|
return std::vector<wxString>{
|
||||||
GetOSinfo(),
|
GetOSinfo(),
|
||||||
#ifdef __LINUX__
|
|
||||||
GetPackageType() + GetAppDisplayBackend(),
|
|
||||||
#else
|
|
||||||
GetPackageType(),
|
GetPackageType(),
|
||||||
#endif
|
|
||||||
GetCPUinfo(),
|
GetCPUinfo(),
|
||||||
GetRAMinfo() + " RAM",
|
GetRAMinfo() + " RAM",
|
||||||
GetGPUinfo(),
|
GetGPUinfo(),
|
||||||
@@ -439,15 +434,10 @@ wxString TroubleshootDialog::GetTimestamp()
|
|||||||
|
|
||||||
wxString TroubleshootDialog::GetSysInfoAll()
|
wxString TroubleshootDialog::GetSysInfoAll()
|
||||||
{
|
{
|
||||||
wxString package = GetPackageType();
|
|
||||||
#ifdef __LINUX__
|
|
||||||
package += GetAppDisplayBackend();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
wxString info;
|
wxString info;
|
||||||
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
|
info += "Version : " + wxString(SoftFever_VERSION) + "\n"
|
||||||
+ "Build : " + wxString(build_commit_label) + "\n"
|
+ "Build : " + wxString(build_commit_label) + "\n"
|
||||||
+ "Package : " + package + "\n"
|
+ "Package : " + GetPackageType() + "\n"
|
||||||
+ "Platform : " + GetOSinfo() + "\n"
|
+ "Platform : " + GetOSinfo() + "\n"
|
||||||
+ "Processor : " + GetCPUinfo() + "\n"
|
+ "Processor : " + GetCPUinfo() + "\n"
|
||||||
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
|
+ "Memory : " + GetRAMinfo() + "RAM" + "\n"
|
||||||
@@ -761,18 +751,6 @@ wxString TroubleshootDialog::GetLinuxDisplayServer()
|
|||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
wxString TroubleshootDialog::GetAppDisplayBackend()
|
|
||||||
{
|
|
||||||
// Display backend actually used by the app (GDK), not the session type
|
|
||||||
wxString display;
|
|
||||||
switch (get_linux_display_backend()) {
|
|
||||||
case LinuxDisplayBackend::X11: display = "X11"; break;
|
|
||||||
case LinuxDisplayBackend::Wayland: display = "Wayland"; break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
return display.IsEmpty() ? "" : (" (" + display + ")"); // spacing included here to prevent non centered text
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
wxString TroubleshootDialog::GetPackageType()
|
wxString TroubleshootDialog::GetPackageType()
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ protected:
|
|||||||
#elif defined(__LINUX__)
|
#elif defined(__LINUX__)
|
||||||
wxString GetLinuxDistroName();
|
wxString GetLinuxDistroName();
|
||||||
wxString GetLinuxDisplayServer();
|
wxString GetLinuxDisplayServer();
|
||||||
wxString GetAppDisplayBackend();
|
|
||||||
#endif
|
#endif
|
||||||
wxString GetPackageType();
|
wxString GetPackageType();
|
||||||
wxString GetCPUinfo();
|
wxString GetCPUinfo();
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ void ComboBox::DoDeleteOneItem(unsigned int pos)
|
|||||||
|
|
||||||
void ComboBox::ForceDropdownOpen()
|
void ComboBox::ForceDropdownOpen()
|
||||||
{
|
{
|
||||||
if (!IsEnabled())
|
if (!IsEnabled() || items.empty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!drop_down) {
|
if (!drop_down) {
|
||||||
@@ -398,7 +398,7 @@ void ComboBox::mouseDown(wxMouseEvent &event)
|
|||||||
SetFocus();
|
SetFocus();
|
||||||
if (drop_down) {
|
if (drop_down) {
|
||||||
drop.Hide();
|
drop.Hide();
|
||||||
} else if (drop.HasDismissLongTime()) {
|
} else if (!items.empty() && drop.HasDismissLongTime()) { // DropDown paints nothing for an empty list
|
||||||
drop.autoPosition();
|
drop.autoPosition();
|
||||||
drop_down = true;
|
drop_down = true;
|
||||||
drop.Popup(&drop);
|
drop.Popup(&drop);
|
||||||
@@ -426,7 +426,7 @@ void ComboBox::keyDown(wxKeyEvent& event)
|
|||||||
case WXK_SPACE:
|
case WXK_SPACE:
|
||||||
if (drop_down) {
|
if (drop_down) {
|
||||||
drop.DismissAndNotify();
|
drop.DismissAndNotify();
|
||||||
} else if (drop.HasDismissLongTime()) {
|
} else if (!items.empty() && drop.HasDismissLongTime()) {
|
||||||
drop.autoPosition();
|
drop.autoPosition();
|
||||||
drop_down = true;
|
drop_down = true;
|
||||||
drop.Popup();
|
drop.Popup();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
|
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
|
||||||
// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
|
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
|
||||||
// as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
|
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
|
||||||
// ahead of it).
|
|
||||||
#ifdef WIN32
|
#ifdef WIN32
|
||||||
#ifndef WIN32_LEAN_AND_MEAN
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
#define WIN32_LEAN_AND_MEAN
|
#define WIN32_LEAN_AND_MEAN
|
||||||
@@ -18,15 +17,12 @@
|
|||||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||||
|
|
||||||
#include <imgui/imgui.h>
|
#include <imgui/imgui.h>
|
||||||
#include <imgui/imgui_internal.h>
|
|
||||||
#include <wx/event.h>
|
#include <wx/event.h>
|
||||||
|
|
||||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||||
#include "slic3r/GUI/KeyChord.hpp"
|
|
||||||
|
|
||||||
using Catch::Matchers::WithinAbs;
|
using Catch::Matchers::WithinAbs;
|
||||||
using Slic3r::GUI::ImGuiWrapper;
|
using Slic3r::GUI::ImGuiWrapper;
|
||||||
using Slic3r::GUI::KeyChord;
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -45,45 +41,6 @@ float wheel_after(int rotation, wxMouseWheelAxis axis)
|
|||||||
return ImGui::GetIO().MouseWheel;
|
return ImGui::GetIO().MouseWheel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
|
|
||||||
struct ImGuiFrames
|
|
||||||
{
|
|
||||||
ImGuiWrapper imgui;
|
|
||||||
|
|
||||||
ImGuiFrames()
|
|
||||||
{
|
|
||||||
imgui.set_display_size(800.f, 600.f);
|
|
||||||
unsigned char* pixels = nullptr;
|
|
||||||
int width = 0, height = 0;
|
|
||||||
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
|
|
||||||
}
|
|
||||||
|
|
||||||
template<class Draw> void frame(Draw draw)
|
|
||||||
{
|
|
||||||
ImGui::NewFrame();
|
|
||||||
draw();
|
|
||||||
ImGui::EndFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
|
|
||||||
void draw_input(bool focus)
|
|
||||||
{
|
|
||||||
ImGui::Begin("window");
|
|
||||||
if (focus)
|
|
||||||
ImGui::SetKeyboardFocusHere();
|
|
||||||
ImGui::InputText("##text", text, sizeof(text));
|
|
||||||
ImGui::End();
|
|
||||||
}
|
|
||||||
|
|
||||||
char text[16] = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
void draw_modal()
|
|
||||||
{
|
|
||||||
if (ImGui::BeginPopupModal("modal"))
|
|
||||||
ImGui::EndPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
||||||
@@ -102,62 +59,3 @@ TEST_CASE("a wheel event with no rotation or on the horizontal axis does not ste
|
|||||||
}));
|
}));
|
||||||
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
|
|
||||||
{
|
|
||||||
ImGuiFrames f;
|
|
||||||
f.frame([&] { f.draw_input(false); });
|
|
||||||
CHECK_FALSE(f.imgui.text_input_active());
|
|
||||||
|
|
||||||
f.frame([&] { f.draw_input(true); });
|
|
||||||
f.frame([&] { f.draw_input(false); });
|
|
||||||
CHECK(f.imgui.text_input_active());
|
|
||||||
|
|
||||||
f.frame([] {});
|
|
||||||
f.frame([] {});
|
|
||||||
CHECK_FALSE(f.imgui.text_input_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
|
|
||||||
{
|
|
||||||
ImGuiFrames f;
|
|
||||||
CHECK_FALSE(f.imgui.modal_popup_open());
|
|
||||||
|
|
||||||
f.frame([] {
|
|
||||||
ImGui::OpenPopup("modal");
|
|
||||||
draw_modal();
|
|
||||||
});
|
|
||||||
CHECK(f.imgui.modal_popup_open());
|
|
||||||
|
|
||||||
// A frame opened by input, before anything is drawn in it.
|
|
||||||
ImGui::NewFrame();
|
|
||||||
CHECK(f.imgui.modal_popup_open());
|
|
||||||
draw_modal();
|
|
||||||
ImGui::EndFrame();
|
|
||||||
CHECK(f.imgui.modal_popup_open());
|
|
||||||
|
|
||||||
// A frame that no longer draws it, as when its canvas is hidden.
|
|
||||||
f.frame([] {});
|
|
||||||
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
|
|
||||||
CHECK_FALSE(f.imgui.modal_popup_open());
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
|
|
||||||
{
|
|
||||||
ImGuiFrames f;
|
|
||||||
const KeyChord letter{ 'A' };
|
|
||||||
const KeyChord save{ 'S', wxMOD_CONTROL };
|
|
||||||
CHECK_FALSE(f.imgui.owns_key(letter));
|
|
||||||
CHECK_FALSE(f.imgui.owns_key(save));
|
|
||||||
|
|
||||||
f.frame([&] { f.draw_input(true); });
|
|
||||||
f.frame([&] { f.draw_input(false); });
|
|
||||||
CHECK(f.imgui.owns_key(letter));
|
|
||||||
CHECK_FALSE(f.imgui.owns_key(save));
|
|
||||||
|
|
||||||
f.frame([] {
|
|
||||||
ImGui::OpenPopup("modal");
|
|
||||||
draw_modal();
|
|
||||||
});
|
|
||||||
CHECK(f.imgui.owns_key(save));
|
|
||||||
}
|
|
||||||
@@ -126,55 +126,6 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
|
|||||||
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
|
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
|
|
||||||
{
|
|
||||||
CHECK(KeyChord{ 'A' }.edits_text());
|
|
||||||
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_SPACE }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_BACK }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_DELETE }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_HOME }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_INSERT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_RETURN }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
|
|
||||||
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
|
|
||||||
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
|
||||||
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{}.edits_text());
|
|
||||||
#ifdef __APPLE__
|
|
||||||
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
|
|
||||||
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
|
|
||||||
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
|
||||||
#else
|
|
||||||
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
|
|
||||||
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
|
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
|
||||||
{
|
{
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -235,21 +186,6 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
|
|
||||||
{
|
|
||||||
ShortcutRegistry registry;
|
|
||||||
size_t checked = 0;
|
|
||||||
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
|
|
||||||
const ShortcutInfo& info = shortcut_info(Shortcut(i));
|
|
||||||
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
|
|
||||||
continue;
|
|
||||||
CAPTURE(info.key);
|
|
||||||
CHECK_FALSE(registry.binding(info.id).edits_text());
|
|
||||||
++checked;
|
|
||||||
}
|
|
||||||
CHECK(checked > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
|
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
|
||||||
{
|
{
|
||||||
ShortcutRegistry registry;
|
ShortcutRegistry registry;
|
||||||
|
|||||||
Reference in new issue
Block a user