bootstrap-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bootstrap_ui/layout.py ADDED
@@ -0,0 +1,260 @@
1
+ """页面与栅格布局组件:Page / Container / Row / Col / Stack / Hr。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, ClassVar, Literal
7
+
8
+ from jinja2 import Environment, FileSystemLoader
9
+ from pydantic import Field
10
+
11
+ from .types import Node, NodeLike, escape, render_children
12
+
13
+ __all__ = ["Page", "Container", "Row", "Col", "Stack", "Hr", "Box", "Fragment"]
14
+
15
+ _TEMPLATE_DIR = Path(__file__).parent / "templates"
16
+
17
+ _env = Environment(
18
+ loader=FileSystemLoader(_TEMPLATE_DIR),
19
+ autoescape=False, # 内容在 Python 侧已经渲染并转义过,这里不能二次转义
20
+ trim_blocks=True,
21
+ lstrip_blocks=True,
22
+ )
23
+
24
+
25
+ class Page(Node):
26
+ """完整的 HTML 页面(``<html>`` 文档),是渲染的入口。"""
27
+
28
+ TAG: ClassVar[str | None] = None
29
+ __default_template_path__: ClassVar[str] = "page.jinja2"
30
+
31
+ title: str = "Bootstrap"
32
+ """浏览器标题"""
33
+
34
+ lang: str = "zh-CN"
35
+ """``<html lang>``"""
36
+
37
+ body: list[NodeLike] = Field(default_factory=list)
38
+ """页面主体内容"""
39
+
40
+ navbar: NodeLike | None = None
41
+ """顶部导航栏(一般传 :class:`~bootstrap.nav.Navbar`)"""
42
+
43
+ footer: NodeLike | None = None
44
+ """页脚内容"""
45
+
46
+ toolbar: NodeLike | None = None
47
+ """主体内容上方的工具条(标题 + 按钮之类)"""
48
+
49
+ container: bool = True
50
+ """是否用容器包裹主体内容"""
51
+
52
+ fluid: bool = False
53
+ """容器是否全宽"""
54
+
55
+ body_class: str | None = None
56
+ """``<body>`` 的 class"""
57
+
58
+ theme: Literal["light", "dark", "auto"] | None = None
59
+ """Bootstrap 5.3 的 ``data-bs-theme``,用于内置暗色模式"""
60
+
61
+ version: str = "5.3.3"
62
+ """Bootstrap 版本号"""
63
+
64
+ cdn: str = "https://cdn.jsdelivr.net/npm"
65
+ """资源 CDN 前缀"""
66
+
67
+ icons: bool = True
68
+ """是否引入 bootstrap-icons"""
69
+
70
+ icons_version: str = "1.11.3"
71
+ """bootstrap-icons 版本号"""
72
+
73
+ css: list[str] = Field(default_factory=list)
74
+ """额外样式表地址"""
75
+
76
+ js: list[str] = Field(default_factory=list)
77
+ """额外脚本地址"""
78
+
79
+ custom_css: str | None = None
80
+ """内联样式"""
81
+
82
+ custom_js: str | None = None
83
+ """内联脚本(在 Bootstrap bundle 之后执行)"""
84
+
85
+ head: str | None = None
86
+ """原样插入 ``<head>`` 末尾的内容"""
87
+
88
+ def render(self, template_name: str = "") -> str:
89
+ """用 jinja2 模板渲染完整页面。"""
90
+ template = _env.get_template(template_name or self.__default_template_path__)
91
+ return template.render(
92
+ lang=escape(self.lang),
93
+ theme=self.theme,
94
+ title=escape(self.title),
95
+ icons=self.icons,
96
+ icons_version=self.icons_version,
97
+ cdn=self.cdn.rstrip("/"),
98
+ version=self.version,
99
+ css=self.css,
100
+ page_css=self.custom_css or "",
101
+ body_class=escape(self.body_class or ""),
102
+ navbar=self.navbar.render() if isinstance(self.navbar, Node) else "",
103
+ toolbar=render_children(self.toolbar, "\n") if self.toolbar is not None else "",
104
+ body=render_children(self.body, "\n"),
105
+ footer=self.footer.render() if isinstance(self.footer, Node) else "",
106
+ js=self.js,
107
+ page_js=self.custom_js or "",
108
+ container=self.container,
109
+ container_class="container-fluid" if self.fluid else "container",
110
+ head=self.head or "",
111
+ )
112
+
113
+ def save(self, path: str | Path, encoding: str = "utf-8") -> Path:
114
+ """把渲染结果写入文件。"""
115
+ target = Path(path)
116
+ target.write_text(self.render(), encoding=encoding)
117
+ return target
118
+
119
+
120
+ class Container(Node):
121
+ """固定宽度(或响应式)的内容容器。"""
122
+
123
+ fluid: bool | Literal["sm", "md", "lg", "xl", "xxl"] = False
124
+ """``True`` 全宽;字符串表示 ``container-{断点}``"""
125
+
126
+ children: list[NodeLike] = Field(default_factory=list)
127
+
128
+ def css_classes(self) -> list[str]:
129
+ if self.fluid is True:
130
+ return ["container-fluid"]
131
+ if isinstance(self.fluid, str):
132
+ return [f"container-{self.fluid}"]
133
+ return ["container"]
134
+
135
+ def render_content(self) -> str:
136
+ return render_children(self.children, "\n")
137
+
138
+
139
+ class Row(Node):
140
+ """栅格行(``.row``)。"""
141
+
142
+ children: list[NodeLike] = Field(default_factory=list)
143
+
144
+ gutter: int | str | None = None
145
+ """列间距,渲染为 ``g-{gutter}``,如 3"""
146
+
147
+ align: Literal["start", "center", "end", "baseline", "stretch"] | None = None
148
+ """垂直对齐(align-items-*)"""
149
+
150
+ justify: Literal["start", "center", "end", "between", "around", "evenly"] | None = None
151
+ """水平分布(justify-content-*)"""
152
+
153
+ cols: int | str | None = None
154
+ """每行固定列数,渲染为 ``row-cols-{cols}``"""
155
+
156
+ def css_classes(self) -> list[str]:
157
+ classes = ["row"]
158
+ if self.gutter is not None:
159
+ classes.append(f"g-{self.gutter}")
160
+ if self.align:
161
+ classes.append(f"align-items-{self.align}")
162
+ if self.justify:
163
+ classes.append(f"justify-content-{self.justify}")
164
+ if self.cols is not None:
165
+ classes.append(f"row-cols-{self.cols}")
166
+ return classes
167
+
168
+ def render_content(self) -> str:
169
+ return render_children(self.children, "\n")
170
+
171
+
172
+ class Col(Node):
173
+ """栅格列(``.col``)。"""
174
+
175
+ body: list[NodeLike] = Field(default_factory=list)
176
+
177
+ width: int | str | None = None
178
+ """默认宽度,1-12 或 ``auto``"""
179
+
180
+ xs: int | str | None = None
181
+ sm: int | str | None = None
182
+ md: int | str | None = None
183
+ lg: int | str | None = None
184
+ xl: int | str | None = None
185
+ xxl: int | str | None = None
186
+ """各断点下的宽度"""
187
+
188
+ offset: int | None = None
189
+ """左侧偏移列数"""
190
+
191
+ align_self: Literal["start", "center", "end", "baseline", "stretch"] | None = None
192
+ """列自身在行内的垂直对齐"""
193
+
194
+ def css_classes(self) -> list[str]:
195
+ classes: list[str] = []
196
+ if self.width is not None:
197
+ classes.append(f"col-{self.width}")
198
+ for breakpoint in ("xs", "sm", "md", "lg", "xl", "xxl"):
199
+ item = getattr(self, breakpoint)
200
+ if item is not None:
201
+ classes.append(f"col-{breakpoint}-{item}")
202
+ if self.offset is not None:
203
+ classes.append(f"offset-{self.offset}")
204
+ if self.align_self:
205
+ classes.append(f"align-self-{self.align_self}")
206
+ if not classes:
207
+ classes.append("col")
208
+ return classes
209
+
210
+ def render_content(self) -> str:
211
+ return render_children(self.body, "\n")
212
+
213
+
214
+ class Stack(Node):
215
+ """垂直 / 水平堆叠(``.vstack`` / ``.hstack``)。"""
216
+
217
+ direction: Literal["vertical", "horizontal"] = "vertical"
218
+
219
+ gap: int | str | None = 3
220
+ """间距,渲染为 ``gap-{gap}``"""
221
+
222
+ children: list[NodeLike] = Field(default_factory=list)
223
+
224
+ def css_classes(self) -> list[str]:
225
+ classes = ["vstack" if self.direction == "vertical" else "hstack"]
226
+ if self.gap is not None:
227
+ classes.append(f"gap-{self.gap}")
228
+ return classes
229
+
230
+ def render_content(self) -> str:
231
+ return render_children(self.children, "\n")
232
+
233
+
234
+ class Hr(Node):
235
+ """分割线。"""
236
+
237
+ TAG: ClassVar[str | None] = "hr"
238
+
239
+ def css_classes(self) -> list[str]:
240
+ return ["my-4"]
241
+
242
+
243
+ class Box(Node):
244
+ """通用 div 容器,用来套用工具类或行内样式。"""
245
+
246
+ children: list[NodeLike] = Field(default_factory=list)
247
+
248
+ def render_content(self) -> str:
249
+ return render_children(self.children, "\n")
250
+
251
+
252
+ class Fragment(Node):
253
+ """不产生包裹标签的片段,用于把多个节点当成一个整体传入。"""
254
+
255
+ TAG: ClassVar[str | None] = None
256
+
257
+ children: list[NodeLike] = Field(default_factory=list)
258
+
259
+ def render(self) -> str:
260
+ return render_children(self.children, "\n")
bootstrap_ui/nav.py ADDED
@@ -0,0 +1,356 @@
1
+ """导航类组件:Navbar / Nav / NavItem / Dropdown / Pagination / Breadcrumb。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar, Literal
6
+ from uuid import uuid4
7
+
8
+ from pydantic import Field
9
+
10
+ from .constants import Color, Size, value as enum_value
11
+ from .content import icon_name
12
+ from .types import Node, NodeLike, escape, render_attrs, render_children
13
+
14
+ __all__ = [
15
+ "Navbar",
16
+ "Nav",
17
+ "NavItem",
18
+ "Dropdown",
19
+ "DropdownItem",
20
+ "Pagination",
21
+ "Breadcrumb",
22
+ "BreadcrumbItem",
23
+ ]
24
+
25
+
26
+ class NavItem(Node):
27
+ """导航项,渲染为 ``<li class="nav-item"><a class="nav-link">``。"""
28
+
29
+ TAG: ClassVar[str | None] = None
30
+
31
+ text: str = ""
32
+ href: str = "#"
33
+ icon: str | None = None
34
+ active: bool = False
35
+ disabled: bool = False
36
+
37
+ def render(self) -> str:
38
+ classes = ["nav-link"]
39
+ if self.active:
40
+ classes.append("active")
41
+ if self.disabled:
42
+ classes.append("disabled")
43
+
44
+ attrs: dict[str, Any] = {"class": " ".join(classes), "href": self.href}
45
+ if self.active:
46
+ attrs["aria-current"] = "page"
47
+ if self.disabled:
48
+ attrs["tabindex"] = "-1"
49
+ attrs["aria-disabled"] = "true"
50
+ if self.id:
51
+ attrs["id"] = self.id
52
+
53
+ icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
54
+ return f'<li class="nav-item"><a{render_attrs(attrs)}>{icon}{escape(self.text)}</a></li>'
55
+
56
+
57
+ class DropdownItem(Node):
58
+ """下拉菜单项,``divider=True`` 时渲染为分割线。"""
59
+
60
+ TAG: ClassVar[str | None] = None
61
+
62
+ text: str = ""
63
+ href: str = "#"
64
+ active: bool = False
65
+ disabled: bool = False
66
+ divider: bool = False
67
+ icon: str | None = None
68
+
69
+ def render(self) -> str:
70
+ if self.divider:
71
+ return '<li><hr class="dropdown-divider"></li>'
72
+
73
+ classes = ["dropdown-item"]
74
+ if self.active:
75
+ classes.append("active")
76
+ if self.disabled:
77
+ classes.append("disabled")
78
+
79
+ attrs: dict[str, Any] = {"class": " ".join(classes), "href": self.href}
80
+ if self.disabled:
81
+ attrs["aria-disabled"] = "true"
82
+ attrs["tabindex"] = "-1"
83
+
84
+ icon = f'<i class="{icon_name(self.icon)} me-2"></i>' if self.icon else ""
85
+ return f'<li><a{render_attrs(attrs)}>{icon}{escape(self.text)}</a></li>'
86
+
87
+
88
+ class Dropdown(Node):
89
+ """下拉菜单。默认渲染为导航栏中的一项(``<li>`` 包裹)。"""
90
+
91
+ TAG: ClassVar[str | None] = None
92
+
93
+ text: str = ""
94
+ items: list[DropdownItem | str] = Field(default_factory=list)
95
+ color: Color = Color.primary
96
+ """``as_button=True`` 时的按钮颜色"""
97
+
98
+ as_button: bool = False
99
+ """``True`` 时渲染为按钮样式的下拉菜单"""
100
+
101
+ menu_end: bool = True
102
+ dark: bool = False
103
+ as_nav_item: bool = True
104
+
105
+ def render(self) -> str:
106
+ toggle_classes = ["dropdown-toggle"]
107
+ if self.as_button:
108
+ toggle_classes = ["btn", f"btn-{enum_value(self.color)}", "dropdown-toggle"]
109
+ else:
110
+ toggle_classes.insert(0, "nav-link")
111
+
112
+ menu_classes = ["dropdown-menu", "dropdown-menu-dark" if self.dark else "dropdown-menu-end" if self.menu_end else ""]
113
+ menu_class = " ".join(cls for cls in menu_classes if cls)
114
+
115
+ toggle = (
116
+ f'<a class="{" ".join(toggle_classes)}" href="#" role="button" '
117
+ f'data-bs-toggle="dropdown" aria-expanded="false">{escape(self.text)}</a>'
118
+ )
119
+ items = "".join(
120
+ item.render() if isinstance(item, DropdownItem) else DropdownItem(text=str(item)).render()
121
+ for item in self.items
122
+ )
123
+ menu = f'<ul class="{menu_class}">{items}</ul>'
124
+
125
+ if self.as_nav_item:
126
+ return f'<li class="nav-item dropdown">{toggle}{menu}</li>'
127
+ return f'<div class="dropdown">{toggle}{menu}</div>'
128
+
129
+
130
+ class Nav(Node):
131
+ """独立导航(``.nav``),可用于侧边栏或标签式导航。"""
132
+
133
+ TAG: ClassVar[str | None] = "ul"
134
+
135
+ items: list[NavItem | Dropdown | str] = Field(default_factory=list)
136
+ vertical: bool = False
137
+ pills: bool = False
138
+ fill: bool = False
139
+ justified: bool = False
140
+
141
+ def css_classes(self) -> list[str]:
142
+ classes = ["nav"]
143
+ if self.vertical:
144
+ classes.append("flex-column")
145
+ classes.append("nav-pills" if self.pills else "nav-tabs")
146
+ if self.fill:
147
+ classes.append("nav-fill")
148
+ if self.justified:
149
+ classes.append("nav-justified")
150
+ return classes
151
+
152
+ def render_content(self) -> str:
153
+ return render_children(self.items)
154
+
155
+
156
+ class Navbar(Node):
157
+ """顶部导航栏。"""
158
+
159
+ TAG: ClassVar[str | None] = "nav"
160
+
161
+ brand: str | None = None
162
+ brand_href: str = "#"
163
+ brand_icon: str | None = None
164
+
165
+ items: list[NavItem | Dropdown | str] = Field(default_factory=list)
166
+ """左侧导航项"""
167
+
168
+ actions: list[NodeLike] = Field(default_factory=list)
169
+ """右侧操作区(按钮、下拉菜单等)"""
170
+
171
+ color: Color | None = None
172
+ """``bg-{color}``,配合 ``dark=True`` 使用深色文字方案"""
173
+
174
+ dark: bool = False
175
+ expand: Literal["sm", "md", "lg", "xl", "xxl", False] = "lg"
176
+ sticky: bool = False
177
+ container: bool = True
178
+ fluid: bool = True
179
+ id: str = Field(default_factory=lambda: f"navbar-{uuid4().hex[:6]}")
180
+
181
+ def css_classes(self) -> list[str]:
182
+ classes = ["navbar"]
183
+ if self.color:
184
+ classes.append(f"bg-{enum_value(self.color)}")
185
+ else:
186
+ classes.append("bg-body-tertiary")
187
+ if self.dark or self.color in {Color.dark, Color.primary}:
188
+ classes.append("navbar-dark")
189
+ if self.sticky:
190
+ classes.append("sticky-top")
191
+ if self.expand:
192
+ classes.append(f"navbar-expand-{self.expand}")
193
+ else:
194
+ classes.append("navbar-expand")
195
+ return classes
196
+
197
+ def html_attrs(self) -> dict[str, Any]:
198
+ attrs = super().html_attrs()
199
+ attrs.setdefault("data-bs-theme", "dark" if (self.dark or self.color in {Color.dark, Color.primary}) else "light")
200
+ return attrs
201
+
202
+ def render_content(self) -> str:
203
+ collapse_id = f"{self.id}-nav"
204
+ brand_icon = f'<i class="{icon_name(self.brand_icon)} me-2"></i>' if self.brand_icon else ""
205
+ brand = (
206
+ f'<a class="navbar-brand" href="{escape(self.brand_href)}">{brand_icon}{escape(self.brand)}</a>'
207
+ if self.brand
208
+ else ""
209
+ )
210
+ toggler = (
211
+ '<button class="navbar-toggler" type="button" data-bs-toggle="collapse" '
212
+ f'data-bs-target="#{collapse_id}" aria-controls="{collapse_id}" '
213
+ 'aria-expanded="false" aria-label="切换导航"><span class="navbar-toggler-icon"></span></button>'
214
+ )
215
+ items = render_children(self.items)
216
+ actions = render_children(self.actions)
217
+ actions_html = f'<div class="d-flex align-items-center gap-2">{actions}</div>' if actions else ""
218
+
219
+ inner = (
220
+ f"{brand}{toggler}"
221
+ f'<div class="collapse navbar-collapse" id="{collapse_id}">'
222
+ f'<ul class="navbar-nav me-auto mb-2 mb-lg-0">{items}</ul>'
223
+ f"{actions_html}</div>"
224
+ )
225
+ if self.container:
226
+ wrapper = "container-fluid" if self.fluid else "container"
227
+ inner = f'<div class="{wrapper}">{inner}</div>'
228
+ return inner
229
+
230
+
231
+ class BreadcrumbItem(Node):
232
+ """面包屑中的一项。"""
233
+
234
+ TAG: ClassVar[str | None] = None
235
+
236
+ text: str = ""
237
+ href: str | None = None
238
+ active: bool = False
239
+
240
+ def render(self) -> str:
241
+ classes = ["breadcrumb-item"]
242
+ if self.active:
243
+ classes.append("active")
244
+ if self.href and not self.active:
245
+ body = f'<a href="{escape(self.href)}">{escape(self.text)}</a>'
246
+ else:
247
+ body = escape(self.text)
248
+ return f'<li class="{" ".join(classes)}">{body}</li>'
249
+
250
+
251
+ class Breadcrumb(Node):
252
+ """面包屑导航。"""
253
+
254
+ TAG: ClassVar[str | None] = "nav"
255
+
256
+ items: list[BreadcrumbItem | str] = Field(default_factory=list)
257
+
258
+ def html_attrs(self) -> dict[str, Any]:
259
+ attrs = super().html_attrs()
260
+ attrs.setdefault("aria-label", "breadcrumb")
261
+ return attrs
262
+
263
+ def render_content(self) -> str:
264
+ nodes: list[str] = []
265
+ for index, item in enumerate(self.items):
266
+ if isinstance(item, BreadcrumbItem):
267
+ node = item
268
+ else:
269
+ node = BreadcrumbItem(text=str(item))
270
+ node.active = node.active or index == len(self.items) - 1
271
+ nodes.append(node.render())
272
+ return f'<ol class="breadcrumb mb-0">{"".join(nodes)}</ol>'
273
+
274
+
275
+ class Pagination(Node):
276
+ """分页导航。"""
277
+
278
+ TAG: ClassVar[str | None] = "nav"
279
+
280
+ current: int = 1
281
+ total: int = 1
282
+ href: str = "?page={page}"
283
+ """页码链接模板,``{page}`` 会被替换"""
284
+
285
+ size: Size | None = None
286
+ max_buttons: int = 7
287
+ show_edges: bool = True
288
+ align: Literal["start", "center", "end"] = "center"
289
+
290
+ def html_attrs(self) -> dict[str, Any]:
291
+ attrs = super().html_attrs()
292
+ attrs.setdefault("aria-label", "分页")
293
+ return attrs
294
+
295
+ def _page_item(self, page: int, label: str, *, active: bool = False, disabled: bool = False) -> str:
296
+ classes = ["page-item"]
297
+ if active:
298
+ classes.append("active")
299
+ if disabled:
300
+ classes.append("disabled")
301
+
302
+ if active:
303
+ inner = f'<span class="page-link">{escape(label)}</span>'
304
+ elif disabled:
305
+ inner = f'<span class="page-link">{escape(label)}</span>'
306
+ else:
307
+ inner = f'<a class="page-link" href="{escape(self.href.format(page=page))}">{escape(label)}</a>'
308
+ return f'<li class="{" ".join(classes)}">{inner}</li>'
309
+
310
+ def _window(self) -> list[int | None]:
311
+ total = max(self.total, 1)
312
+ current = min(max(self.current, 1), total)
313
+ limit = max(self.max_buttons, 5)
314
+ if total <= limit:
315
+ return list(range(1, total + 1))
316
+
317
+ half = (limit - 3) // 2
318
+ start = max(2, current - half)
319
+ end = min(total - 1, start + (limit - 3) - 1)
320
+ start = max(2, end - (limit - 3) + 1)
321
+
322
+ pages: list[int | None] = [1]
323
+ if start > 2:
324
+ pages.append(None)
325
+ pages.extend(range(start, end + 1))
326
+ if end < total - 1:
327
+ pages.append(None)
328
+ pages.append(total)
329
+ return pages
330
+
331
+ def render_content(self) -> str:
332
+ total = max(self.total, 1)
333
+ current = min(max(self.current, 1), total)
334
+ parts: list[str] = []
335
+
336
+ if self.show_edges:
337
+ parts.append(self._page_item(1, "«", disabled=current == 1))
338
+ parts.append(self._page_item(current - 1, "‹", disabled=current == 1))
339
+
340
+ for page in self._window():
341
+ if page is None:
342
+ parts.append('<li class="page-item disabled"><span class="page-link">…</span></li>')
343
+ else:
344
+ parts.append(self._page_item(page, str(page), active=page == current))
345
+
346
+ parts.append(self._page_item(current + 1, "›", disabled=current == total))
347
+ if self.show_edges:
348
+ parts.append(self._page_item(total, "»", disabled=current == total))
349
+
350
+ size_class = f" pagination-{enum_value(self.size)}" if self.size else ""
351
+ justify = {
352
+ "start": "justify-content-start",
353
+ "center": "justify-content-center",
354
+ "end": "justify-content-end",
355
+ }[self.align]
356
+ return f'<ul class="pagination {justify}{size_class}">{"".join(parts)}</ul>'