| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625 |
- //
- // CVProfileDocumentView.swift
- // App for Indeed
- //
- // Renders saved profile data in a layout that follows the selected CV template’s
- // family (professional / modern / minimal / executive / creative), headline,
- // accent, section labels, and column structure.
- //
- import Cocoa
- /// Typography and chrome derived from `CVTemplate.family` so the filled résumé
- /// visibly matches the gallery card the user picked—not a single generic layout.
- private struct DocumentStyle {
- let nameFont: NSFont
- let roleFont: NSFont
- let contactFont: NSFont
- let sectionFont: NSFont
- let bodyFont: NSFont
- let bodyCompactFont: NSFont
- let expTitleFont: NSFont
- let expMetaFont: NSFont
- let eduTitleFont: NSFont
- let eduMetaFont: NSFont
- let bulletBodyFont: NSFont
- let bulletMarkerFont: NSFont
- let bulletMarkerColor: NSColor
- let ink: NSColor
- let muted: NSColor
- let rule: NSColor
- let cardBackground: NSColor
- let columnVerticalSpacing: CGFloat
- let bodyBlockSpacing: CGFloat
- /// When true, the headline job title uses the template theme color.
- let roleUsesThemeColor: Bool
- /// Section heading text color (often theme; executive stays conservative).
- let sectionInk: NSColor
- static func make(for template: CVTemplate) -> DocumentStyle {
- let theme = template.themeColor
- switch template.family {
- case .minimal:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 20, weight: .regular),
- roleFont: .systemFont(ofSize: 13.5, weight: .regular),
- contactFont: .systemFont(ofSize: 11.5, weight: .regular),
- sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
- bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
- expTitleFont: .systemFont(ofSize: 13, weight: .medium),
- expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
- eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
- eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
- bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
- bulletMarkerColor: theme.withAlphaComponent(0.55),
- ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
- muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
- rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
- cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
- columnVerticalSpacing: 15,
- bodyBlockSpacing: 15,
- roleUsesThemeColor: false,
- sectionInk: theme.withAlphaComponent(0.92)
- )
- case .professional:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 21, weight: .semibold),
- roleFont: .systemFont(ofSize: 13.5, weight: .medium),
- contactFont: .systemFont(ofSize: 11.5, weight: .regular),
- sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
- bodyFont: .systemFont(ofSize: 12, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
- expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
- expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
- eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
- eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
- bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
- bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
- ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
- muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
- rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
- cardBackground: NSColor.white,
- columnVerticalSpacing: 13,
- bodyBlockSpacing: 13,
- roleUsesThemeColor: false,
- sectionInk: theme
- )
- case .modern:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 22, weight: .bold),
- roleFont: .systemFont(ofSize: 14, weight: .semibold),
- contactFont: .systemFont(ofSize: 12, weight: .regular),
- sectionFont: .systemFont(ofSize: 11, weight: .heavy),
- bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
- expTitleFont: .systemFont(ofSize: 14, weight: .bold),
- expMetaFont: .systemFont(ofSize: 12, weight: .medium),
- eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
- eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
- bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
- bulletMarkerColor: theme,
- ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
- muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
- rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
- cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
- columnVerticalSpacing: 17,
- bodyBlockSpacing: 16,
- roleUsesThemeColor: true,
- sectionInk: theme
- )
- case .executive:
- let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
- let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
- let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
- let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
- let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
- let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
- let expMeta = NSFont(name: "Georgia-Italic", size: 12)
- ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
- let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
- ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
- return DocumentStyle(
- nameFont: serifName,
- roleFont: serifRole,
- contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
- sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
- bodyFont: serifBody,
- bodyCompactFont: serifCompact,
- expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
- expMetaFont: expMeta,
- eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
- eduMetaFont: eduMeta,
- bulletBodyFont: serifCompact,
- bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
- bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
- ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
- muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
- rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
- cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
- columnVerticalSpacing: 18,
- bodyBlockSpacing: 17,
- roleUsesThemeColor: false,
- sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
- )
- case .creative:
- return DocumentStyle(
- nameFont: .systemFont(ofSize: 23, weight: .heavy),
- roleFont: .systemFont(ofSize: 14, weight: .semibold),
- contactFont: .systemFont(ofSize: 11.5, weight: .medium),
- sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
- bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
- expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
- expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
- eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
- eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
- bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
- bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
- bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
- ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
- muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
- rule: theme.withAlphaComponent(0.22),
- cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
- columnVerticalSpacing: 18,
- bodyBlockSpacing: 17,
- roleUsesThemeColor: true,
- sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
- )
- }
- }
- }
- /// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
- final class CVProfileDocumentView: NSView {
- private let profile: SavedProfile
- private let template: CVTemplate
- private let style: DocumentStyle
- init(profile: SavedProfile, template: CVTemplate) {
- self.profile = profile
- self.template = template
- self.style = DocumentStyle.make(for: template)
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.backgroundColor = NSColor.clear.cgColor
- userInterfaceLayoutDirection = .leftToRight
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.backgroundColor = style.cardBackground.cgColor
- card.layer?.cornerRadius = template.family == .executive ? 6 : 10
- card.layer?.borderWidth = 1
- card.layer?.borderColor = style.rule.cgColor
- card.layer?.masksToBounds = true
- let root = buildRoot()
- root.translatesAutoresizingMaskIntoConstraints = false
- card.addSubview(root)
- addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: leadingAnchor),
- card.trailingAnchor.constraint(equalTo: trailingAnchor),
- card.topAnchor.constraint(equalTo: topAnchor),
- card.bottomAnchor.constraint(equalTo: bottomAnchor),
- card.widthAnchor.constraint(equalToConstant: 640),
- root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
- root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
- root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
- root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- // MARK: - Composition
- private func buildRoot() -> NSView {
- switch template.layout {
- case .singleColumn:
- return singleColumnLayout()
- case .twoColumn(let side, let tinted):
- return twoColumnLayout(sidebar: side, tinted: tinted)
- }
- }
- private func singleColumnLayout() -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.alignment = .leading
- v.spacing = style.columnVerticalSpacing + 3
- v.addArrangedSubview(headerBlock())
- v.addArrangedSubview(hairline())
- v.addArrangedSubview(bodyColumn(compact: false))
- return v
- }
- private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.alignment = .leading
- v.spacing = style.columnVerticalSpacing + 2
- v.addArrangedSubview(headerBlock())
- v.addArrangedSubview(hairline())
- let row = NSStackView()
- row.orientation = .horizontal
- row.alignment = .top
- row.spacing = template.family == .minimal ? 18 : 22
- let sidebarCol = sidebarColumn(tinted: tinted)
- let mainCol = bodyColumn(compact: true)
- if sidebar == .leading {
- row.addArrangedSubview(sidebarCol)
- row.addArrangedSubview(mainCol)
- } else {
- row.addArrangedSubview(mainCol)
- row.addArrangedSubview(sidebarCol)
- }
- sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
- v.addArrangedSubview(row)
- return v
- }
- private func headerBlock() -> NSView {
- let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
- let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
- let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
- let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
- let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
- let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
- let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
- let textCol = NSStackView(views: [name, role, contact])
- textCol.orientation = .vertical
- textCol.spacing = template.family == .professional ? 3 : 4
- textCol.alignment = .leading
- switch template.headline {
- case .centered:
- textCol.alignment = .centerX
- name.alignment = .center
- role.alignment = .center
- contact.alignment = .center
- let accent = headlineAccent()
- let stack = NSStackView(views: [textCol, accent])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .centerX
- return stack
- case .avatarStacked:
- textCol.alignment = .centerX
- name.alignment = .center
- role.alignment = .center
- contact.alignment = .center
- let accent = headlineAccent()
- let avatar = initialsBadge(for: nameText)
- let stack = NSStackView(views: [avatar, textCol, accent])
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .centerX
- return stack
- case .leftAligned, .leftWithInitials:
- let row = NSStackView()
- row.orientation = .horizontal
- row.spacing = 14
- row.alignment = .centerY
- row.addArrangedSubview(textCol)
- if template.headline == .leftWithInitials {
- row.addArrangedSubview(NSView())
- row.addArrangedSubview(initialsBadge(for: nameText))
- }
- let col = NSStackView(views: [row, headlineAccent()])
- col.orientation = .vertical
- col.spacing = 8
- col.alignment = .leading
- return col
- }
- }
- private func initialsBadge(for fullName: String) -> NSView {
- let initials = Self.initials(from: fullName)
- let t = NSTextField(labelWithString: initials)
- t.font = .systemFont(ofSize: 13, weight: .bold)
- t.textColor = template.themeColor
- t.alignment = .center
- t.translatesAutoresizingMaskIntoConstraints = false
- let wrap = NSView()
- wrap.translatesAutoresizingMaskIntoConstraints = false
- wrap.wantsLayer = true
- wrap.layer?.cornerRadius = 22
- wrap.layer?.borderWidth = 1.5
- wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
- wrap.addSubview(t)
- NSLayoutConstraint.activate([
- wrap.widthAnchor.constraint(equalToConstant: 44),
- wrap.heightAnchor.constraint(equalToConstant: 44),
- t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
- t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
- ])
- return wrap
- }
- private static func initials(from fullName: String) -> String {
- let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
- if parts.count >= 2 {
- let a = parts[0].prefix(1)
- let b = parts[1].prefix(1)
- return "\(a)\(b)".uppercased()
- }
- if let first = parts.first { return String(first.prefix(2)).uppercased() }
- return "CV"
- }
- private func headlineAccent() -> NSView {
- let bar = NSView()
- bar.translatesAutoresizingMaskIntoConstraints = false
- bar.wantsLayer = true
- switch template.accent {
- case .none:
- bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
- return bar
- case .redUnderline:
- bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
- bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
- bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
- return bar
- case .redBar:
- bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
- bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
- bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
- return bar
- case .blueBar:
- bar.layer?.backgroundColor = template.themeColor.cgColor
- bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
- bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
- return bar
- }
- }
- private func hairline() -> NSView {
- let v = NSView()
- v.translatesAutoresizingMaskIntoConstraints = false
- v.wantsLayer = true
- v.layer?.backgroundColor = style.rule.cgColor
- let h: CGFloat = template.family == .executive ? 1.5 : 1
- v.heightAnchor.constraint(equalToConstant: h).isActive = true
- return v
- }
- private func sidebarColumn(tinted: Bool) -> NSView {
- let box = NSStackView()
- box.orientation = .vertical
- box.spacing = 12
- box.alignment = .leading
- if tinted {
- box.wantsLayer = true
- box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
- box.layer?.cornerRadius = 8
- }
- box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
- box.addArrangedSubview(sectionHeading("Contact"))
- for line in contactLines() {
- box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
- }
- if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
- box.addArrangedSubview(skillsBlock)
- }
- return box
- }
- private func bodyColumn(compact: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
- v.alignment = .leading
- if let summary = nonEmpty(profile.careerSummary) {
- v.addArrangedSubview(sectionHeading("Summary"))
- v.addArrangedSubview(paragraph(summary, compact: compact))
- }
- let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
- if !jobs.isEmpty {
- v.addArrangedSubview(sectionHeading("Experience"))
- for job in jobs {
- v.addArrangedSubview(experienceBlock(job: job, compact: compact))
- }
- }
- let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
- if !schools.isEmpty {
- v.addArrangedSubview(sectionHeading("Education"))
- for edu in schools {
- v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
- }
- }
- if let cert = nonEmpty(profile.certificates) {
- v.addArrangedSubview(sectionHeading("Certificates"))
- v.addArrangedSubview(paragraph(cert, compact: compact))
- }
- if let interests = nonEmpty(profile.interests) {
- v.addArrangedSubview(sectionHeading("Interests"))
- v.addArrangedSubview(paragraph(interests, compact: compact))
- }
- if let ref = nonEmpty(profile.referral) {
- v.addArrangedSubview(sectionHeading("Referrals"))
- v.addArrangedSubview(paragraph(ref, compact: compact))
- }
- return v
- }
- private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
- guard let body, !body.isEmpty else { return nil }
- let s = NSStackView()
- s.orientation = .vertical
- s.spacing = 6
- s.alignment = .leading
- s.addArrangedSubview(sectionHeading(title))
- s.addArrangedSubview(paragraph(body, compact: true))
- return s
- }
- private func contactLines() -> [String] {
- var lines: [String] = []
- let p = profile.personal
- if !p.email.isEmpty { lines.append(p.email) }
- if !p.phone.isEmpty { lines.append(p.phone) }
- if !p.address.isEmpty { lines.append(p.address) }
- return lines.isEmpty ? ["—"] : lines
- }
- private func combinedAncillaryText() -> String? {
- let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
- return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
- }
- private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
- let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
- let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = template.family == .professional ? 4 : 6
- v.alignment = .leading
- if !titleLine.isEmpty {
- v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
- }
- if !meta.isEmpty {
- v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
- }
- for bullet in Self.bulletChunks(from: job.description) {
- v.addArrangedSubview(bulletRow(bullet, compact: compact))
- }
- return v
- }
- private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
- let v = NSStackView()
- v.orientation = .vertical
- v.spacing = 4
- v.alignment = .leading
- let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
- if !head.isEmpty {
- v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
- }
- if !edu.year.isEmpty {
- v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
- }
- return v
- }
- private func bulletRow(_ text: String, compact: Bool) -> NSView {
- let marker: String = template.family == .minimal ? "·" : "•"
- let dot = NSTextField(labelWithString: marker)
- dot.font = style.bulletMarkerFont
- dot.textColor = style.bulletMarkerColor
- dot.translatesAutoresizingMaskIntoConstraints = false
- let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
- let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
- let row = NSStackView(views: [dot, body])
- row.orientation = .horizontal
- row.spacing = template.family == .creative ? 10 : 8
- row.alignment = .top
- dot.setContentHuggingPriority(.required, for: .horizontal)
- return row
- }
- private func paragraph(_ text: String, compact: Bool) -> NSTextField {
- let font = compact ? style.bodyCompactFont : style.bodyFont
- return label(text, font: font, color: style.ink, maxLines: 0)
- }
- private func sectionHeading(_ raw: String) -> NSTextField {
- let upper = raw.uppercased()
- let s: String
- switch template.sectionLabelStyle {
- case .uppercase: s = upper
- case .slashed: s = "// \(upper)"
- case .bracketed: s = "[ \(upper) ]"
- }
- let t = NSTextField(labelWithString: s)
- t.font = style.sectionFont
- t.textColor = style.sectionInk
- t.alignment = .left
- return t
- }
- private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
- let isWrapping = maxLines == 0
- let t: NSTextField
- if isWrapping {
- t = NSTextField(wrappingLabelWithString: string)
- t.maximumNumberOfLines = 0
- } else {
- t = NSTextField(labelWithString: string)
- t.maximumNumberOfLines = maxLines
- }
- t.font = font
- t.textColor = color
- t.alignment = .left
- return t
- }
- private func displayable(_ value: String, placeholder: String) -> String {
- let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return t.isEmpty ? placeholder : t
- }
- private func nonEmpty(_ value: String) -> String? {
- let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
- return t.isEmpty ? nil : t
- }
- private static func bulletChunks(from text: String) -> [String] {
- let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty { return [] }
- let byNewline = trimmed.components(separatedBy: .newlines)
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- if byNewline.count > 1 { return byNewline }
- let byBullet = trimmed.split(separator: "•")
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- if byBullet.count > 1 { return byBullet.map { String($0) } }
- return [trimmed]
- }
- }
- // MARK: - Payload helpers
- private extension WorkExperiencePayload {
- var isEffectivelyEmpty: Bool {
- [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- }
- }
- private extension EducationPayload {
- var isEffectivelyEmpty: Bool {
- [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- }
- }
|