"""Build portable WPF skills from this checkout; no network or third-party modules.""" import argparse import json import re from pathlib import Path from xml.etree import ElementTree as ET ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / 'skills' LIB = ROOT / 'src/Shared/HandyControl_Shared' DOC = ROOT / 'doc/source/handycontrol' REMOTE = 'https://github.com/HandyOrg/HandyControl/blob/master/' CLASS = re.compile(r'public\s+(?:(?:abstract|static|partial|sealed)\s+)*class\s+(\w+)(?:\s*:\s*([^\r\n{]+))?') def read(path): return path.read_text(encoding='utf-8-sig') def slug(name): return re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '-', name).lower() def link(path): rel = path.relative_to(ROOT).as_posix() return f'[{rel}]({REMOTE}{rel})' def source_index(): result = {} for path in sorted((LIB / 'Controls').rglob('*.cs')): for match in CLASS.finditer(read(path)): result.setdefault(match[1], []).append((path, (match[2] or '').strip())) return result def short_example(text, names, index): """Keep only self-contained, XML-parseable examples without Demo dependencies.""" candidates = [] for match in re.finditer(r'```\s*(?:xml|xaml)\s*\n(.*?)```', text, re.S | re.I): snippet = match[1].strip() if len(snippet.splitlines()) > 16 or not any(f'' + snippet + '') except ET.ParseError: continue types = [e.tag.split('}')[1].split('.')[0] for e in element.iter() if e.tag.startswith('{https://handyorg.github.io/handycontrol}')] if not all(n in index for n in types): continue candidates.append(snippet) return ('xml', min(candidates, key=len)) if candidates else None def api_reference(names, index): sections = ['# 源码 API 导航\n', '下面是当前工作区的声明摘录,不是完整 C# 文件,不包含全部继承成员、访问器和条件编译条件。' '属性是否可写、依赖属性默认值、枚举成员和目标框架限制,需打开对应源码核对。\n'] visited = set() def append(name): if name in visited or name not in index: return visited.add(name) for path, base in index[name]: text = read(path) declarations = [] for line in text.splitlines(): line = line.strip() if not line.startswith('public ') or 'DependencyProperty ' in line or 'RoutedEvent ' in line: continue # Declaration headers only: never present extracted implementation as compilable code. line = re.split(r'\s*=>|\s*\{', line, maxsplit=1)[0] declarations.append(line) sections.extend([f'## {name}\n', f'源码:{link(path)}\n', '```text\n' + '\n'.join(dict.fromkeys(declarations)) + '\n```\n']) parent = base.split(',')[0].strip() if parent in index: append(parent) for name in names: append(name) return '\n'.join(sections) SETUP = '''程序集/NuGet 为 `HandyControl`,XAML 命名空间为 `xmlns:hc="https://handyorg.github.io/handycontrol"`,C# 控件命名空间为 `HandyControl.Controls`。 在 `App.xaml` 合并 `pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml`, 然后合并 `pack://application:,,,/HandyControl;component/Themes/Theme.xaml`。 已有主题配置时先检查,避免重复合并。版本和目标框架以使用方项目为准。 ''' NOTES = { 'numericUpDown': 'Value 是 double;范围属性是 Minimum / Maximum,步长是 Increment,不能照搬 Syncfusion 的 MinValue / MaxValue / Step。DecimalPlaces 为 int?,仅控制显示。ValueChanged 的事件参数使用 FunctionEventArgs,读取 Info;绑定时显式指定 Mode=TwoWay。', 'dialog': 'Dialog.Show 返回覆盖层 Dialog,不等于 Window.ShowDialog。无 token 时依赖活动窗口的 AdornerDecorator;指定 token 时先配置 Dialog.Token 和 DialogContainer。关闭使用返回实例的 Close()。异步结果需要实现库要求的结果接口并引入扩展命名空间,不能直接假定存在任意 Task 返回值。', 'growl': 'Growl 是通知 API 与控件的组合。页面内通知需要 Panel 上的 Growl.GrowlParent 或 Growl.Token;多窗口使用唯一 token。普通通知与 Global 方法的显示范围不同。后台任务更新 UI 先切回 Dispatcher。', 'grid': '这里的栅格是 hc:Row 与 hc:Col,采用 24 列;不是 hc:Grid。布局使用 Span、Offset、Gutter 等当前源码属性。', 'loading': 'LoadingBase 是抽象基类,实际使用 LoadingLine 或 LoadingCircle;耗时任务异步执行,避免阻塞动画线程。', 'window': 'XAML 根元素改为 hc:Window 时,code-behind 的基类也必须是 HandyControl.Controls.Window。与 System.Windows.Window 同名,C# 使用别名或全名。', 'textBox': 'hc:TextBox 与原生 TextBox 的样式方案不同。先确定使用的类型,再设置标题、占位和验证属性;不要假定 WPF PasswordBox 或其他输入框都支持 Text 绑定。', 'passwordBox': '区分 HandyControl.Controls.PasswordBox 与原生 System.Windows.Controls.PasswordBox;核对 Password 的属性定义和绑定机制,不在日志中输出密码。', 'messageBox': '使用 HandyControl.Controls.MessageBox 全名或别名以避免与 System.Windows.MessageBox 混淆;按实际重载处理 MessageBoxResult。', 'pagination': '绑定页码与数据加载逻辑时检查页码基准、总数变化及边界,事件参数以源码声明为准。', 'propertyGrid': '先确认 SelectedObject 的实际类型及属性可读写性,再选择编辑器;自定义编辑器需要实现库内 PropertyEditorBase 约定。', } EXAMPLES = { 'numericUpDown': ('xml', ''), 'grid': ('xml', '\n \n \n'), 'loading': ('xml', ''), 'dialog': ('csharp', '// 在已显示且具备装饰层的 WPF 窗口的 UI 线程执行。\nvar dialog = HandyControl.Controls.Dialog.Show(new System.Windows.Controls.TextBlock\n{\n Text = "正在处理"\n});\n// 操作完成后关闭。\ndialog.Close();'), 'growl': ('xml', '\n'), } def skill_files(name, title, description, body, refs=None): files = { f'{name}/SKILL.md': f'---\nname: {name}\ndescription: {json.dumps(description, ensure_ascii=False)}\n---\n\n# {title}\n\n{body.strip()}\n', f'{name}/agents/openai.yaml': 'interface:\n' + f' display_name: {json.dumps(title, ensure_ascii=False)}\n' + ' short_description: "Implement HandyControl WPF controls from verified source guidance"\n' + f' default_prompt: "Use ${name} to implement the requested HandyControl WPF UI."\n', } for path, content in (refs or {}).items(): files[f'{name}/references/{path}'] = content return files def build(): index = source_index() files, rows = {}, [] for doc in sorted((DOC / 'extend_controls').glob('*/index.md')): text, key = read(doc), doc.parent.name title = re.search(r'^title:\s*(.+)$', text, re.M)[1].strip() names = list(dict.fromkeys(m[0] for m in CLASS.findall(text))) names = [n for n in names if n in index] if not names: names = [n for n in index if n.lower() == key.lower()] if not names: raise ValueError(f'No source mapping for {doc}') name = 'handycontrol-wpf-' + slug(key) doc_url = f'https://handyorg.github.io/handycontrol/extend_controls/{key}/' headings = [m[1].strip() for m in re.finditer(r'^#{1,3}\s+(.+)$', text, re.M)] demos = sorted(p for p in (ROOT / 'src/Shared/HandyControlDemo_Shared').rglob('*') if p.is_file() and any(p.name.lower() in (n.lower() + 'demo.xaml', n.lower() + 'demo.xaml.cs') for n in names)) styles = sorted(p for p in (LIB / 'Themes/Styles').glob('*.xaml') if p.stem.lower() in {n.lower() for n in names}) evidence = f'# {title} 使用导航\n\n文档:{link(doc)}\n\n在线文档:[控件说明]({doc_url})\n\n' evidence += '按任务阅读原文中的章节:' + '、'.join(headings) + '。\n\n' evidence += '## 当前源码\n\n' + '\n'.join('- ' + link(p) for n in names for p, _ in index[n]) + '\n\n' evidence += '## 样式及官方示例\n\n' + '\n'.join('- ' + link(p) for p in styles + demos) + '\n' body = f'''实现或排查 **{title}**。相关类型:{', '.join(f'`{n}`' for n in names)}。 ## 接入 {SETUP} ## 实现 先读 [API 声明与继承关系](references/api.md),再按需求查看 [文档、样式与示例导航](references/usage.md)。源码路径相对于 HandyControl 仓库根目录; 在本仓库优先读本地文件,安装到其他项目后使用内附声明与链接,遇到版本差异检查实际引用版本的源码。 不要将 Demo 的 langs、ex:Lang、ViewModelLocator 或示例资源直接复制进使用方项目。 {NOTES.get(key, '结合基类确认内容模型、绑定属性和事件参数;集合控件区分 ItemsSource、选中项与 ItemTemplate;修改样式前核对资源键和模板部件。')} 用目标项目现有的 MVVM 方式绑定;需要回写时明确 TwoWay 和更新时机, 不要假定所有依赖属性默认双向绑定。原生 WPF 与 HandyControl 同名类型使用命名空间区分。 交付时运行目标框架的构建,并在窗口加载后检查模板、绑定错误、空值和相关输入边界; 无法运行 WPF 时明确说明尚未验证 UI 行为。 ''' example_pair = EXAMPLES.get(key) or short_example(text, names, index) if example_pair: language, example = example_pair body += f'\n## 最小片段\n\n依赖上面的应用资源配置;Binding 路径由使用方 ViewModel 提供。\n\n```{language}\n{example}\n```\n' files.update(skill_files(name, 'HandyControl WPF ' + title, f'实现、配置或排查 HandyControl WPF 的 {title}({", ".join(names)})。适用于该扩展控件的 XAML、绑定、事件和模板;不用于 Avalonia 或其他厂商控件。', body, {'api.md': api_reference(names, index), 'usage.md': evidence})) rows.append((name, title, key, names)) catalog = '# 扩展控件索引\n\n每行对应可单独安装的 Skill。仅有原生样式需求时选择 handycontrol-wpf-native-styles。\n\n| Skill | 控件 | 类型 |\n|---|---|---|\n' catalog += '\n'.join(f'| `{n}` | [{t}](https://handyorg.github.io/handycontrol/extend_controls/{k}/) | {", ".join(types)} |' for n, t, k, types in rows) + '\n' setup = read(OUT / 'templates/getting-started.md') files.update(skill_files('handycontrol-wpf', 'HandyControl WPF', '在 WPF 项目中接入 HandyControl、选择控件、配置资源或排查 XAML。用于 HandyControl 使用方和本仓库控件开发,不用于 Avalonia 或 Syncfusion。', '''先检查使用方 csproj、App.xaml、现有 MVVM 约定与实际 HandyControl 版本。 新项目或资源缺失时读 [接入与验证](references/getting-started.md)。 选择扩展控件时读 [控件索引](references/controls.md),再调用对应已安装 Skill; 未安装时可根据索引中的类型与官方文档继续,不假定其他 Skill 必然存在。 原生控件外观与主题问题读 [样式和主题](references/styling.md)。 本仓库 WPF 共享实现位于 src/Shared/HandyControl_Shared,示例位于 src/Shared/HandyControlDemo_Shared,项目入口位于 src/Net_GE45 和 src/Net_40。 src/Avalonia 是独立实现,不能混用。修改库时先查依赖属性、对应样式、Demo,再改动。 不要直接修改自动合并的 Themes/Theme.xaml;检查 Theme_GE45.txt / Theme_40.txt 和样式源文件。 保留已有工作区修改。输出实际变更、运行方法及验证限制。 ''', {'getting-started.md': setup, 'controls.md': catalog, 'styling.md': read(OUT / 'templates/styling.md')})) for name, title, desc, template in [ ('handycontrol-wpf-theming', 'HandyControl WPF 主题', '配置 HandyControl WPF 的皮肤、资源字典、动态颜色和主题切换;排查资源键与控件样式,不用于 Avalonia。', 'styling.md'), ('handycontrol-wpf-native-styles', 'HandyControl WPF 原生控件样式', '为原生 WPF Button、TextBox、DataGrid、ListBox 等应用 HandyControl 样式和附加属性,区分原生与 hc 同名控件。', 'native-styles.md'), ]: files.update(skill_files(name, title, desc, SETUP + '\n按任务阅读 [配置及示例](references/usage.md)。先确认控件实际 CLR 类型和样式 TargetType,再修改资源。\n', {'usage.md': read(OUT / 'templates' / template)})) # Native styles are read from XML, so resource keys cannot drift silently. native = files['handycontrol-wpf-native-styles/references/usage.md'] for doc in sorted((DOC / 'native_controls').glob('*/index.md')): candidates = [p for p in (LIB / 'Themes/Styles').glob('*.xaml') if p.stem.lower() == doc.parent.name.lower()] for path in candidates: tree = ET.fromstring(read(path)) keys = [e.attrib['{http://schemas.microsoft.com/winfx/2006/xaml}Key'] for e in tree.iter() if e.tag.endswith('}Style') and '{http://schemas.microsoft.com/winfx/2006/xaml}Key' in e.attrib] native += f'\n## {doc.parent.name}\n\n{link(path)}\n\n显式样式键:' + '、'.join(f'`{k}`' for k in keys) + '\n' files['handycontrol-wpf-native-styles/references/usage.md'] = native files['catalog.json'] = json.dumps({'skills': sorted({p.split('/')[0] for p in files})}, indent=2) + '\n' return files def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--check', action='store_true', help='Check snapshots without writing') args = parser.parse_args() files = build() stale = [] for rel, content in files.items(): path = OUT / rel if args.check: if not path.exists() or read(path) != content: stale.append(rel) else: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding='utf-8', newline='\n') expected = {p.split('/')[0] for p in files if p.endswith('/SKILL.md')} stale.extend(str(p.parent.relative_to(OUT)) for p in OUT.glob('*/SKILL.md') if p.parent.name not in expected) if stale: raise SystemExit('Stale or unexpected skill files:\n' + '\n'.join(stale)) print(f'{"Checked" if args.check else "Generated"} {len(expected)} skills / {len(files)} files') if __name__ == '__main__': main()